latest update
This commit is contained in:
1 parent
9ad842dcd5
commit
062c0e896f
42 files changed
+6497
-2366
No files matched your search
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +146,9 @@ 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);
|
||||||
})
|
})
|
||||||
->get()
|
->get()
|
||||||
->sortByDesc(function ($video) use ($videoIds) {
|
->sortByDesc(function ($video) use ($videoIds) {
|
||||||
@@ -136,9 +164,9 @@ 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);
|
||||||
})
|
})
|
||||||
->latest()
|
->latest()
|
||||||
->paginate(12);
|
->paginate(12);
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -23,6 +24,7 @@ class VideoController extends Controller
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$videos = Video::public()->latest()->paginate(12);
|
$videos = Video::public()->latest()->paginate(12);
|
||||||
|
|
||||||
return view('videos.index', compact('videos'));
|
return view('videos.index', compact('videos'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +37,9 @@ 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);
|
->paginate(12);
|
||||||
@@ -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,11 +191,11 @@ 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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,14 +203,37 @@ class VideoController extends Controller
|
|||||||
// Load comments with user relationship
|
// Load comments with user relationship
|
||||||
$video->load(['comments.user', 'comments.replies.user']);
|
$video->load(['comments.user', 'comments.replies.user']);
|
||||||
|
|
||||||
|
// 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 edit(Video $video, Request $request)
|
public function edit(Video $video, Request $request)
|
||||||
@@ -219,7 +244,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 +256,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 +282,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 +306,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 +322,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 +342,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 +356,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 +371,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 +381,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 +392,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,17 +406,17 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -406,36 +431,68 @@ class VideoController extends Controller
|
|||||||
$hours = min(max($hours, 24), 168); // Between 24h and 7 days
|
$hours = min(max($hours, 24), 168); // Between 24h and 7 days
|
||||||
$limit = min(max($limit, 10), 100);
|
$limit = min(max($limit, 10), 100);
|
||||||
|
|
||||||
// Get trending videos using the scope with raw score calculation
|
// Get all public ready videos first
|
||||||
$trendingVideos = \DB::table('videos')
|
$videos = Video::public()
|
||||||
->select('videos.*',
|
|
||||||
\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, 1 - 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
|
|
||||||
) as trending_score')
|
|
||||||
)
|
|
||||||
->where('visibility', 'public')
|
|
||||||
->where('status', 'ready')
|
->where('status', 'ready')
|
||||||
->where('created_at', '>=', now()->subDays(10))
|
->where('created_at', '>=', now()->subDays(10))
|
||||||
->having('trending_score', '>', 0)
|
->with('user')
|
||||||
->orderByDesc('trending_score')
|
|
||||||
->limit($limit)
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Load user relationship
|
// Calculate trending score for each video
|
||||||
$trendingVideos = $trendingVideos->map(function ($video) {
|
$videos = $videos->map(function ($video) use ($hours) {
|
||||||
$video->user = \App\Models\User::find($video->user_id);
|
$recentViews = \DB::table('video_views')
|
||||||
$video->view_count = \DB::table('video_views')->where('video_id', $video->id)->count();
|
->where('video_id', $video->id)
|
||||||
$video->like_count = \DB::table('video_likes')->where('video_id', $video->id)->count();
|
->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;
|
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', [
|
return view('videos.trending', [
|
||||||
'videos' => $trendingVideos,
|
'videos' => $trendingVideos,
|
||||||
'hours' => $hours,
|
'hours' => $hours,
|
||||||
'limit' => $limit,
|
'limit' => $limit,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shorts page
|
||||||
|
public function shorts(Request $request)
|
||||||
|
{
|
||||||
|
$videos = Video::public()
|
||||||
|
->where('is_shorts', true)
|
||||||
|
->where('status', 'ready')
|
||||||
|
->with('user')
|
||||||
|
->latest()
|
||||||
|
->paginate(12);
|
||||||
|
|
||||||
|
return view('videos.shorts', compact('videos'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-3
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
-31
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -135,16 +169,17 @@ class Video extends Model
|
|||||||
if ($user) {
|
if ($user) {
|
||||||
return $query->where(function ($q) use ($user) {
|
return $query->where(function ($q) use ($user) {
|
||||||
$q->where('visibility', '!=', 'private')
|
$q->where('visibility', '!=', 'private')
|
||||||
->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,69 +245,143 @@ class Video extends Model
|
|||||||
{
|
{
|
||||||
return $this->comments()->count();
|
return $this->comments()->count();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Get recent views count (within hours)
|
// Get recent views count (within hours)
|
||||||
public function getRecentViews( = 48)
|
public function getRecentViews($hours = 48)
|
||||||
{
|
{
|
||||||
return \DB::table('video_views')
|
return \DB::table('video_views')
|
||||||
->where('video_id', ->id)
|
->where('video_id', $this->id)
|
||||||
->where('watched_at', '>=', now()->subHours())
|
->where('watched_at', '>=', now()->subHours($hours))
|
||||||
->count();
|
->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get views in last 24 hours (for velocity calculation)
|
// Get views in last 24 hours (for velocity calculation)
|
||||||
public function getViewsLast24Hours()
|
public function getViewsLast24Hours()
|
||||||
{
|
{
|
||||||
return ->getRecentViews(24);
|
return $this->getRecentViews(24);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate trending score (YouTube-style algorithm)
|
// Calculate trending score (YouTube-style algorithm)
|
||||||
public function getTrendingScore( = 48)
|
public function getTrendingScore($hours = 48)
|
||||||
{
|
{
|
||||||
= ->getRecentViews();
|
$recentViews = $this->getRecentViews($hours);
|
||||||
|
|
||||||
// Don't include videos older than 10 days
|
// Don't include videos older than 10 days
|
||||||
if (->created_at->diffInDays(now()) > 10) {
|
if ($this->created_at->diffInDays(now()) > 10) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't include videos with no recent views
|
// Don't include videos with no recent views
|
||||||
if ( < 5) {
|
if ($recentViews < 5) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate view velocity (views per hour in last 48 hours)
|
// Calculate view velocity (views per hour in last 48 hours)
|
||||||
= / ;
|
$velocity = $recentViews / $hours;
|
||||||
|
|
||||||
// Recency bonus: newer videos get a boost
|
// Recency bonus: newer videos get a boost
|
||||||
= ->created_at->diffInHours(now());
|
$ageHours = $this->created_at->diffInHours(now());
|
||||||
= max(0, 1 - ( / 240)); // Decreases over 10 days
|
$recencyBonus = max(0, 1 - ($ageHours / 240)); // Decreases over 10 days
|
||||||
|
|
||||||
// Like count bonus
|
// Like count bonus
|
||||||
= ->like_count * 0.1;
|
$likeBonus = $this->like_count * 0.1;
|
||||||
|
|
||||||
// Calculate final score
|
// Calculate final score
|
||||||
// Weight: 70% recent views, 15% velocity, 10% recency, 5% likes
|
// Weight: 70% recent views, 15% velocity, 10% recency, 5% likes
|
||||||
= ( * 0.70) +
|
$score = ($recentViews * 0.70) +
|
||||||
( * 100 * 0.15) +
|
($velocity * 100 * 0.15) +
|
||||||
( * 50 * 0.10) +
|
($recencyBonus * 50 * 0.10) +
|
||||||
( * 0.05);
|
($likeBonus * 0.05);
|
||||||
|
|
||||||
return round(, 2);
|
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
|
// Scope for trending videos
|
||||||
public function scopeTrending(, = 48, = 50)
|
public function scopeTrending($query, $hours = 48, $limit = 50)
|
||||||
{
|
{
|
||||||
return ->public()
|
return $query->public()
|
||||||
->where('status', 'ready')
|
->where('status', 'ready')
|
||||||
->where('created_at', '>=', now()->subDays(10))
|
->where('created_at', '>=', now()->subDays(10))
|
||||||
->orderByDesc(\DB::raw('(
|
->orderByDesc(\DB::raw('(
|
||||||
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL ' . . ' 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)) * 0.70 +
|
||||||
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL ' . . ' HOUR)) / ' . . ' * 100 * 0.15 +
|
(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, 1 - TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) / 240) * 50 * 0.10 +
|
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
|
(SELECT COUNT(*) FROM video_likes vl WHERE vl.video_id = videos.id) * 0.1 * 0.05
|
||||||
)'))
|
)'))
|
||||||
->limit();
|
->limit($limit);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'name' => env('APP_NAME', 'Laravel'),
|
'name' => env('APP_NAME', 'TAKEONE'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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') }}">
|
||||||
@@ -90,6 +93,7 @@
|
|||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
margin: 0 40px;
|
margin: 0 40px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-search {
|
.yt-search {
|
||||||
@@ -438,8 +442,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;">
|
||||||
@@ -814,8 +823,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<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) -->
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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/[email protected]/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/[email protected]/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
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
@if($user->avatar)
|
<div class="profile-banner"></div>
|
||||||
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar">
|
<div class="profile-content text-center">
|
||||||
@else
|
@if($user->avatar)
|
||||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar">
|
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar">
|
||||||
@endif
|
@else
|
||||||
|
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar">
|
||||||
<h1 class="profile-name">{{ $user->name }}</h1>
|
@endif
|
||||||
<p class="profile-email">{{ $user->email }}</p>
|
<h1 class="profile-name">{{ $user->name }}</h1>
|
||||||
|
@if($user->username)
|
||||||
<div class="profile-stats justify-content-center">
|
<p class="profile-username">@ {{ $user->username }}</p>
|
||||||
<div class="profile-stat">
|
@endif
|
||||||
<div class="profile-stat-value">{{ $user->videos->count() }}</div>
|
@if($user->bio)
|
||||||
<div class="profile-stat-label">Videos</div>
|
<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>
|
</div>
|
||||||
<div class="profile-stat">
|
@if($user->hasSocialLinks())
|
||||||
<div class="profile-stat-value">{{ $user->likes->count() }}</div>
|
<div class="social-links justify-content-center">
|
||||||
<div class="profile-stat-label">Likes</div>
|
@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
|
||||||
</div>
|
@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
|
||||||
<div class="profile-stat">
|
@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
|
||||||
<div class="profile-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</div>
|
@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
|
||||||
<div class="profile-stat-label">Total Views</div>
|
@if($user->linkedin)<a href="https://linkedin.com/in/{{ $user->linkedin }}" target="_blank" class="social-link linkedin" title="LinkedIn"><i class="bi bi-linkedin"></i></a>@endif
|
||||||
|
@if($user->tiktok)<a href="https://tiktok.com/@{{ $user->tiktok }}" target="_blank" class="social-link tiktok" title="TikTok"><i class="bi bi-tiktok"></i></a>@endif
|
||||||
|
@if($user->website)<a href="{{ $user->website_url }}" target="_blank" class="social-link" title="Website"><i class="bi bi-globe"></i></a>@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="profile-stats justify-content-center">
|
||||||
|
<a href="{{ route('channel', $user->id) }}" class="profile-stat"><div class="profile-stat-value">{{ $user->videos->count() }}</div><div class="profile-stat-label">Videos</div></a>
|
||||||
|
<div class="profile-stat"><div class="profile-stat-value">{{ number_format($user->subscriber_count) }}</div><div class="profile-stat-label">Subscribers</div></div>
|
||||||
|
<div class="profile-stat"><div class="profile-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</div><div class="profile-stat-label">Views</div></div>
|
||||||
|
<div class="profile-stat"><div class="profile-stat-value">{{ $user->likes->count() }}</div><div class="profile-stat-label">Likes</div></div>
|
||||||
</div>
|
</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>
|
||||||
|
</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>
|
</div>
|
||||||
|
@if($user->videos->count() > 4)
|
||||||
|
<div class="text-center mt-4"><a href="{{ route('channel', $user->id) }}" class="btn-secondary">View All Videos <i class="bi bi-arrow-right"></i></a></div>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<div class="empty-state"><i class="bi bi-camera-video"></i><p>No videos yet</p></div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
|
||||||
@csrf
|
|
||||||
@method('PUT')
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Avatar</label>
|
|
||||||
<input type="file" name="avatar" class="form-input" accept="image/*">
|
|
||||||
<small class="text-muted">Max size: 5MB. Supported: JPG, PNG, WebP</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn-primary">Save Changes</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-4">
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<h2 class="form-title">Quick Links</h2>
|
<h2 class="form-title"><i class="bi bi-pencil-square"></i> Edit Profile</h2>
|
||||||
|
@if(session('success'))<div class="alert alert-success">{{ session('success') }}</div>@endif
|
||||||
|
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<div class="form-group"><label class="form-label">Name</label><input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required></div>
|
||||||
|
<div class="form-group"><label class="form-label">Avatar</label><input type="file" name="avatar" class="form-input" accept="image/*"><small class="form-hint">Max: 5MB</small></div>
|
||||||
|
<div class="form-group"><label class="form-label">Bio</label><textarea name="bio" class="form-textarea" placeholder="Tell us about yourself...">{{ old('bio', $user->bio) }}</textarea></div>
|
||||||
|
<div class="form-group"><label class="form-label">Location</label><input type="text" name="location" class="form-input" value="{{ old('location', $user->location) }}" placeholder="City, Country"></div>
|
||||||
|
<div class="form-group"><label class="form-label">Birthday</label><input type="date" name="birthday" class="form-input" value="{{ old('birthday', $user->birthday) }}"></div>
|
||||||
|
<button type="submit" class="btn-primary w-100"><i class="bi bi-check-lg"></i> Save Changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a href="{{ route('channel', $user->id) }}" class="btn-primary d-inline-block text-decoration-none">
|
<div class="form-card">
|
||||||
<i class="bi bi-play-btn"></i> View My Channel
|
<h2 class="form-title"><i class="bi bi-share"></i> Social Links</h2>
|
||||||
</a>
|
<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>
|
||||||
|
|
||||||
<a href="{{ route('settings') }}" class="btn-primary d-inline-block text-decoration-none ms-2">
|
<div class="form-card">
|
||||||
<i class="bi bi-gear"></i> Settings
|
<h2 class="form-title"><i class="bi bi-link-45deg"></i> Quick Links</h2>
|
||||||
</a>
|
<div class="d-flex flex-column gap-3">
|
||||||
|
<a href="{{ route('channel', $user->id) }}" class="btn-secondary text-center"><i class="bi bi-play-btn"></i> View My Channel</a>
|
||||||
|
<a href="{{ route('settings') }}" class="btn-secondary text-center"><i class="bi bi-gear"></i> Account Settings</a>
|
||||||
|
<a href="{{ route('history') }}" class="btn-secondary text-center"><i class="bi bi-clock-history"></i> Watch History</a>
|
||||||
|
<a href="{{ route('liked') }}" class="btn-secondary text-center"><i class="bi bi-heart"></i> Liked Videos</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,353 @@
|
|||||||
|
@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>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
@if($videos->hasPages())
|
||||||
|
<div class="shorts-pagination">
|
||||||
|
{{ $videos->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Shorts Page - YouTube Shorts Style */
|
||||||
|
.shorts-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-logo-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: linear-gradient(135deg, #ff0050, #ff6b6b);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-brand h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shorts Grid - 4 columns on desktop */
|
||||||
|
.shorts-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shorts Card */
|
||||||
|
.shorts-card {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-card:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-thumbnail-wrapper {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 9/16;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-card:hover .shorts-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-views {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-time {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-details {
|
||||||
|
padding: 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-channel-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-channel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-avatar, .shorts-avatar-placeholder {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-avatar-placeholder {
|
||||||
|
background: #666;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-channel-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-ago {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.shorts-empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
background: linear-gradient(135deg, #ff0050, #ff6b6b);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-empty-icon i {
|
||||||
|
font-size: 36px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-empty-state h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-empty-state p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-upload-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #ff0050;
|
||||||
|
color: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-upload-btn:hover {
|
||||||
|
background: #e60048;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.shorts-pagination {
|
||||||
|
margin-top: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-pagination .pagination {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-pagination .page-link {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-pagination .page-item.active .page-link {
|
||||||
|
background: #ff0050;
|
||||||
|
border-color: #ff0050;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.shorts-container {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shorts-container {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.shorts-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shorts-brand {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -177,13 +177,13 @@
|
|||||||
<div class="trending-header">
|
<div class="trending-header">
|
||||||
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
||||||
<div class="trending-filters">
|
<div class="trending-filters">
|
||||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ == 24 ? 'active' : '' }}">Today</a>
|
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a>
|
||||||
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ == 48 ? 'active' : '' }}">This Week</a>
|
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a>
|
||||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ == 168 ? 'active' : '' }}">This Month</a>
|
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(())
|
@if($videos->isEmpty())
|
||||||
<div class="empty-trending">
|
<div class="empty-trending">
|
||||||
<i class="bi bi-play-circle"></i>
|
<i class="bi bi-play-circle"></i>
|
||||||
<h3>No trending videos yet</h3>
|
<h3>No trending videos yet</h3>
|
||||||
@@ -191,25 +191,8 @@
|
|||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="yt-video-grid">
|
<div class="yt-video-grid">
|
||||||
@foreach( as )
|
@foreach($videos as $video)
|
||||||
<a href="{{ route('videos.show', ) }}" class="yt-video-card">
|
<x-video-card :video="$video" />
|
||||||
<div class="yt-video-thumb">
|
|
||||||
<img src="{{ }}" alt="{{ }}" loading="lazy">
|
|
||||||
<span class="trending-badge"><i class="bi bi-fire"></i> Trending</span>
|
|
||||||
@if()<span class="yt-video-duration">{{ gmdate('i:s', ) }}</span>@endif
|
|
||||||
</div>
|
|
||||||
<div class="yt-video-meta">
|
|
||||||
@if()<img src="{{ ->avatar_url }}" alt="{{ ->name }}" class="yt-video-avatar">@endif
|
|
||||||
<div class="yt-video-info">
|
|
||||||
<h3 class="yt-video-title">{{ }}</h3>
|
|
||||||
<div class="yt-video-channel"><a href="#">{{ ->name ?? 'Unknown' }}</a></div>
|
|
||||||
<div class="yt-video-stats">
|
|
||||||
{{ number_format() }} views
|
|
||||||
@if( > 0) • {{ number_format() }} likes @endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
@@ -93,7 +253,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 +317,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 +338,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 +384,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 +442,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) -->
|
||||||
@@ -259,24 +506,28 @@
|
|||||||
</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="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
||||||
@@ -288,10 +539,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="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>
|
||||||
@@ -301,8 +555,9 @@
|
|||||||
</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="yt-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
|
||||||
@@ -310,88 +565,101 @@
|
|||||||
</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"
|
||||||
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
|
||||||
<span>{{ number_format($video->view_count) }} views</span>
|
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
||||||
<span>•</span>
|
<span>{{ number_format($video->view_count) }} views</span>
|
||||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
<span>•</span>
|
||||||
</div>
|
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||||
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
</div>
|
||||||
<div class="description-content" id="descriptionContent">
|
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
||||||
@if($needsExpand)
|
<div class="description-content" id="descriptionContent">
|
||||||
<div class="description-short" id="descShort">
|
@if ($needsExpand)
|
||||||
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
<div class="description-short" id="descShort">
|
||||||
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
|
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
||||||
</div>
|
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
|
||||||
<div class="description-full" id="descFull" style="display: none;">
|
</div>
|
||||||
|
<div class="description-full" id="descFull" style="display: none;">
|
||||||
|
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="toggleDescription()" id="descToggleBtn"
|
||||||
|
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
|
||||||
|
Show more
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||||
</div>
|
@endif
|
||||||
<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;">
|
</div>
|
||||||
Show more
|
|
||||||
</button>
|
|
||||||
@else
|
|
||||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function toggleDescription() {
|
function toggleDescription() {
|
||||||
const descShort = document.getElementById('descShort');
|
const descShort = document.getElementById('descShort');
|
||||||
const descFull = document.getElementById('descFull');
|
const descFull = document.getElementById('descFull');
|
||||||
const toggleBtn = document.getElementById('descToggleBtn');
|
const toggleBtn = document.getElementById('descToggleBtn');
|
||||||
|
|
||||||
if (descShort.style.display !== 'none') {
|
if (descShort.style.display !== 'none') {
|
||||||
descShort.style.display = 'none';
|
descShort.style.display = 'none';
|
||||||
descFull.style.display = 'block';
|
descFull.style.display = 'block';
|
||||||
toggleBtn.textContent = 'Show less';
|
toggleBtn.textContent = 'Show less';
|
||||||
} else {
|
} else {
|
||||||
descShort.style.display = 'block';
|
descShort.style.display = 'block';
|
||||||
descFull.style.display = 'none';
|
descFull.style.display = 'none';
|
||||||
toggleBtn.textContent = 'Show more';
|
toggleBtn.textContent = 'Show more';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</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;
|
||||||
</style>
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-description-box .description-text p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-description-box .description-text a {
|
||||||
|
color: #3ea6ff;
|
||||||
|
}
|
||||||
|
</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;"
|
||||||
|
alt="{{ Auth::user()->name }}">
|
||||||
<div style="flex: 1;">
|
<div style="flex: 1;">
|
||||||
<textarea
|
<textarea id="commentBody" class="form-control" placeholder="Add a comment... Use @ to mention someone" rows="3"
|
||||||
id="commentBody"
|
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>
|
||||||
class="form-control"
|
|
||||||
placeholder="Add a comment... Use @ to mention someone"
|
|
||||||
rows="3"
|
|
||||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
|
|
||||||
></textarea>
|
|
||||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||||
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
<button type="button" class="yt-action-btn"
|
||||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
|
onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
||||||
|
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;"
|
||||||
|
onclick="submitComment({{ $video->id }})">Comment</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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,85 +668,188 @@
|
|||||||
@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>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function submitComment(videoId) {
|
function submitComment(videoId) {
|
||||||
const body = document.getElementById('commentBody').value.trim();
|
const body = document.getElementById('commentBody').value.trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|
||||||
fetch(`/videos/${videoId}/comments`, {
|
fetch(`/videos/${videoId}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'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(data => {
|
})
|
||||||
if (data.success) {
|
.then(response => response.json())
|
||||||
document.getElementById('commentBody').value = '';
|
.then(data => {
|
||||||
location.reload();
|
if (data.success) {
|
||||||
}
|
document.getElementById('commentBody').value = '';
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteComment(commentId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
fetch(`/comments/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const commentTexts = document.querySelectorAll('.comment-body');
|
||||||
|
commentTexts.forEach(text => {
|
||||||
|
const html = text.innerHTML.replace(/@(\w+)/g,
|
||||||
|
'<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
||||||
|
text.innerHTML = html;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function deleteComment(commentId) {
|
function submitReply(videoId, parentId) {
|
||||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
||||||
|
const body = textarea.value.trim();
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
fetch(`/comments/${commentId}`, {
|
fetch(`/videos/${videoId}/comments`, {
|
||||||
method: 'DELETE',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
'Content-Type': 'application/json',
|
||||||
}
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
})
|
},
|
||||||
.then(response => response.json())
|
body: JSON.stringify({
|
||||||
.then(data => {
|
body: body,
|
||||||
if (data.success) {
|
parent_id: parentId
|
||||||
location.reload();
|
})
|
||||||
}
|
})
|
||||||
});
|
.then(response => response.json())
|
||||||
}
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
location.reload();
|
||||||
const commentTexts = document.querySelectorAll('.comment-body');
|
}
|
||||||
commentTexts.forEach(text => {
|
});
|
||||||
const html = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
}
|
||||||
text.innerHTML = html;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function submitReply(videoId, parentId) {
|
|
||||||
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
|
||||||
const body = textarea.value.trim();
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
fetch(`/videos/${videoId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ body: body, parent_id: parentId })
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar - Up Next / Recommendations -->
|
||||||
<div class="yt-sidebar-container">
|
<div class="yt-sidebar-container">
|
||||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||||
<div class="text-secondary">More videos coming soon...</div>
|
<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>
|
||||||
|
@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,26 +857,112 @@
|
|||||||
@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() {
|
||||||
openEditVideoModal({{ $video->id }});
|
openEditVideoModal({{ $video->id }});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@endauth
|
@endauth
|
||||||
@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) {
|
||||||
|
// Only handle if not typing in an input
|
||||||
|
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
|
||||||
@@ -34,6 +34,108 @@
|
|||||||
|
|
||||||
.video-container video { width: 100%; height: 100%; object-fit: contain; }
|
.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 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -178,7 +280,6 @@
|
|||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.yt-main { margin-left: 0; flex-direction: column; }
|
.yt-main { margin-left: 0; flex-direction: column; }
|
||||||
.yt-sidebar-container { width: 100%; }
|
.yt-sidebar-container { width: 100%; }
|
||||||
.yt-header-center { display: none; }
|
|
||||||
.sidebar-video-card { flex-direction: column; }
|
.sidebar-video-card { flex-direction: column; }
|
||||||
.sidebar-thumb { width: 100%; }
|
.sidebar-thumb { width: 100%; }
|
||||||
|
|
||||||
@@ -241,6 +342,47 @@
|
|||||||
<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 Trophy Icon (Match Type) -->
|
<!-- Video Title with Trophy Icon (Match Type) -->
|
||||||
@@ -475,10 +617,91 @@
|
|||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar - Up Next / Recommendations -->
|
||||||
<div class="yt-sidebar-container">
|
<div class="yt-sidebar-container">
|
||||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||||
<div class="text-secondary">More videos coming soon...</div>
|
<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>
|
||||||
|
@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
|
||||||
|
</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">More videos coming soon...</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -497,15 +720,80 @@
|
|||||||
@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
|
||||||
@@ -34,6 +34,108 @@
|
|||||||
|
|
||||||
.video-container video { width: 100%; height: 100%; object-fit: contain; }
|
.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 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -178,7 +280,6 @@
|
|||||||
@media (max-width: 991px) {
|
@media (max-width: 991px) {
|
||||||
.yt-main { margin-left: 0; flex-direction: column; }
|
.yt-main { margin-left: 0; flex-direction: column; }
|
||||||
.yt-sidebar-container { width: 100%; }
|
.yt-sidebar-container { width: 100%; }
|
||||||
.yt-header-center { display: none; }
|
|
||||||
.sidebar-video-card { flex-direction: column; }
|
.sidebar-video-card { flex-direction: column; }
|
||||||
.sidebar-thumb { width: 100%; }
|
.sidebar-thumb { width: 100%; }
|
||||||
|
|
||||||
@@ -241,6 +342,47 @@
|
|||||||
<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) -->
|
||||||
@@ -475,10 +617,96 @@
|
|||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar - Up Next / Recommendations -->
|
||||||
<div class="yt-sidebar-container">
|
<div class="yt-sidebar-container">
|
||||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||||
<div class="text-secondary">More videos coming soon...</div>
|
<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>
|
||||||
|
@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>
|
||||||
@@ -497,15 +725,80 @@
|
|||||||
@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
|
||||||
@@ -1,133 +1,57 @@
|
|||||||
<!DOCTYPE html>
|
@extends('layouts.app')
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
<title>Laravel</title>
|
@section('title', 'Welcome - ' . config('app.name'))
|
||||||
|
|
||||||
<!-- Fonts -->
|
@section('content')
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<div class="welcome-container">
|
||||||
<link href="https://fonts.bunny.net/css?family=figtree:400,600&display=swap" rel="stylesheet" />
|
<div class="text-center mb-5">
|
||||||
|
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" style="height: 60px;" class="mb-4">
|
||||||
|
<h1 class="mb-3">Welcome to {{ config('app.name') }}</h1>
|
||||||
|
<p class="text-secondary">Discover, watch, and share videos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Styles -->
|
<div class="row justify-content-center">
|
||||||
<style>
|
<div class="col-md-8">
|
||||||
/* ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com */*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}::after,::before{--tw-content:''}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:Figtree, sans-serif;font-feature-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*, ::before, ::after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::-webkit-backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.relative{position:relative}.mx-auto{margin-left:a
Line truncated
|
<div class="d-grid gap-3">
|
||||||
</style>
|
@auth
|
||||||
</head>
|
<a href="{{ route('videos.index') }}" class="btn btn-lg" style="background: var(--brand-red); color: white;">
|
||||||
<body class="antialiased">
|
<i class="bi bi-play-circle-fill me-2"></i>
|
||||||
<div class="relative sm:flex sm:justify-center sm:items-center min-h-screen bg-dots-darker bg-center bg-gray-100 dark:bg-dots-lighter dark:bg-gray-900 selection:bg-red-500 selection:text-white">
|
Browse Videos
|
||||||
@if (Route::has('login'))
|
</a>
|
||||||
<div class="sm:fixed sm:top-0 sm:right-0 p-6 text-right z-10">
|
@else
|
||||||
@auth
|
<a href="{{ route('login') }}" class="btn btn-lg" style="background: var(--brand-red); color: white;">
|
||||||
<a href="{{ url('/home') }}" class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Home</a>
|
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||||
@else
|
Sign In
|
||||||
<a href="{{ route('login') }}" class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Log in</a>
|
</a>
|
||||||
|
<a href="{{ route('register') }}" class="btn btn-lg btn-outline-light">
|
||||||
@if (Route::has('register'))
|
<i class="bi bi-person-plus me-2"></i>
|
||||||
<a href="{{ route('register') }}" class="ml-4 font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Register</a>
|
Create Account
|
||||||
@endif
|
</a>
|
||||||
@endauth
|
@endauth
|
||||||
</div>
|
<a href="{{ route('videos.trending') }}" class="btn btn-lg btn-outline-light">
|
||||||
@endif
|
<i class="bi bi-fire me-2"></i>
|
||||||
|
Trending Videos
|
||||||
<div class="max-w-7xl mx-auto p-6 lg:p-8">
|
</a>
|
||||||
<div class="flex justify-center">
|
|
||||||
<svg viewBox="0 0 62 65" fill="none" xmlns="http://www.w3.org/2000/svg" class="h-16 w-auto bg-gray-100 dark:bg-gray-900">
|
|
||||||
<path d="M61.8548 14.6253C61.8778 14.7102 61.8895 14.7978 61.8897 14.8858V28.5615C61.8898 28.737 61.8434 28.9095 61.7554 29.0614C61.6675 29.2132 61.5409 29.3392 61.3887 29.4265L49.9104 36.0351V49.1337C49.9104 49.4902 49.7209 49.8192 49.4118 49.9987L25.4519 63.7916C25.3971 63.8227 25.3372 63.8427 25.2774 63.8639C25.255 63.8714 25.2338 63.8851 25.2101 63.8913C25.0426 63.9354 24.8666 63.9354 24.6991 63.8913C24.6716 63.8838 24.6467 63.8689 24.6205 63.8589C24.5657 63.8389 24.5084 63.8215 24.456 63.7916L0.501061 49.9987C0.348882 49.9113 0.222437 49.7853 0.134469 49.6334C0.0465019 49.4816 0.000120578 49.3092 0 49.1337L0 8.10652C0 8.01678 0.0124642 7.92953 0.0348998 7.84477C0.0423783 7.8161 0.0598282 7.78993 0.0697995 7.76126C0.0884958 7.70891 0.105946 7.65531 0.133367 7.6067C0.152063 7.5743 0.179485 7.54812 0.20192 7.51821C0.230588 7.47832 0.256763 7.43719 0.290416 7.40229C0.319084 7.37362 0.356476 7.35243 0.388883 7.32751C0.425029 7.29759 0.457436 7.26518 0.498568 7.2415L12.4779 0.345059C12.6296 0.257786 12.8015 0.211853 12.9765 0.211853C13.1515 0.211853 13.3234 0.257786 13.475 0.345059L25.4531 7.2415H25.4556C25.4955 7.26643 25.5292 7.29759 25.5653 7.32626C25.5977 7.35119 25.6339 7.37362 25.6625 7.40104C25.6974 7.43719 25.7224 7.47832 25.7523 7.51821C25.7735 7.54812 25.8021 7.5743 25.8196 7.6067C25.8483 7.65656 25.8645 7.70891 25.8844 7.76126C25.8944 7.78993 25.9118 7.8161 25.9193 7.84602C25.9423 7.93096 25.954 8.01853 25.9542 8.10652V33.7317L35.9355 27.9844V14.8846C35.9355 14.7973 35.948 14.7088 35.9704 14.6253C35.9792 14.5954 35.9954 14.5692 36.0053 14.5405C36.0253 14.4882 36.0427 14.4346 36.0702 14.386C36.0888 14.3536 36.1163 14.3274 36.1375 14.2975C36.1674 14.2576 36.1923 14.2165 36.2272 14.1816C36.2559 14.1529 36.292 14.1317 36.3244 14.1068C36.3618 14.0769 36.3942 14.0445 36.4341 14.0208L48.4147 7.12434C48.5663 7.03694 48.7383 6.99094 48.9133 6.99094C49.0883 6.99094 49.2602 7.03694 49.4118 7.12434L61.3899 14.0208C61.4323 14.0457 61.4647 14.0769 61.5021 14.1055C61.5333 14.1305 61.5694 14.1529 61.5981 14.1803C61.633 14.2165 61.6579 14.2576 61.6878 14.2975C61.7103 14.3274 61.7377 14.3536 61.7551 14.386C61.7838 14.4346 61.8 14.4882 61.8199 14.5405C61.8312 14.5692 61.8474 14.5954 61.8548 14.6253ZM59.893 27.9844V16.6121L55.7013 19.0252L49.9104 22.3593V33.7317L59.8942 27.9844H59.893ZM47.9149 48.5566V37.1768L42.2187 40.4299L25.953 49.7133V61.2003L47.9149 48.5566ZM1.99677 9.83281V48.5566L23.9562 61.199V49.7145L12.4841 43.2219L12.4804 43.2194L12.4754 43.2169C12.4368 43.1945 12.4044 43.1621 12.3682 43.1347C12.3371 43.1097 12.3009 43.0898 12.2735 43.0624L12.271 43.0586C12.2386 43.0275 12.2162 42.9888 12.1887 42.9539C12.1638 42.9203 12.1339 42.8916 12.114 42.8567L12.1127 42.853C12.0903 42.8156 12.0766 42.7707 12.0604 42.7283C12.0442 42.6909 12.023 42.656 12.013 42.6161C12.0005 42.5688 11.998 42.5177 11.9931 42.4691C11.9881 42.4317 11.9781 42.3943 11.9781 42.3569V15.5801L6.18848 12.2446L1.99677 9.83281ZM12.9777 2.36177L2.99764 8.10652L12.9752 13.8513L22.9541 8.10527L12.9752 2.36177H12.9777ZM18.1678 38.2138L23.9574 34.8809V9.83281L19.7657 12.2459L13.9749 15.5801V40.6281L18.1678 38.2138ZM48.9133 9.14105L38.9344 14.8858L48.9133 20.6305L58.8909 14.8846L48.9133 9.14105ZM47.9149 22.3593L42.124 19.0252L37.9323 16.6121V27.9844L43.7219 31.3174L47.9149 33.7317V22.3593ZM24.9533 47.987L39.59 39.631L46.9065 35.4555L36.9352 29.7145L25.4544 36.3242L14.9907 42.3482L24.9533 47.987Z" fill="#FF2D20"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-16">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
|
||||||
<a href="https://laravel.com/docs" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500">
|
|
||||||
<div>
|
|
||||||
<div class="h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="w-7 h-7 stroke-red-500">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="mt-6 text-xl font-semibold text-gray-900 dark:text-white">Documentation</h2>
|
|
||||||
|
|
||||||
<p class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
|
|
||||||
Laravel has wonderful documentation covering every aspect of the framework. Whether you are a newcomer or have prior experience with Laravel, we recommend reading our documentation from beginning to end.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="self-center shrink-0 stroke-red-500 w-6 h-6 mx-6">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://laracasts.com" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500">
|
|
||||||
<div>
|
|
||||||
<div class="h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="w-7 h-7 stroke-red-500">
|
|
||||||
<path stroke-linecap="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="mt-6 text-xl font-semibold text-gray-900 dark:text-white">Laracasts</h2>
|
|
||||||
|
|
||||||
<p class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
|
|
||||||
Laracasts offers thousands of video tutorials on Laravel, PHP, and JavaScript development. Check them out, see for yourself, and massively level up your development skills in the process.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="self-center shrink-0 stroke-red-500 w-6 h-6 mx-6">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://laravel-news.com" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500">
|
|
||||||
<div>
|
|
||||||
<div class="h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="w-7 h-7 stroke-red-500">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="mt-6 text-xl font-semibold text-gray-900 dark:text-white">Laravel News</h2>
|
|
||||||
|
|
||||||
<p class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
|
|
||||||
Laravel News is a community driven portal and newsletter aggregating all of the latest and most important news in the Laravel ecosystem, including new package releases and tutorials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="self-center shrink-0 stroke-red-500 w-6 h-6 mx-6">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500">
|
|
||||||
<div>
|
|
||||||
<div class="h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="w-7 h-7 stroke-red-500">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.115 5.19l.319 1.913A6 6 0 008.11 10.36L9.75 12l-.387.775c-.217.433-.132.956.21 1.298l1.348 1.348c.21.21.329.497.329.795v1.089c0 .426.24.815.622 1.006l.153.076c.433.217.956.132 1.298-.21l.723-.723a8.7 8.7 0 002.288-4.042 1.087 1.087 0 00-.358-1.099l-1.33-1.108c-.251-.21-.582-.299-.905-.245l-1.17.195a1.125 1.125 0 01-.98-.314l-.295-.295a1.125 1.125 0 010-1.591l.13-.132a1.125 1.125 0 011.3-.21l.603.302a.809.809 0 001.086-1.086L14.25 7.5l1.256-.837a4.5 4.5 0 001.528-1.732l.146-.292M6.115 5.19A9 9 0 1017.18 4.64M6.115 5.19A8.965 8.965 0 0112 3c1.929 0 3.716.607 5.18 1.64" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="mt-6 text-xl font-semibold text-gray-900 dark:text-white">Vibrant Ecosystem</h2>
|
|
||||||
|
|
||||||
<p class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
|
|
||||||
Laravel's robust library of first-party tools and libraries, such as <a href="https://forge.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Forge</a>, <a href="https://vapor.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Vapor</a>, <a href="https://nova.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Nova</a>, and <a href="https://envoyer.io" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Envoyer</a> help you take your projects to the next level. Pair them with powerful open source libraries like <a href="https://laravel.com/docs/billing" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Cashier</a>, <a href="https://laravel.com/docs/dusk" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Dusk</a>, <a href="https://laravel.com/docs/broadcasting" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Echo</a>, <a href="https://laravel.com/docs/horizon" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Horizon</a>, <a href="https://laravel.com/docs/sanctum" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Sanctum</a>, <a href="https://laravel.com/docs/telescope" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Telescope</a>, and more.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center mt-16 px-0 sm:items-center sm:justify-between">
|
|
||||||
<div class="text-center text-sm sm:text-left">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center text-sm text-gray-500 dark:text-gray-400 sm:text-right sm:ml-0">
|
|
||||||
Laravel v{{ Illuminate\Foundation\Application::VERSION }} (PHP v{{ PHP_VERSION }})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
</html>
|
|
||||||
|
<div class="text-center mt-5 pt-4 border-top" style="border-color: var(--border-color) !important;">
|
||||||
|
<p class="text-secondary mb-2">Explore our features</p>
|
||||||
|
<div class="d-flex justify-content-center gap-4 flex-wrap">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-play-btn-fill fs-2" style="color: var(--brand-red);"></i>
|
||||||
|
<p class="mt-2 mb-0">Watch Videos</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-cloud-upload fs-2" style="color: var(--brand-red);"></i>
|
||||||
|
<p class="mt-2 mb-0">Upload Content</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-people fs-2" style="color: var(--brand-red);"></i>
|
||||||
|
<p class="mt-2 mb-0">Build Community</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
+39
-9
@@ -1,24 +1,24 @@
|
|||||||
<?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\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('/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 () {
|
||||||
@@ -60,6 +60,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,4 +111,3 @@ Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->gro
|
|||||||
Route::put('/videos/{video}', [SuperAdminController::class, 'updateVideo'])->name('videos.update');
|
Route::put('/videos/{video}', [SuperAdminController::class, 'updateVideo'])->name('videos.update');
|
||||||
Route::delete('/videos/{video}', [SuperAdminController::class, 'deleteVideo'])->name('videos.delete');
|
Route::delete('/videos/{video}', [SuperAdminController::class, 'deleteVideo'])->name('videos.delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
Whitespace-only changes.
Reference in new issue
Block a user