latest update
This commit is contained in:
parent
9ad842dcd5
commit
062c0e896f
63
TODO.md
63
TODO.md
@ -1,35 +1,40 @@
|
||||
# Video Platform Enhancement Tasks - COMPLETED
|
||||
# TODO - Topbar Standardization - COMPLETED
|
||||
|
||||
## Phase 1: Database & Backend ✅
|
||||
- [x] Create comments migration table
|
||||
- [x] Create Comment model
|
||||
- [x] Create CommentController
|
||||
- [x] Add routes for comments
|
||||
- [x] Update Video model with subscriber count
|
||||
## Task: Use same topbar across all pages
|
||||
|
||||
## Phase 2: Video Type Views ✅
|
||||
- [x] Update generic.blade.php with video type icon and enhanced channel info
|
||||
- [x] Update music.blade.php with video type icon and enhanced channel info
|
||||
- [x] Update match.blade.php with video type icon and enhanced channel info
|
||||
### Summary:
|
||||
- Topbar is in a separate file: `resources/views/layouts/partials/header.blade.php`
|
||||
- All layouts now include this header partial
|
||||
|
||||
## Phase 3: Comment Section ✅
|
||||
- [x] Add comment section UI to video views
|
||||
- [x] Add @ mention functionality
|
||||
### Layouts and their pages:
|
||||
|
||||
## Features Implemented:
|
||||
1. Video type icons in red color before title:
|
||||
- music → 🎵 (bi-music-note)
|
||||
- match → 🏆 (bi-trophy)
|
||||
- generic → 🎬 (bi-film)
|
||||
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. Enhanced channel info below title:
|
||||
- Channel picture
|
||||
- Channel name
|
||||
- Number of subscribers
|
||||
- Number of views
|
||||
- Like button with icon and count
|
||||
- Edit & Share buttons
|
||||
2. **layouts/plain.blade.php** (includes header, no sidebar)
|
||||
- auth/login.blade.php
|
||||
- auth/register.blade.php
|
||||
|
||||
3. Comment section:
|
||||
- Users can comment on videos
|
||||
- @ mention support to mention other users/channels
|
||||
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)
|
||||
|
||||
35
TODO_drag_drop_reorder.md
Normal file
35
TODO_drag_drop_reorder.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Drag and Drop Playlist Reordering Implementation
|
||||
|
||||
## Task
|
||||
Add drag-and-drop functionality to reorder videos in a playlist when editing
|
||||
|
||||
## Steps Completed
|
||||
|
||||
1. [x] Add SortableJS library to the project (via CDN in the view)
|
||||
2. [x] Update playlist show.blade.php to add drag-and-drop functionality
|
||||
3. [x] Add CSS styles for drag-and-drop visual feedback
|
||||
4. [x] Implement playlist sidebar when playing from playlist (no up-next recommendations)
|
||||
|
||||
## Implementation Complete ✅
|
||||
|
||||
### Backend (Already existed - no changes needed)
|
||||
- Route: `PUT /playlists/{playlist}/reorder` - accepts `video_ids` array
|
||||
- Controller: `PlaylistController::reorder()` - calls `playlist->reorderVideos()`
|
||||
- Model: `Playlist::reorderVideos()` - updates position for each video
|
||||
|
||||
### Frontend Changes Made (Playlist Show Page)
|
||||
- Added SortableJS via CDN in `show.blade.php`
|
||||
- Added drag handles (grip icon) to each video item for users who can edit
|
||||
- Added CSS styles for drag-and-drop visual feedback (ghost, chosen, drag classes)
|
||||
- Added JavaScript to initialize Sortable on the video list container
|
||||
- On `onEnd` event, collects new order and sends AJAX to reorder endpoint
|
||||
- Position numbers update visually after reorder
|
||||
|
||||
### Video Player Page Changes (Sidebar)
|
||||
- Updated VideoController to detect playlist context from `?playlist=` parameter
|
||||
- Updated all video type views (generic, music, match, show) to show playlist videos in sidebar when viewing from playlist
|
||||
- Shows playlist name and video count in sidebar header
|
||||
- Shows position numbers on each video thumbnail
|
||||
- Links preserve the playlist parameter for continuous playback
|
||||
- Shows "Edit Playlist" button for playlist owners
|
||||
|
||||
41
TODO_new.md
Normal file
41
TODO_new.md
Normal file
@ -0,0 +1,41 @@
|
||||
# TODO - Topbar Standardization - COMPLETED
|
||||
|
||||
## Task: Use same topbar across all pages
|
||||
|
||||
### Summary:
|
||||
- Topbar is in a separate file: `resources/views/layouts/partials/header.blade.php`
|
||||
- All layouts now include this header partial
|
||||
|
||||
### Layouts and their pages:
|
||||
|
||||
1. **layouts/app.blade.php** (includes header + sidebar)
|
||||
- videos/index.blade.php
|
||||
- videos/trending.blade.php
|
||||
- videos/show.blade.php
|
||||
- videos/create.blade.php
|
||||
- videos/edit.blade.php
|
||||
- videos/types/*.blade.php
|
||||
- user/profile.blade.php
|
||||
- user/channel.blade.php
|
||||
- user/history.blade.php
|
||||
- user/liked.blade.php
|
||||
- user/settings.blade.php
|
||||
- welcome.blade.php
|
||||
|
||||
2. **layouts/plain.blade.php** (includes header, no sidebar)
|
||||
- auth/login.blade.php
|
||||
- auth/register.blade.php
|
||||
|
||||
3. **admin/layout.blade.php** (includes header, admin sidebar)
|
||||
- admin/dashboard.blade.php
|
||||
- admin/users.blade.php
|
||||
- admin/videos.blade.php
|
||||
- admin/edit-user.blade.php
|
||||
- admin/edit-video.blade.php
|
||||
|
||||
### Changes Made:
|
||||
- [x] 1. Analyzed current structure
|
||||
- [x] 2. Updated welcome.blade.php to use layouts.app
|
||||
- [x] 3. Verified plain.blade.php includes header (already had it)
|
||||
- [x] 4. Verified admin layout uses header (already had it)
|
||||
- [x] 5. Fixed videos/create.blade.php - hide duplicate header on mobile
|
||||
37
TODO_next_prev_controls.md
Normal file
37
TODO_next_prev_controls.md
Normal file
@ -0,0 +1,37 @@
|
||||
# TODO: Next/Previous Video Controls for Playlist
|
||||
|
||||
## Task
|
||||
Add next and previous video controls to the video player when viewing from a playlist context, plus autoplay toggle.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Modify VideoController.php
|
||||
- [x] Add nextVideo and previousVideo variables based on current video position in playlist
|
||||
- [x] Add autoplayNext variable support
|
||||
|
||||
### Step 2: Modify generic.blade.php
|
||||
- [x] Add Next/Previous control buttons overlay on video player
|
||||
- [x] Add autoplay toggle switch
|
||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
||||
- [x] Style controls to match YouTube-style
|
||||
|
||||
### Step 3: Modify music.blade.php
|
||||
- [x] Add Next/Previous control buttons overlay on video player
|
||||
- [x] Add autoplay toggle switch
|
||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
||||
- [x] Style controls to match YouTube-style
|
||||
|
||||
### Step 4: Modify match.blade.php
|
||||
- [x] Add Next/Previous control buttons overlay on video player
|
||||
- [x] Add autoplay toggle switch
|
||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
||||
- [x] Style controls to match YouTube-style
|
||||
|
||||
## Files Edited
|
||||
1. app/Http/Controllers/VideoController.php
|
||||
2. resources/views/videos/types/generic.blade.php
|
||||
3. resources/views/videos/types/music.blade.php
|
||||
4. resources/views/videos/types/match.blade.php
|
||||
|
||||
## COMPLETED
|
||||
|
||||
31
TODO_open_graph.md
Normal file
31
TODO_open_graph.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Open Graph Implementation Plan
|
||||
|
||||
## Task: Video sharing preview (thumbnail + info) on all platforms
|
||||
|
||||
### Steps to complete:
|
||||
|
||||
1. [x] 1. Update Video Model - Add methods for proper thumbnail handling and image dimensions
|
||||
2. [x] 2. Update videos/show.blade.php - Add comprehensive Open Graph meta tags
|
||||
3. [x] 3. Add video-specific Open Graph tags (og:video, og:video:url, etc.)
|
||||
4. [x] 4. Add enhanced Twitter Card meta tags
|
||||
5. [x] 5. Add Schema.org VideoObject markup
|
||||
6. [x] 6. Ensure thumbnail is publicly accessible
|
||||
|
||||
### Platform Support:
|
||||
- ✅ WhatsApp
|
||||
- ✅ Facebook
|
||||
- ✅ Twitter/X
|
||||
- ✅ LinkedIn
|
||||
- ✅ Telegram
|
||||
- ✅ Pinterest
|
||||
- ✅ All other social platforms
|
||||
|
||||
### Meta Tags Implemented:
|
||||
- Basic: og:title, og:description, og:image, og:url, og:type, og:site_name
|
||||
- Image: og:image:width, og:image:height, og:image:alt
|
||||
- Video-specific: og:video, og:video:url, og:video:secure_url, og:video:type, og:video:width, og:video:height, video:duration, video:release_date
|
||||
- Twitter: twitter:card, twitter:site, twitter:creator, twitter:player, twitter:player:stream
|
||||
- LinkedIn: linkedin:owner
|
||||
- Pinterest: pinterest-rich-pin
|
||||
- Schema.org: VideoObject with full video metadata
|
||||
|
||||
37
TODO_playlists.md
Normal file
37
TODO_playlists.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Playlist Implementation TODO
|
||||
|
||||
## Phase 1: Database & Models - COMPLETED
|
||||
- [x] Create playlists migration
|
||||
- [x] Create playlist_videos pivot table migration
|
||||
- [x] Create Playlist model
|
||||
- [x] Update User model with playlists relationship
|
||||
- [x] Update Video model with playlists relationship
|
||||
|
||||
## Phase 2: Controller & Routes - COMPLETED
|
||||
- [x] Create PlaylistController
|
||||
- [x] Add RESTful routes for playlists
|
||||
- [x] Add routes for adding/removing/reordering videos
|
||||
|
||||
## Phase 3: Views - Playlist Pages - COMPLETED
|
||||
- [x] Create playlists index page (user's playlists)
|
||||
- [x] Create playlist show page (view videos in playlist)
|
||||
- [x] Create playlist create/edit modal
|
||||
- [x] Add playlist management in user channel
|
||||
|
||||
## Phase 4: Views - Integration - COMPLETED
|
||||
- [x] Add "Add to Playlist" button on video page
|
||||
- [x] Add "Add to Playlist" modal
|
||||
- [x] Add playlist dropdown on video cards
|
||||
- [x] Add continuous play functionality
|
||||
|
||||
## Phase 5: Extra Features - COMPLETED
|
||||
- [x] Auto-create "Watch Later" playlist for new users
|
||||
- [x] Watch progress tracking
|
||||
- [x] Playlist sharing
|
||||
- [x] Playlist statistics
|
||||
|
||||
## Phase 6: Testing & Polish - COMPLETED
|
||||
- [x] Add Playlists link to sidebar (FIXED)
|
||||
- [x] Fix "Please log in" alert for authenticated users (FIXED)
|
||||
- [x] Add responsive styles
|
||||
|
||||
51
TODO_shorts_implementation.md
Normal file
51
TODO_shorts_implementation.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Shorts Feature Implementation Plan
|
||||
|
||||
## Overview
|
||||
Add "Shorts" as a separate attribute (boolean flag) to identify short-form vertical videos, independent of the video content type (generic/music/match).
|
||||
|
||||
## ✅ Completed Tasks
|
||||
|
||||
### 1. ✅ Database Migration
|
||||
- [x] Created migration to add `is_shorts` boolean column to videos table
|
||||
|
||||
### 2. ✅ Video Model (app/Models/Video.php)
|
||||
- [x] Added `is_shorts` to fillable array
|
||||
- [x] Added `is_shorts` to casts (boolean)
|
||||
- [x] Added helper methods: `isShorts()`, `scopeShorts()`, `scopeNotShorts()`
|
||||
- [x] Added `qualifiesAsShorts()` for auto-detection
|
||||
- [x] Added `getFormattedDurationAttribute()`
|
||||
- [x] Added `getShortsBadgeAttribute()`
|
||||
|
||||
### 3. ✅ Video Controller (app/Http/Controllers/VideoController.php)
|
||||
- [x] Updated validation to include `is_shorts`
|
||||
- [x] Added auto-detection of shorts based on:
|
||||
- Duration ≤ 60 seconds
|
||||
- Portrait orientation (height > width)
|
||||
- [x] Updated store method to include duration and is_shorts
|
||||
- [x] Updated edit method to include is_shorts in JSON response
|
||||
- [x] Updated update method to support is_shorts
|
||||
- [x] Added shorts() method for shorts page
|
||||
|
||||
### 4. ✅ Views
|
||||
- [x] Added Shorts toggle in upload form (create.blade.php)
|
||||
- [x] Added Shorts toggle CSS styles
|
||||
- [x] Added Shorts badge in video cards
|
||||
- [x] Added Shorts toggle in edit modal
|
||||
- [x] Created shorts.blade.php page
|
||||
- [x] Updated sidebar to link to Shorts page
|
||||
|
||||
### 5. ✅ Routes
|
||||
- [x] Added /shorts route
|
||||
|
||||
### 6. ✅ Admin
|
||||
- [x] Updated SuperAdminController to support is_shorts
|
||||
|
||||
## Usage
|
||||
1. Users can mark videos as Shorts during upload
|
||||
2. Shorts are automatically detected if:
|
||||
- Duration ≤ 60 seconds AND
|
||||
- Portrait orientation
|
||||
3. Shorts have a red badge on video cards
|
||||
4. Dedicated /shorts page shows all Shorts videos
|
||||
5. Sidebar has a link to Shorts
|
||||
|
||||
15
TODO_upnext_recommendations.md
Normal file
15
TODO_upnext_recommendations.md
Normal file
@ -0,0 +1,15 @@
|
||||
# TODO: Implement YouTube-style "Up Next" Recommendations
|
||||
|
||||
## Tasks:
|
||||
- [x] 1. Analyze codebase and understand the current implementation
|
||||
- [x] 2. Add recommendations method in VideoController
|
||||
- [x] 3. Add route for recommendations endpoint
|
||||
- [x] 4. Update show.blade.php to display recommended videos
|
||||
- [x] 5. Fix "Undefined variable $currentVideo" error
|
||||
|
||||
## Progress:
|
||||
- Step 1: COMPLETED - Analyzed VideoController, Video model, and show.blade.php
|
||||
- Step 2: COMPLETED - Added getRecommendedVideos() and recommendations() methods in VideoController
|
||||
- Step 3: COMPLETED - Added route in web.php for /videos/{video}/recommendations
|
||||
- Step 4: COMPLETED - Updated show.blade.php sidebar with Up Next recommendations
|
||||
- Step 5: COMPLETED - Fixed missing $currentVideo variable in closure (line 258)
|
||||
389
app/Http/Controllers/PlaylistController.php
Normal file
389
app/Http/Controllers/PlaylistController.php
Normal file
@ -0,0 +1,389 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PlaylistController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'publicPlaylists', 'userPlaylists']);
|
||||
}
|
||||
|
||||
// List user's playlists
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
|
||||
|
||||
return view('playlists.index', compact('playlists'));
|
||||
}
|
||||
|
||||
// View a single playlist
|
||||
public function show(Playlist $playlist)
|
||||
{
|
||||
// Check if user can view this playlist
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
$videos = $playlist->videos()->orderBy('position')->paginate(20);
|
||||
|
||||
return view('playlists.show', compact('playlist', 'videos'));
|
||||
}
|
||||
|
||||
// Create new playlist form
|
||||
public function create()
|
||||
{
|
||||
return view('playlists.create');
|
||||
}
|
||||
|
||||
// Store new playlist
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'visibility' => 'nullable|in:public,private',
|
||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
|
||||
]);
|
||||
|
||||
$playlistData = [
|
||||
'user_id' => Auth::id(),
|
||||
'name' => $request->name,
|
||||
'description' => $request->description,
|
||||
'visibility' => $request->visibility ?? 'private',
|
||||
'is_default' => false,
|
||||
];
|
||||
|
||||
// Create playlist first to get ID for thumbnail naming
|
||||
$playlist = Playlist::create($playlistData);
|
||||
|
||||
// Handle thumbnail upload
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$file = $request->file('thumbnail');
|
||||
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
|
||||
$file->storeAs('public/thumbnails', $filename);
|
||||
$playlist->update(['thumbnail' => $filename]);
|
||||
}
|
||||
|
||||
// Reload playlist with thumbnail
|
||||
$playlist->refresh();
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'playlist' => [
|
||||
'id' => $playlist->id,
|
||||
'name' => $playlist->name,
|
||||
'visibility' => $playlist->visibility,
|
||||
'thumbnail_url' => $playlist->thumbnail_url,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist created!');
|
||||
}
|
||||
|
||||
// Edit playlist form
|
||||
public function edit(Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
if (request()->expectsJson() || request()->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'playlist' => [
|
||||
'id' => $playlist->id,
|
||||
'name' => $playlist->name,
|
||||
'description' => $playlist->description,
|
||||
'visibility' => $playlist->visibility,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return view('playlists.edit', compact('playlist'));
|
||||
}
|
||||
|
||||
// Update playlist
|
||||
public function update(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'visibility' => 'nullable|in:public,private',
|
||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
|
||||
]);
|
||||
|
||||
$updateData = [
|
||||
'name' => $request->name,
|
||||
'description' => $request->description,
|
||||
'visibility' => $request->visibility ?? 'private',
|
||||
];
|
||||
|
||||
// Handle thumbnail upload
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
// Delete old thumbnail if exists
|
||||
if ($playlist->thumbnail) {
|
||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
||||
if (file_exists($oldPath)) {
|
||||
unlink($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload new thumbnail
|
||||
$file = $request->file('thumbnail');
|
||||
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
|
||||
$file->storeAs('public/thumbnails', $filename);
|
||||
$updateData['thumbnail'] = $filename;
|
||||
}
|
||||
|
||||
// Handle thumbnail removal
|
||||
if ($request->input('remove_thumbnail') == '1') {
|
||||
if ($playlist->thumbnail) {
|
||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
||||
if (file_exists($oldPath)) {
|
||||
unlink($oldPath);
|
||||
}
|
||||
$updateData['thumbnail'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$playlist->update($updateData);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Playlist updated!',
|
||||
'playlist' => [
|
||||
'id' => $playlist->id,
|
||||
'name' => $playlist->name,
|
||||
'visibility' => $playlist->visibility,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist updated!');
|
||||
}
|
||||
|
||||
// Delete playlist
|
||||
public function destroy(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to delete this playlist.');
|
||||
}
|
||||
|
||||
// Don't allow deleting default playlists
|
||||
if ($playlist->is_default) {
|
||||
abort(400, 'Cannot delete default playlist.');
|
||||
}
|
||||
|
||||
$playlist->delete();
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Playlist deleted!',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('playlists.index')->with('success', 'Playlist deleted!');
|
||||
}
|
||||
|
||||
// Add video to playlist
|
||||
public function addVideo(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'video_id' => 'required|exists:videos,id',
|
||||
]);
|
||||
|
||||
$video = Video::findOrFail($request->video_id);
|
||||
|
||||
// Check if video can be viewed
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(403, 'You cannot add this video to your playlist.');
|
||||
}
|
||||
|
||||
$added = $playlist->addVideo($video);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $added ? 'Video added to playlist!' : 'Video is already in playlist.',
|
||||
'video_count' => $playlist->video_count,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
public function removeVideo(Request $request, Playlist $playlist, Video $video)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$removed = $playlist->removeVideo($video);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Video removed from playlist.',
|
||||
'video_count' => $playlist->video_count,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Video removed from playlist.');
|
||||
}
|
||||
|
||||
// Reorder videos in playlist
|
||||
public function reorder(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'video_ids' => 'required|array',
|
||||
'video_ids.*' => 'integer|exists:videos,id',
|
||||
]);
|
||||
|
||||
$playlist->reorderVideos($request->video_ids);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Playlist reordered!',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Playlist reordered!');
|
||||
}
|
||||
|
||||
// Get user's playlists (for dropdown)
|
||||
public function userPlaylists()
|
||||
{
|
||||
// Handle unauthenticated users
|
||||
if (! Auth::check()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'playlists' => [],
|
||||
'authenticated' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$playlists = $user->playlists()->orderBy('name')->get();
|
||||
|
||||
// Get video IDs for each playlist
|
||||
$playlistsWithVideoIds = $playlists->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'description' => $p->description,
|
||||
'video_count' => $p->videos()->count(),
|
||||
'formatted_duration' => $p->formatted_duration,
|
||||
'is_default' => $p->is_default,
|
||||
'visibility' => $p->visibility,
|
||||
'thumbnail_url' => $p->thumbnail_url,
|
||||
'video_ids' => $p->videos()->pluck('videos.id')->toArray(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'playlists' => $playlistsWithVideoIds,
|
||||
'authenticated' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Quick add to Watch Later
|
||||
public function watchLater(Request $request, Video $video)
|
||||
{
|
||||
$watchLater = Playlist::getWatchLater(Auth::id());
|
||||
$added = $watchLater->addVideo($video);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $added ? 'Added to Watch Later!' : 'Already in Watch Later.',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', $added ? 'Added to Watch Later!' : 'Already in Watch Later.');
|
||||
}
|
||||
|
||||
// Update watch progress
|
||||
public function updateProgress(Request $request, Playlist $playlist, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'seconds' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$playlist->updateWatchProgress($video, $request->seconds);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Play all videos in playlist (redirect to first video with playlist context)
|
||||
public function playAll(Playlist $playlist)
|
||||
{
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
$firstVideo = $playlist->videos()->orderBy('position')->first();
|
||||
|
||||
if (! $firstVideo) {
|
||||
return back()->with('error', 'Playlist is empty.');
|
||||
}
|
||||
|
||||
// Redirect to first video with playlist parameter
|
||||
return redirect()->route('videos.show', [
|
||||
'video' => $firstVideo->id,
|
||||
'playlist' => $playlist->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Shuffle play - redirect to random video
|
||||
public function shuffle(Playlist $playlist)
|
||||
{
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
$randomVideo = $playlist->getRandomVideo();
|
||||
|
||||
if (! $randomVideo) {
|
||||
return back()->with('error', 'Playlist is empty.');
|
||||
}
|
||||
|
||||
return redirect()->route('videos.show', [
|
||||
'video' => $randomVideo->id,
|
||||
'playlist' => $playlist->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -217,9 +217,10 @@ class SuperAdminController extends Controller
|
||||
'visibility' => 'required|in:public,unlisted,private',
|
||||
'type' => 'required|in:generic,music,match',
|
||||
'status' => 'required|in:pending,processing,ready,failed',
|
||||
'is_shorts' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status']);
|
||||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts']);
|
||||
|
||||
$video->update($data);
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ class UserController extends Controller
|
||||
public function profile()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return view('user.profile', compact('user'));
|
||||
}
|
||||
|
||||
@ -32,16 +33,38 @@ class UserController extends Controller
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
'bio' => 'nullable|string|max:500',
|
||||
'website' => 'nullable|string|max:255',
|
||||
'twitter' => 'nullable|string|max:100',
|
||||
'instagram' => 'nullable|string|max:100',
|
||||
'facebook' => 'nullable|string|max:100',
|
||||
'youtube' => 'nullable|string|max:100',
|
||||
'linkedin' => 'nullable|string|max:100',
|
||||
'tiktok' => 'nullable|string|max:100',
|
||||
'birthday' => 'nullable|date',
|
||||
'location' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$data = ['name' => $request->name];
|
||||
$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')) {
|
||||
// Delete old avatar
|
||||
if ($user->avatar) {
|
||||
Storage::delete('public/avatars/' . $user->avatar);
|
||||
Storage::delete('public/avatars/'.$user->avatar);
|
||||
}
|
||||
$filename = Str::uuid() . '.' . $request->file('avatar')->getClientOriginalExtension();
|
||||
$filename = Str::uuid().'.'.$request->file('avatar')->getClientOriginalExtension();
|
||||
$request->file('avatar')->storeAs('public/avatars', $filename);
|
||||
$data['avatar'] = $filename;
|
||||
}
|
||||
@ -55,6 +78,7 @@ class UserController extends Controller
|
||||
public function settings()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return view('user.settings', compact('user'));
|
||||
}
|
||||
|
||||
@ -68,12 +92,12 @@ class UserController extends Controller
|
||||
'new_password' => 'required|min:8|confirmed',
|
||||
]);
|
||||
|
||||
if (!Hash::check($request->current_password, $user->password)) {
|
||||
if (! Hash::check($request->current_password, $user->password)) {
|
||||
return back()->withErrors(['current_password' => 'Current password is incorrect']);
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'password' => Hash::make($request->new_password)
|
||||
'password' => Hash::make($request->new_password),
|
||||
]);
|
||||
|
||||
return redirect()->route('settings')->with('success', 'Password updated successfully!');
|
||||
@ -94,21 +118,25 @@ class UserController extends Controller
|
||||
$videos = Video::where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
// Also get user's playlists for their own channel
|
||||
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
|
||||
} else {
|
||||
$videos = Video::public()
|
||||
->where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
$playlists = null;
|
||||
}
|
||||
|
||||
return view('user.channel', compact('user', 'videos'));
|
||||
return view('user.channel', compact('user', 'videos', 'playlists'));
|
||||
}
|
||||
|
||||
// Watch history
|
||||
public function history()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
|
||||
// Get videos the user has watched, ordered by most recently watched
|
||||
// Include private videos since they are the user's own
|
||||
$videoIds = \DB::table('video_views')
|
||||
@ -118,9 +146,9 @@ class UserController extends Controller
|
||||
->unique();
|
||||
|
||||
$videos = Video::whereIn('id', $videoIds)
|
||||
->where(function($q) use ($user) {
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->get()
|
||||
->sortByDesc(function ($video) use ($videoIds) {
|
||||
@ -136,9 +164,9 @@ class UserController extends Controller
|
||||
$user = Auth::user();
|
||||
// Include private videos in liked (user's own private videos)
|
||||
$videos = $user->likes()
|
||||
->where(function($q) use ($user) {
|
||||
->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->latest()
|
||||
->paginate(12);
|
||||
@ -150,8 +178,8 @@ class UserController extends Controller
|
||||
public function like(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$video->isLikedBy($user)) {
|
||||
|
||||
if (! $video->isLikedBy($user)) {
|
||||
$video->likes()->attach($user->id);
|
||||
}
|
||||
|
||||
@ -162,7 +190,7 @@ class UserController extends Controller
|
||||
public function unlike(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
|
||||
$video->likes()->detach($user->id);
|
||||
|
||||
return back();
|
||||
@ -172,7 +200,7 @@ class UserController extends Controller
|
||||
public function toggleLike(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
|
||||
if ($video->isLikedBy($user)) {
|
||||
$video->likes()->detach($user->id);
|
||||
$liked = false;
|
||||
@ -183,8 +211,7 @@ class UserController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'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\Mail\VideoUploaded;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\FFProbe;
|
||||
@ -23,25 +24,26 @@ class VideoController extends Controller
|
||||
public function index()
|
||||
{
|
||||
$videos = Video::public()->latest()->paginate(12);
|
||||
|
||||
return view('videos.index', compact('videos'));
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$query = $request->get('q', '');
|
||||
|
||||
|
||||
if (empty($query)) {
|
||||
return redirect()->route('videos.index');
|
||||
}
|
||||
|
||||
|
||||
$videos = Video::public()
|
||||
->where(function($q) use ($query) {
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('title', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%");
|
||||
->orWhere('description', 'like', "%{$query}%");
|
||||
})
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
|
||||
return view('videos.index', compact('videos', 'query'));
|
||||
}
|
||||
|
||||
@ -62,41 +64,41 @@ class VideoController extends Controller
|
||||
]);
|
||||
|
||||
$videoFile = $request->file('video');
|
||||
$filename = Str::slug($request->title) . '-' . time() . '.' . $videoFile->getClientOriginalExtension();
|
||||
$filename = Str::slug($request->title).'-'.time().'.'.$videoFile->getClientOriginalExtension();
|
||||
$path = $videoFile->storeAs('public/videos', $filename);
|
||||
|
||||
|
||||
// Get file info
|
||||
$fileSize = $videoFile->getSize();
|
||||
$mimeType = $videoFile->getMimeType();
|
||||
|
||||
$thumbnailPath = null;
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$thumbFilename = Str::uuid() . '.' . $request->file('thumbnail')->getClientOriginalExtension();
|
||||
$thumbFilename = Str::uuid().'.'.$request->file('thumbnail')->getClientOriginalExtension();
|
||||
$thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
} else {
|
||||
// Extract thumbnail from video using FFmpeg
|
||||
try {
|
||||
$ffmpeg = FFMpeg::create();
|
||||
$videoPath = storage_path('app/' . $path);
|
||||
|
||||
$videoPath = storage_path('app/'.$path);
|
||||
|
||||
if (file_exists($videoPath)) {
|
||||
$video = $ffmpeg->open($videoPath);
|
||||
$frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1));
|
||||
|
||||
$thumbFilename = Str::uuid() . '.jpg';
|
||||
$thumbFullPath = storage_path('app/public/thumbnails/' . $thumbFilename);
|
||||
|
||||
|
||||
$thumbFilename = Str::uuid().'.jpg';
|
||||
$thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
$frame->save($thumbFullPath);
|
||||
$thumbnailPath = 'public/thumbnails/' . $thumbFilename;
|
||||
$thumbnailPath = 'public/thumbnails/'.$thumbFilename;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('FFmpeg failed to extract thumbnail: ' . $e->getMessage());
|
||||
\Log::error('FFmpeg failed to extract thumbnail: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,16 +109,16 @@ class VideoController extends Controller
|
||||
|
||||
try {
|
||||
$ffprobe = FFProbe::create();
|
||||
$videoPath = storage_path('app/' . $path);
|
||||
|
||||
$videoPath = storage_path('app/'.$path);
|
||||
|
||||
if (file_exists($videoPath)) {
|
||||
$streams = $ffprobe->streams($videoPath);
|
||||
$videoStream = $streams->videos()->first();
|
||||
|
||||
|
||||
if ($videoStream) {
|
||||
$width = $videoStream->get('width');
|
||||
$height = $videoStream->get('height');
|
||||
|
||||
|
||||
// Auto-detect orientation based on dimensions
|
||||
if ($width && $height) {
|
||||
if ($height > $width) {
|
||||
@ -131,7 +133,7 @@ class VideoController extends Controller
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -163,22 +165,22 @@ class VideoController extends Controller
|
||||
Mail::to(Auth::user()->email)->send(new VideoUploaded($video, Auth::user()->name));
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('Email notification failed: ' . $e->getMessage());
|
||||
\Log::error('Email notification failed: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'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
|
||||
if (!$video->canView(Auth::user())) {
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
|
||||
// Track view if user is logged in
|
||||
if (Auth::check()) {
|
||||
$user = Auth::user();
|
||||
@ -188,27 +190,50 @@ class VideoController extends Controller
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>', now()->subHour())
|
||||
->first();
|
||||
|
||||
if (!$existingView) {
|
||||
|
||||
if (! $existingView) {
|
||||
\DB::table('video_views')->insert([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'watched_at' => now()
|
||||
'watched_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load comments with user relationship
|
||||
$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
|
||||
$view = match($video->type) {
|
||||
$view = match ($video->type) {
|
||||
'match' => 'videos.types.match',
|
||||
'music' => 'videos.types.music',
|
||||
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)
|
||||
@ -217,12 +242,12 @@ class VideoController extends Controller
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403, 'You do not have permission to edit this video.');
|
||||
}
|
||||
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
// For AJAX request, return JSON
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -231,10 +256,10 @@ class VideoController extends Controller
|
||||
'title' => $video->title,
|
||||
'description' => $video->description,
|
||||
'thumbnail' => $video->thumbnail,
|
||||
'thumbnail_url' => $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : null,
|
||||
'thumbnail_url' => $video->thumbnail ? asset('storage/thumbnails/'.$video->thumbnail) : null,
|
||||
'visibility' => $video->visibility ?? 'public',
|
||||
'type' => $video->type ?? 'generic',
|
||||
]
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -257,15 +282,15 @@ class VideoController extends Controller
|
||||
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
||||
}
|
||||
$thumbFilename = Str::uuid() . '.' . $request->file('thumbnail')->getClientOriginalExtension();
|
||||
$thumbFilename = Str::uuid().'.'.$request->file('thumbnail')->getClientOriginalExtension();
|
||||
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
$data['thumbnail'] = basename($data['thumbnail']);
|
||||
}
|
||||
|
||||
// Set default visibility if not provided
|
||||
if (!isset($data['visibility'])) {
|
||||
if (! isset($data['visibility'])) {
|
||||
unset($data['visibility']);
|
||||
}
|
||||
|
||||
@ -281,7 +306,7 @@ class VideoController extends Controller
|
||||
'title' => $video->title,
|
||||
'description' => $video->description,
|
||||
'visibility' => $video->visibility,
|
||||
]
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -294,15 +319,15 @@ class VideoController extends Controller
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403, 'You do not have permission to delete this video.');
|
||||
}
|
||||
|
||||
|
||||
$videoTitle = $video->title;
|
||||
|
||||
Storage::delete('public/videos/' . $video->filename);
|
||||
|
||||
Storage::delete('public/videos/'.$video->filename);
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
||||
}
|
||||
$video->delete();
|
||||
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
@ -317,13 +342,13 @@ class VideoController extends Controller
|
||||
public function stream(Video $video)
|
||||
{
|
||||
// Check if user can view this video
|
||||
if (!$video->canView(Auth::user())) {
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
|
||||
$path = storage_path('app/public/videos/'.$video->filename);
|
||||
|
||||
if (! file_exists($path)) {
|
||||
abort(404, 'Video file not found');
|
||||
}
|
||||
|
||||
@ -331,47 +356,47 @@ class VideoController extends Controller
|
||||
$mimeType = $video->mime_type ?: 'video/mp4';
|
||||
|
||||
$handle = fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
if (! $handle) {
|
||||
abort(500, 'Cannot open video file');
|
||||
}
|
||||
|
||||
$range = request()->header('Range');
|
||||
|
||||
|
||||
if ($range) {
|
||||
// Parse range header
|
||||
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
|
||||
$start = intval($matches[1] ?? 0);
|
||||
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
|
||||
|
||||
|
||||
$length = $end - $start + 1;
|
||||
|
||||
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . $length);
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
|
||||
header('Content-Type: '.$mimeType);
|
||||
header('Content-Length: '.$length);
|
||||
header('Content-Range: bytes '.$start.'-'.$end.'/'.$fileSize);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
|
||||
|
||||
fseek($handle, $start);
|
||||
$chunkSize = 8192;
|
||||
$bytesToRead = $length;
|
||||
|
||||
while (!feof($handle) && $bytesToRead > 0) {
|
||||
|
||||
while (! feof($handle) && $bytesToRead > 0) {
|
||||
$buffer = fread($handle, min($chunkSize, $bytesToRead));
|
||||
echo $buffer;
|
||||
flush();
|
||||
$bytesToRead -= strlen($buffer);
|
||||
}
|
||||
|
||||
|
||||
fclose($handle);
|
||||
exit;
|
||||
} else {
|
||||
// No range requested, stream entire file
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . $fileSize);
|
||||
header('Content-Type: '.$mimeType);
|
||||
header('Content-Length: '.$fileSize);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
|
||||
|
||||
fpassthru($handle);
|
||||
fclose($handle);
|
||||
exit;
|
||||
@ -381,18 +406,18 @@ class VideoController extends Controller
|
||||
public function download(Video $video)
|
||||
{
|
||||
// Check if user can view this video
|
||||
if (!$video->canView(Auth::user())) {
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
|
||||
$path = storage_path('app/public/videos/'.$video->filename);
|
||||
|
||||
if (! file_exists($path)) {
|
||||
abort(404, 'Video file not found');
|
||||
}
|
||||
|
||||
$filename = $video->title . '.' . pathinfo($video->filename, PATHINFO_EXTENSION);
|
||||
|
||||
$filename = $video->title.'.'.pathinfo($video->filename, PATHINFO_EXTENSION);
|
||||
|
||||
return response()->download($path, $filename);
|
||||
}
|
||||
|
||||
@ -401,41 +426,73 @@ class VideoController extends Controller
|
||||
{
|
||||
$hours = $request->get('hours', 48); // Default: 48 hours
|
||||
$limit = $request->get('limit', 50);
|
||||
|
||||
|
||||
// Validate parameters
|
||||
$hours = min(max($hours, 24), 168); // Between 24h and 7 days
|
||||
$limit = min(max($limit, 10), 100);
|
||||
|
||||
// Get trending videos using the scope with raw score calculation
|
||||
$trendingVideos = \DB::table('videos')
|
||||
->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')
|
||||
|
||||
// Get all public ready videos first
|
||||
$videos = Video::public()
|
||||
->where('status', 'ready')
|
||||
->where('created_at', '>=', now()->subDays(10))
|
||||
->having('trending_score', '>', 0)
|
||||
->orderByDesc('trending_score')
|
||||
->limit($limit)
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
// Load user relationship
|
||||
$trendingVideos = $trendingVideos->map(function ($video) {
|
||||
$video->user = \App\Models\User::find($video->user_id);
|
||||
$video->view_count = \DB::table('video_views')->where('video_id', $video->id)->count();
|
||||
$video->like_count = \DB::table('video_likes')->where('video_id', $video->id)->count();
|
||||
|
||||
// Calculate trending score for each video
|
||||
$videos = $videos->map(function ($video) use ($hours) {
|
||||
$recentViews = \DB::table('video_views')
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>=', now()->subHours($hours))
|
||||
->count();
|
||||
|
||||
$likeCount = \DB::table('video_likes')
|
||||
->where('video_id', $video->id)
|
||||
->count();
|
||||
|
||||
// Calculate age in hours
|
||||
$ageHours = $video->created_at->diffInHours(now());
|
||||
|
||||
// Calculate trending score
|
||||
// 70% recent views, 15% velocity, 10% recency, 5% likes
|
||||
$velocity = $recentViews / $hours;
|
||||
$recencyBonus = max(0, 1 - ($ageHours / 240));
|
||||
|
||||
$score = ($recentViews * 0.70) +
|
||||
($velocity * 100 * 0.15) +
|
||||
($recencyBonus * 50 * 0.10) +
|
||||
($likeCount * 0.1 * 0.05);
|
||||
|
||||
$video->trending_score = round($score, 2);
|
||||
$video->view_count = $recentViews;
|
||||
$video->like_count = $likeCount;
|
||||
|
||||
return $video;
|
||||
});
|
||||
|
||||
|
||||
// Filter and sort by trending score
|
||||
$trendingVideos = $videos
|
||||
->filter(fn ($v) => $v->trending_score > 0)
|
||||
->sortByDesc('trending_score')
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
return view('videos.trending', [
|
||||
'videos' => $trendingVideos,
|
||||
'hours' => $hours,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
// Shorts page
|
||||
public function shorts(Request $request)
|
||||
{
|
||||
$videos = Video::public()
|
||||
->where('is_shorts', true)
|
||||
->where('status', 'ready')
|
||||
->with('user')
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
return view('videos.shorts', compact('videos'));
|
||||
}
|
||||
}
|
||||
|
||||
321
app/Models/Playlist.php
Normal file
321
app/Models/Playlist.php
Normal file
@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Playlist extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'description',
|
||||
'thumbnail',
|
||||
'visibility',
|
||||
'is_default',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function videos()
|
||||
{
|
||||
return $this->belongsToMany(Video::class, 'playlist_videos')
|
||||
->withPivot('position', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
|
||||
->orderBy('position');
|
||||
}
|
||||
|
||||
// Get videos with their pivot data
|
||||
public function getVideosWithPivot()
|
||||
{
|
||||
return $this->videos()->orderBy('position')->get();
|
||||
}
|
||||
|
||||
// Accessors
|
||||
public function getThumbnailUrlAttribute()
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
||||
}
|
||||
|
||||
// Generate a placeholder based on playlist name
|
||||
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
|
||||
}
|
||||
|
||||
// Get total video count
|
||||
public function getVideoCountAttribute()
|
||||
{
|
||||
return $this->videos()->count();
|
||||
}
|
||||
|
||||
// Get total duration of all videos in playlist
|
||||
public function getTotalDurationAttribute()
|
||||
{
|
||||
return $this->videos()->sum('duration');
|
||||
}
|
||||
|
||||
// Get formatted total duration (e.g., "2h 30m")
|
||||
public function getFormattedDurationAttribute()
|
||||
{
|
||||
$totalSeconds = $this->total_duration;
|
||||
|
||||
if (! $totalSeconds) {
|
||||
return '0m';
|
||||
}
|
||||
|
||||
$hours = floor($totalSeconds / 3600);
|
||||
$minutes = floor(($totalSeconds % 3600) / 60);
|
||||
|
||||
if ($hours > 0) {
|
||||
return "{$hours}h {$minutes}m";
|
||||
}
|
||||
|
||||
return "{$minutes}m";
|
||||
}
|
||||
|
||||
// Get total views of all videos in playlist
|
||||
public function getTotalViewsAttribute()
|
||||
{
|
||||
return $this->videos()->get()->sum('view_count');
|
||||
}
|
||||
|
||||
// Check if user owns this playlist
|
||||
public function isOwnedBy($user)
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->user_id === $user->id;
|
||||
}
|
||||
|
||||
// Check if video is in this playlist
|
||||
public function hasVideo(Video $video)
|
||||
{
|
||||
return $this->videos()->where('video_id', $video->id)->exists();
|
||||
}
|
||||
|
||||
// Get video position in playlist
|
||||
public function getVideoPosition(Video $video)
|
||||
{
|
||||
$pivot = $this->videos()->where('video_id', $video->id)->first();
|
||||
|
||||
return $pivot ? $pivot->pivot->position : null;
|
||||
}
|
||||
|
||||
// Get next video in playlist
|
||||
public function getNextVideo(Video $currentVideo)
|
||||
{
|
||||
$currentPosition = $this->getVideoPosition($currentVideo);
|
||||
|
||||
if ($currentPosition === null) {
|
||||
return $this->videos()->first();
|
||||
}
|
||||
|
||||
return $this->videos()
|
||||
->wherePivot('position', '>', $currentPosition)
|
||||
->orderBy('position')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Get previous video in playlist
|
||||
public function getPreviousVideo(Video $currentVideo)
|
||||
{
|
||||
$currentPosition = $this->getVideoPosition($currentVideo);
|
||||
|
||||
if ($currentPosition === null) {
|
||||
return $this->videos()->last();
|
||||
}
|
||||
|
||||
return $this->videos()
|
||||
->wherePivot('position', '<', $currentPosition)
|
||||
->orderByDesc('position')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Get shareable URL
|
||||
public function getShareUrlAttribute()
|
||||
{
|
||||
return route('playlists.show', $this->id);
|
||||
}
|
||||
|
||||
// Scope for public playlists
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('visibility', 'public');
|
||||
}
|
||||
|
||||
// Scope for user's playlists
|
||||
public function scopeForUser($query, $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
// Scope for default playlists (Watch Later)
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
// Visibility helpers
|
||||
public function isPublic()
|
||||
{
|
||||
return $this->visibility === 'public';
|
||||
}
|
||||
|
||||
public function isPrivate()
|
||||
{
|
||||
return $this->visibility === 'private';
|
||||
}
|
||||
|
||||
// Check if user can view this playlist
|
||||
public function canView($user = null)
|
||||
{
|
||||
// Owner can always view
|
||||
if ($user && $this->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public playlists can be viewed by anyone
|
||||
return $this->visibility === 'public';
|
||||
}
|
||||
|
||||
// Check if user can edit this playlist
|
||||
public function canEdit($user = null)
|
||||
{
|
||||
return $user && $this->user_id === $user->id;
|
||||
}
|
||||
|
||||
// Update video positions after reordering
|
||||
public function reorderVideos($videoIds)
|
||||
{
|
||||
foreach ($videoIds as $index => $videoId) {
|
||||
$this->videos()->updateExistingPivot($videoId, ['position' => $index]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add video to playlist
|
||||
public function addVideo(Video $video)
|
||||
{
|
||||
if ($this->hasVideo($video)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPosition = $this->videos()->max('position') ?? -1;
|
||||
|
||||
$this->videos()->attach($video->id, [
|
||||
'position' => $maxPosition + 1,
|
||||
'added_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
public function removeVideo(Video $video)
|
||||
{
|
||||
if (! $this->hasVideo($video)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->videos()->detach($video->id);
|
||||
|
||||
// Reorder remaining videos
|
||||
$this->reorderPositions();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reorder positions after removal
|
||||
protected function reorderPositions()
|
||||
{
|
||||
$videos = $this->videos()->orderBy('position')->get();
|
||||
$position = 0;
|
||||
|
||||
foreach ($videos as $video) {
|
||||
$this->videos()->updateExistingPivot($video->id, ['position' => $position]);
|
||||
$position++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update watch progress for a video in playlist
|
||||
public function updateWatchProgress(Video $video, $seconds)
|
||||
{
|
||||
$pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot;
|
||||
|
||||
if ($pivot) {
|
||||
$pivot->watched_seconds = $seconds;
|
||||
$pivot->last_watched_at = now();
|
||||
|
||||
// Mark as watched if 90% complete
|
||||
if ($video->duration && $seconds >= ($video->duration * 0.9)) {
|
||||
$pivot->watched = true;
|
||||
}
|
||||
|
||||
$pivot->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Get watch progress for a video in playlist
|
||||
public function getWatchProgress(Video $video)
|
||||
{
|
||||
$pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot;
|
||||
|
||||
return $pivot ? [
|
||||
'watched_seconds' => $pivot->watched_seconds,
|
||||
'watched' => $pivot->watched,
|
||||
'last_watched_at' => $pivot->last_watched_at,
|
||||
] : null;
|
||||
}
|
||||
|
||||
// Get first unwatched video
|
||||
public function getFirstUnwatchedVideo()
|
||||
{
|
||||
return $this->videos()
|
||||
->wherePivot('watched', false)
|
||||
->orderBy('position')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Get random video (for shuffle)
|
||||
public function getRandomVideo()
|
||||
{
|
||||
return $this->videos()->inRandomOrder()->first();
|
||||
}
|
||||
|
||||
// Static method to create default "Watch Later" playlist
|
||||
public static function createWatchLater($userId)
|
||||
{
|
||||
return self::create([
|
||||
'user_id' => $userId,
|
||||
'name' => 'Watch Later',
|
||||
'description' => 'Save videos to watch later',
|
||||
'visibility' => 'private',
|
||||
'is_default' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Get or create watch later playlist for user
|
||||
public static function getWatchLater($userId)
|
||||
{
|
||||
$playlist = self::where('user_id', $userId)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
|
||||
if (! $playlist) {
|
||||
$playlist = self::createWatchLater($userId);
|
||||
}
|
||||
|
||||
return $playlist;
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,16 @@ class User extends Authenticatable
|
||||
'password',
|
||||
'avatar',
|
||||
'role',
|
||||
'bio',
|
||||
'website',
|
||||
'twitter',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'youtube',
|
||||
'linkedin',
|
||||
'tiktok',
|
||||
'birthday',
|
||||
'location',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@ -52,12 +62,18 @@ class User extends Authenticatable
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function playlists()
|
||||
{
|
||||
return $this->hasMany(Playlist::class);
|
||||
}
|
||||
|
||||
public function getAvatarUrlAttribute()
|
||||
{
|
||||
if ($this->avatar) {
|
||||
return asset('storage/avatars/' . $this->avatar);
|
||||
return asset('storage/avatars/'.$this->avatar);
|
||||
}
|
||||
return 'https://i.pravatar.cc/150?u=' . $this->id;
|
||||
|
||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
@ -82,5 +98,39 @@ class User extends Authenticatable
|
||||
// For now, return a placeholder - in production this would come from a subscriptions table
|
||||
return rand(100, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
// Get social links as an array
|
||||
public function getSocialLinksAttribute()
|
||||
{
|
||||
return [
|
||||
'twitter' => $this->twitter,
|
||||
'instagram' => $this->instagram,
|
||||
'facebook' => $this->facebook,
|
||||
'youtube' => $this->youtube,
|
||||
'linkedin' => $this->linkedin,
|
||||
'tiktok' => $this->tiktok,
|
||||
];
|
||||
}
|
||||
|
||||
// Check if user has any social links
|
||||
public function hasSocialLinks()
|
||||
{
|
||||
return $this->twitter || $this->instagram || $this->facebook ||
|
||||
$this->youtube || $this->linkedin || $this->tiktok;
|
||||
}
|
||||
|
||||
// Get formatted website URL
|
||||
public function getWebsiteUrlAttribute()
|
||||
{
|
||||
if (! $this->website) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add https:// if no protocol is specified
|
||||
if (! preg_match('/^https?:\/\//', $this->website)) {
|
||||
return 'https://'.$this->website;
|
||||
}
|
||||
|
||||
return $this->website;
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ class Video extends Model
|
||||
'status',
|
||||
'visibility',
|
||||
'type',
|
||||
'is_shorts',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -29,6 +30,7 @@ class Video extends Model
|
||||
'size' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'is_shorts' => 'boolean',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
@ -52,21 +54,26 @@ class Video extends Model
|
||||
// Accessors
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return asset('storage/videos/' . $this->filename);
|
||||
return asset('storage/videos/'.$this->filename);
|
||||
}
|
||||
|
||||
public function getThumbnailUrlAttribute()
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return asset('storage/thumbnails/' . $this->thumbnail);
|
||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
||||
}
|
||||
return 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
|
||||
public function isLikedBy($user)
|
||||
{
|
||||
if (!$user) return false;
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->likes()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
@ -88,6 +95,33 @@ class Video extends Model
|
||||
return route('videos.show', $this->id);
|
||||
}
|
||||
|
||||
// Get formatted duration (e.g., "1:30" or "0:45" for shorts)
|
||||
public function getFormattedDurationAttribute()
|
||||
{
|
||||
if (! $this->duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$minutes = floor($this->duration / 60);
|
||||
$seconds = $this->duration % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
}
|
||||
|
||||
// Get Shorts badge
|
||||
public function getShortsBadgeAttribute()
|
||||
{
|
||||
if ($this->isShorts()) {
|
||||
return [
|
||||
'icon' => 'bi-collection-play-fill',
|
||||
'label' => 'SHORTS',
|
||||
'color' => '#ff0000',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Visibility helpers
|
||||
public function isPublic()
|
||||
{
|
||||
@ -135,16 +169,17 @@ class Video extends Model
|
||||
if ($user) {
|
||||
return $query->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
->orWhere('user_id', $user->id);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->where('visibility', '!=', 'private');
|
||||
}
|
||||
|
||||
// Video type helpers
|
||||
public function getTypeIconAttribute()
|
||||
{
|
||||
return match($this->type) {
|
||||
return match ($this->type) {
|
||||
'music' => 'bi-music-note',
|
||||
'match' => 'bi-trophy',
|
||||
default => 'bi-film',
|
||||
@ -153,7 +188,7 @@ class Video extends Model
|
||||
|
||||
public function getTypeSymbolAttribute()
|
||||
{
|
||||
return match($this->type) {
|
||||
return match ($this->type) {
|
||||
'music' => '🎵',
|
||||
'match' => '🏆',
|
||||
default => '🎬',
|
||||
@ -175,6 +210,31 @@ class Video extends Model
|
||||
return $this->type === 'match';
|
||||
}
|
||||
|
||||
// Shorts helpers
|
||||
public function isShorts()
|
||||
{
|
||||
return $this->is_shorts === true || $this->is_shorts === 1 || $this->is_shorts === '1';
|
||||
}
|
||||
|
||||
// Scope for shorts videos
|
||||
public function scopeShorts($query)
|
||||
{
|
||||
return $query->where('is_shorts', true);
|
||||
}
|
||||
|
||||
// Scope for non-shorts videos
|
||||
public function scopeNotShorts($query)
|
||||
{
|
||||
return $query->where('is_shorts', false);
|
||||
}
|
||||
|
||||
// Check if video qualifies as shorts (auto-detection)
|
||||
public function qualifiesAsShorts()
|
||||
{
|
||||
// Shorts: duration <= 60 seconds AND portrait orientation
|
||||
return $this->duration <= 60 && $this->orientation === 'portrait';
|
||||
}
|
||||
|
||||
// Comments relationship
|
||||
public function comments()
|
||||
{
|
||||
@ -185,69 +245,143 @@ class Video extends Model
|
||||
{
|
||||
return $this->comments()->count();
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent views count (within hours)
|
||||
public function getRecentViews( = 48)
|
||||
public function getRecentViews($hours = 48)
|
||||
{
|
||||
return \DB::table('video_views')
|
||||
->where('video_id', ->id)
|
||||
->where('watched_at', '>=', now()->subHours())
|
||||
->where('video_id', $this->id)
|
||||
->where('watched_at', '>=', now()->subHours($hours))
|
||||
->count();
|
||||
}
|
||||
|
||||
// Get views in last 24 hours (for velocity calculation)
|
||||
public function getViewsLast24Hours()
|
||||
{
|
||||
return ->getRecentViews(24);
|
||||
return $this->getRecentViews(24);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (->created_at->diffInDays(now()) > 10) {
|
||||
if ($this->created_at->diffInDays(now()) > 10) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Don't include videos with no recent views
|
||||
if ( < 5) {
|
||||
if ($recentViews < 5) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Calculate view velocity (views per hour in last 48 hours)
|
||||
= / ;
|
||||
|
||||
$velocity = $recentViews / $hours;
|
||||
|
||||
// Recency bonus: newer videos get a boost
|
||||
= ->created_at->diffInHours(now());
|
||||
= max(0, 1 - ( / 240)); // Decreases over 10 days
|
||||
|
||||
$ageHours = $this->created_at->diffInHours(now());
|
||||
$recencyBonus = max(0, 1 - ($ageHours / 240)); // Decreases over 10 days
|
||||
|
||||
// Like count bonus
|
||||
= ->like_count * 0.1;
|
||||
|
||||
$likeBonus = $this->like_count * 0.1;
|
||||
|
||||
// Calculate final score
|
||||
// Weight: 70% recent views, 15% velocity, 10% recency, 5% likes
|
||||
= ( * 0.70) +
|
||||
( * 100 * 0.15) +
|
||||
( * 50 * 0.10) +
|
||||
( * 0.05);
|
||||
|
||||
return round(, 2);
|
||||
$score = ($recentViews * 0.70) +
|
||||
($velocity * 100 * 0.15) +
|
||||
($recencyBonus * 50 * 0.10) +
|
||||
($likeBonus * 0.05);
|
||||
|
||||
return round($score, 2);
|
||||
}
|
||||
|
||||
// Get thumbnail dimensions for Open Graph
|
||||
public function getThumbnailWidthAttribute()
|
||||
{
|
||||
// Default OG recommended size is 1200x630
|
||||
return $this->width ?? 1280;
|
||||
}
|
||||
|
||||
public function getThumbnailHeightAttribute()
|
||||
{
|
||||
// Default OG recommended size is 1200x630
|
||||
return $this->height ?? 720;
|
||||
}
|
||||
|
||||
// Get video stream URL for Open Graph
|
||||
public function getStreamUrlAttribute()
|
||||
{
|
||||
return route('videos.stream', $this->id);
|
||||
}
|
||||
|
||||
// Get secure share URL
|
||||
public function getSecureShareUrlAttribute()
|
||||
{
|
||||
return secure_url(route('videos.show', $this->id));
|
||||
}
|
||||
|
||||
// Get secure thumbnail URL
|
||||
public function getSecureThumbnailUrlAttribute()
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return secure_asset('storage/thumbnails/'.$this->thumbnail);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get full thumbnail URL with dimensions for Open Graph
|
||||
public function getOpenGraphImageAttribute()
|
||||
{
|
||||
$thumbnail = $this->thumbnail_url;
|
||||
|
||||
// Add cache busting for dynamic thumbnails
|
||||
if ($this->thumbnail) {
|
||||
$thumbnail .= '?v='.$this->updated_at->timestamp;
|
||||
}
|
||||
|
||||
return $thumbnail;
|
||||
}
|
||||
|
||||
// Get author/uploader name
|
||||
public function getAuthorNameAttribute()
|
||||
{
|
||||
return $this->user ? $this->user->name : config('app.name');
|
||||
}
|
||||
|
||||
// Get video duration in ISO 8601 format for Open Graph
|
||||
public function getIsoDurationAttribute()
|
||||
{
|
||||
if (! $this->duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hours = floor($this->duration / 3600);
|
||||
$minutes = floor(($this->duration % 3600) / 60);
|
||||
$seconds = $this->duration % 60;
|
||||
|
||||
if ($hours > 0) {
|
||||
return sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
|
||||
} elseif ($minutes > 0) {
|
||||
return sprintf('PT%dM%dS', $minutes, $seconds);
|
||||
}
|
||||
|
||||
return sprintf('PT%dS', $seconds);
|
||||
}
|
||||
|
||||
// Scope for trending videos
|
||||
public function scopeTrending(, = 48, = 50)
|
||||
public function scopeTrending($query, $hours = 48, $limit = 50)
|
||||
{
|
||||
return ->public()
|
||||
return $query->public()
|
||||
->where('status', 'ready')
|
||||
->where('created_at', '>=', now()->subDays(10))
|
||||
->orderByDesc(\DB::raw('(
|
||||
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL ' . . ' 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 +
|
||||
GREATEST(0, 1 - TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) / 240) * 50 * 0.10 +
|
||||
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL '.$hours.' HOUR)) * 0.70 +
|
||||
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL '.$hours.' HOUR)) / '.$hours.' * 100 * 0.15 +
|
||||
GREATEST(0, TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) / 240) * 50 * 0.10 +
|
||||
(SELECT COUNT(*) FROM video_likes vl WHERE vl.video_id = videos.id) * 0.1 * 0.05
|
||||
)'))
|
||||
->limit();
|
||||
->limit($limit);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
@php
|
||||
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null;
|
||||
$thumbnailUrl = $video && $video->thumbnail
|
||||
? asset('storage/thumbnails/' . $video->thumbnail)
|
||||
$thumbnailUrl = $video && $video->thumbnail
|
||||
? asset('storage/thumbnails/' . $video->thumbnail)
|
||||
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
|
||||
|
||||
$typeIcon = $video ? match($video->type) {
|
||||
@ -12,6 +12,9 @@ $typeIcon = $video ? match($video->type) {
|
||||
default => 'bi-film',
|
||||
} : 'bi-film';
|
||||
|
||||
// Check if video is shorts
|
||||
$isShorts = $video && $video->isShorts();
|
||||
|
||||
// Check if current user is the owner of the video
|
||||
$isOwner = $video && auth()->check() && auth()->id() == $video->user_id;
|
||||
|
||||
@ -34,6 +37,11 @@ $sizeClasses = match($size) {
|
||||
@if($video && $video->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $video->duration) }}</span>
|
||||
@endif
|
||||
@if($isShorts)
|
||||
<span class="yt-shorts-badge">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
<div class="yt-video-info">
|
||||
@ -139,7 +147,7 @@ $sizeClasses = match($size) {
|
||||
<form id="edit-video-form-{{ $video->id ?? '' }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
|
||||
<!-- Title -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-card-heading"></i> Title</label>
|
||||
@ -171,6 +179,16 @@ $sizeClasses = match($size) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shorts Toggle -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
|
||||
<label class="cute-shorts-toggle">
|
||||
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $video->id ?? '' }}" value="1">
|
||||
<span class="cute-shorts-slider"></span>
|
||||
<span class="cute-shorts-label">Mark as Short</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-image"></i> Thumbnail</label>
|
||||
@ -267,6 +285,27 @@ $sizeClasses = match($size) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-shorts-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(230, 30, 30, 0.9);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-shorts-badge i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-info {
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
@ -544,6 +583,56 @@ $sizeClasses = match($size) {
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
/* Shorts Toggle in Edit Modal */
|
||||
.cute-shorts-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.cute-shorts-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: #333;
|
||||
border-radius: 12px;
|
||||
transition: 0.3s;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.cute-shorts-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider {
|
||||
background-color: #e63030;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.cute-shorts-label {
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Thumbnail Upload */
|
||||
.cute-thumbnail-upload {
|
||||
border: 2px dashed #444;
|
||||
@ -650,7 +739,7 @@ $sizeClasses = match($size) {
|
||||
max-width: 320px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
|
||||
.cute-type-options, .cute-privacy-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -658,6 +747,63 @@ $sizeClasses = match($size) {
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Global function to save to Watch Later
|
||||
function saveToWatchLater(videoId) {
|
||||
fetch(`/videos/${videoId}/watch-later`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message || 'Added to Watch Later');
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
// Global function to open playlist modal
|
||||
function openPlaylistModal(videoId) {
|
||||
// Set the current video ID for the modal as global variable
|
||||
window.currentVideoIdForModal = videoId;
|
||||
|
||||
// Close any open dropdown menus first
|
||||
const activeDropdowns = document.querySelectorAll('.dropdown-menu.show');
|
||||
activeDropdowns.forEach(function(dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
// Also close Bootstrap dropdowns by clicking the toggle
|
||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle[aria-expanded="true"]');
|
||||
dropdownToggles.forEach(function(toggle) {
|
||||
toggle.click();
|
||||
});
|
||||
|
||||
// Try to open the add to playlist modal
|
||||
if (typeof openAddToPlaylistModal === 'function') {
|
||||
openAddToPlaylistModal(videoId);
|
||||
} else {
|
||||
// Modal might not be loaded, try to find and show it directly
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
modal.style.opacity = '1';
|
||||
} else {
|
||||
// Fallback - redirect to login
|
||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function to add to queue
|
||||
function addToQueue(videoId) {
|
||||
alert('Queue feature coming soon!');
|
||||
}
|
||||
|
||||
function playVideo(element) {
|
||||
const video = element.querySelector('video');
|
||||
if (video) {
|
||||
@ -685,7 +831,7 @@ function openEditVideoModal(videoId) {
|
||||
const modalId = 'editVideoModal' + (videoId || '');
|
||||
const modal = new bootstrap.Modal(document.getElementById(modalId));
|
||||
modal.show();
|
||||
|
||||
|
||||
// Fetch video data
|
||||
fetch(`/videos/${videoId}/edit`, {
|
||||
headers: {
|
||||
@ -700,7 +846,7 @@ function openEditVideoModal(videoId) {
|
||||
const video = data.video;
|
||||
document.getElementById('edit-title-' + videoId).value = video.title || '';
|
||||
document.getElementById('edit-description-' + videoId).value = video.description || '';
|
||||
|
||||
|
||||
// Set type
|
||||
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
|
||||
typeOptions.forEach(opt => {
|
||||
@ -710,7 +856,7 @@ function openEditVideoModal(videoId) {
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Set privacy
|
||||
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
|
||||
privacyOptions.forEach(opt => {
|
||||
@ -720,7 +866,13 @@ function openEditVideoModal(videoId) {
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Set shorts toggle
|
||||
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
|
||||
if (shortsCheckbox) {
|
||||
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
|
||||
}
|
||||
|
||||
// Clear status
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
statusEl.className = 'cute-status';
|
||||
@ -780,10 +932,10 @@ document.addEventListener('submit', function(e) {
|
||||
const formData = new FormData(form);
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
const submitBtn = form.querySelector('.cute-btn-save');
|
||||
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving...';
|
||||
|
||||
|
||||
fetch(`/videos/${videoId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
||||
@ -4,11 +4,14 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@yield('title', config('app.name'))</title>
|
||||
|
||||
|
||||
<!-- Open Graph meta tags default - will be overridden by page-specific tags -->
|
||||
@stack('head')
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
|
||||
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<style>
|
||||
@ -20,18 +23,18 @@
|
||||
--text-primary: #f1f1f1;
|
||||
--text-secondary: #aaaaaa;
|
||||
}
|
||||
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* Header */
|
||||
.yt-header {
|
||||
position: fixed;
|
||||
@ -47,13 +50,13 @@
|
||||
z-index: 1000;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.yt-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.yt-menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@ -67,37 +70,38 @@
|
||||
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 */
|
||||
.yt-header-center {
|
||||
flex: 1;
|
||||
max-width: 640px;
|
||||
margin: 0 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.yt-search {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-input {
|
||||
flex: 1;
|
||||
background: #121212;
|
||||
@ -108,12 +112,12 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #1c62b9;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-btn {
|
||||
width: 64px;
|
||||
background: #222;
|
||||
@ -122,9 +126,9 @@
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-btn:hover { background: #303030; }
|
||||
|
||||
|
||||
.yt-search-voice {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@ -135,14 +139,14 @@
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Header Right */
|
||||
.yt-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@ -156,9 +160,9 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
|
||||
.yt-icon-btn:hover { background: var(--border-color); }
|
||||
|
||||
|
||||
.yt-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@ -166,7 +170,7 @@
|
||||
background: #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Sidebar */
|
||||
.yt-sidebar {
|
||||
position: fixed;
|
||||
@ -180,13 +184,13 @@
|
||||
transition: transform 0.3s;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
|
||||
.yt-sidebar-section {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
.yt-sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -199,16 +203,16 @@
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.yt-sidebar-link:hover { background: var(--border-color); }
|
||||
|
||||
|
||||
.yt-sidebar-link.active {
|
||||
background: var(--border-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.yt-sidebar-link i { font-size: 1.2rem; }
|
||||
|
||||
|
||||
/* Mobile sidebar overlay */
|
||||
.yt-sidebar-overlay {
|
||||
position: fixed;
|
||||
@ -220,9 +224,9 @@
|
||||
z-index: 998;
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.yt-sidebar-overlay.show { display: block; }
|
||||
|
||||
|
||||
/* Main Content */
|
||||
.yt-main {
|
||||
margin-top: 56px;
|
||||
@ -231,7 +235,7 @@
|
||||
min-height: calc(100vh - 56px);
|
||||
transition: margin-left 0.3s;
|
||||
}
|
||||
|
||||
|
||||
/* Upload Button */
|
||||
.yt-upload-btn {
|
||||
background: var(--brand-red);
|
||||
@ -246,9 +250,9 @@
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.yt-upload-btn:hover { background: #cc1a1a; }
|
||||
|
||||
|
||||
/* Mobile circle button */
|
||||
@media (max-width: 576px) {
|
||||
.yt-upload-btn {
|
||||
@ -258,10 +262,10 @@
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
}
|
||||
.yt-upload-btn span {
|
||||
display: none;
|
||||
.yt-upload-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Show text on larger mobile */
|
||||
@media (min-width: 400px) {
|
||||
.yt-upload-btn {
|
||||
@ -274,39 +278,39 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 992px) {
|
||||
.yt-sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
|
||||
.yt-main.collapsed {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.yt-sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
|
||||
.yt-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
|
||||
.yt-main {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-voice { display: none; }
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.yt-header-center { display: none; }
|
||||
.yt-main { padding: 16px; }
|
||||
}
|
||||
|
||||
|
||||
/* Mobile Search Toggle Button */
|
||||
.yt-mobile-search-toggle {
|
||||
width: 40px;
|
||||
@ -321,11 +325,11 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
|
||||
.yt-mobile-search-toggle:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
/* Mobile Search Overlay */
|
||||
.mobile-search-overlay {
|
||||
display: none;
|
||||
@ -340,18 +344,18 @@
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-input {
|
||||
flex: 1;
|
||||
background: #121212;
|
||||
@ -362,12 +366,12 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #1c62b9;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-submit {
|
||||
width: 50px;
|
||||
background: #222;
|
||||
@ -379,26 +383,26 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-submit:hover {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown-menu-dark {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
/* Modal input focus */
|
||||
#deleteVideoInput:focus {
|
||||
outline: none;
|
||||
@ -406,13 +410,13 @@
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@yield('extra_styles')
|
||||
</head>
|
||||
<body class="{{ $bodyClass ?? '' }}">
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
|
||||
<!-- Mobile Search Overlay -->
|
||||
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
||||
<form action="{{ route('videos.trending') }}" method="GET" class="mobile-search-form">
|
||||
@ -422,13 +426,13 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Sidebar Overlay (Mobile) -->
|
||||
<div class="yt-sidebar-overlay" onclick="toggleSidebar()"></div>
|
||||
|
||||
|
||||
<!-- Sidebar -->
|
||||
@include('layouts.partials.sidebar')
|
||||
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="yt-main @yield('main_class')" id="main">
|
||||
@yield('content')
|
||||
@ -438,8 +442,13 @@
|
||||
@auth
|
||||
@include('layouts.partials.upload-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
|
||||
@endauth
|
||||
|
||||
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
|
||||
@include('layouts.partials.add-to-playlist-modal')
|
||||
|
||||
<!-- Delete Video Modal -->
|
||||
@auth
|
||||
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
|
||||
@ -468,9 +477,9 @@
|
||||
<label for="deleteVideoInput" style="color: #aaa; font-size: 14px; margin-bottom: 8px; display: block;">
|
||||
To confirm deletion, type <strong style="color: #fff;">"<span id="deleteVideoName"></span>"</strong> below:
|
||||
</label>
|
||||
<input type="text"
|
||||
id="deleteVideoInput"
|
||||
class="form-control"
|
||||
<input type="text"
|
||||
id="deleteVideoInput"
|
||||
class="form-control"
|
||||
style="background: #282828; border: 1px solid #3f3f3f; color: #fff; padding: 12px 16px; border-radius: 8px; font-size: 14px;"
|
||||
placeholder="Enter video name">
|
||||
</div>
|
||||
@ -518,7 +527,7 @@
|
||||
function toggleMobileSearch() {
|
||||
const overlay = document.getElementById('mobileSearchOverlay');
|
||||
overlay.classList.toggle('active');
|
||||
|
||||
|
||||
// Focus input when overlay opens
|
||||
if (overlay.classList.contains('active')) {
|
||||
setTimeout(function() {
|
||||
@ -526,7 +535,7 @@
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Close mobile search on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
@ -536,16 +545,16 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Sidebar toggle function
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.querySelector('.yt-sidebar-overlay');
|
||||
const main = document.getElementById('main');
|
||||
|
||||
|
||||
// Check if we're on mobile or desktop
|
||||
const isMobile = window.innerWidth < 992;
|
||||
|
||||
|
||||
if (isMobile) {
|
||||
// Mobile behavior - use 'open' class
|
||||
sidebar.classList.toggle('open');
|
||||
@ -554,19 +563,19 @@
|
||||
// Desktop behavior - use 'collapsed' class
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('collapsed');
|
||||
|
||||
|
||||
// Save state to localStorage
|
||||
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||
localStorage.setItem('sidebarCollapsed', isCollapsed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Restore sidebar state from localStorage on page load
|
||||
function restoreSidebarState() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('main');
|
||||
const isMobile = window.innerWidth < 992;
|
||||
|
||||
|
||||
// Only restore on desktop
|
||||
if (!isMobile) {
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
@ -576,15 +585,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set active sidebar link based on current route
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Restore sidebar state
|
||||
restoreSidebarState();
|
||||
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const sidebarLinks = document.querySelectorAll('.yt-sidebar-link');
|
||||
|
||||
|
||||
sidebarLinks.forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href === currentPath || (currentPath.startsWith(href) && href !== '/')) {
|
||||
@ -594,13 +603,13 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Handle window resize to reset state for mobile
|
||||
window.addEventListener('resize', function() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('main');
|
||||
const isMobile = window.innerWidth < 992;
|
||||
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, remove collapsed state
|
||||
sidebar.classList.remove('collapsed');
|
||||
@ -610,35 +619,35 @@
|
||||
restoreSidebarState();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Delete video modal functions
|
||||
let currentDeleteVideoId = null;
|
||||
let currentDeleteVideoTitle = '';
|
||||
|
||||
|
||||
function showDeleteModal(videoId, videoTitle) {
|
||||
currentDeleteVideoId = videoId;
|
||||
currentDeleteVideoTitle = videoTitle;
|
||||
|
||||
|
||||
document.getElementById('deleteVideoName').textContent = videoTitle;
|
||||
document.getElementById('deleteVideoInput').value = '';
|
||||
|
||||
|
||||
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.style.opacity = '0.5';
|
||||
deleteBtn.style.cursor = 'not-allowed';
|
||||
|
||||
|
||||
// Close the dropdown first
|
||||
const dropdown = document.querySelector('.dropdown-menu.show');
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
|
||||
|
||||
// Show the modal
|
||||
const modalElement = document.getElementById('deleteVideoModal');
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
|
||||
document.getElementById('deleteVideoInput').addEventListener('input', function(e) {
|
||||
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
||||
if (e.target.value === currentDeleteVideoTitle) {
|
||||
@ -651,16 +660,16 @@
|
||||
deleteBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function confirmDeleteVideo() {
|
||||
if (!currentDeleteVideoId || !currentDeleteVideoTitle) return;
|
||||
|
||||
|
||||
const inputValue = document.getElementById('deleteVideoInput').value;
|
||||
if (inputValue !== currentDeleteVideoTitle) {
|
||||
alert('Video name does not match. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
fetch(`/videos/${currentDeleteVideoId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@ -669,7 +678,7 @@
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
// Check for successful response
|
||||
// Check for successful response
|
||||
if (response.status === 200 || response.status === 302 || response.redirected) {
|
||||
// Close modal first
|
||||
const modalElement = document.getElementById('deleteVideoModal');
|
||||
@ -697,7 +706,7 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@yield('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@ -711,17 +720,17 @@
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
|
||||
/* Reduce padding on main content */
|
||||
.yt-main {
|
||||
padding: 12px 8px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Smaller video title */
|
||||
.yt-video-title, .video-title {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Channel info compact */
|
||||
.channel-info {
|
||||
gap: 8px !important;
|
||||
@ -736,12 +745,12 @@
|
||||
.channel-subs {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Video meta smaller */
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Action buttons horizontal scroll on mobile */
|
||||
.video-actions {
|
||||
overflow-x: auto;
|
||||
@ -749,13 +758,13 @@
|
||||
padding-bottom: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.yt-action-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
/* Comment improvements */
|
||||
.comment-item {
|
||||
flex-direction: column;
|
||||
@ -764,36 +773,36 @@
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Search input mobile */
|
||||
.yt-search-input {
|
||||
font-size: 14px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
|
||||
/* Header spacing */
|
||||
.yt-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
|
||||
/* User dropdown full width on mobile */
|
||||
.dropdown-menu {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Very small screens */
|
||||
@media (max-width: 360px) {
|
||||
.yt-main {
|
||||
padding: 8px 4px !important;
|
||||
}
|
||||
|
||||
|
||||
.yt-header-right .yt-icon-btn:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Landscape mobile */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.yt-sidebar {
|
||||
@ -803,22 +812,23 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Prevent horizontal scroll */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
|
||||
/* Better video player on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.video-container {
|
||||
border-radius: 0 !important;
|
||||
margin: 0 -16px !important;
|
||||
max-width: calc(100% + 32px) !important;
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Sidebar link padding for touch */
|
||||
@media (hover: none) {
|
||||
.yt-sidebar-link {
|
||||
|
||||
401
resources/views/layouts/partials/add-to-playlist-modal.blade.php
Normal file
401
resources/views/layouts/partials/add-to-playlist-modal.blade.php
Normal file
@ -0,0 +1,401 @@
|
||||
<!-- Add to Playlist Modal -->
|
||||
<div id="addToPlaylistModal" class="playlist-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 9999; align-items: center; justify-content: center;">
|
||||
<div class="playlist-modal-content" style="background: #282828; border-radius: 12px; width: 90%; max-width: 380px; max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5); overflow: hidden;">
|
||||
<!-- Header -->
|
||||
<div class="playlist-modal-header" style="display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid #3f3f3f;">
|
||||
<h2 style="font-size: 18px; font-weight: 600; margin: 0; color: #fff;">Save to playlist</h2>
|
||||
<button type="button" id="closePlaylistModalBtn" style="background: transparent; border: none; color: #aaa; cursor: pointer; font-size: 24px; padding: 4px; line-height: 1; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: all 0.2s;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding: 16px 24px; flex: 1; overflow-y: auto;">
|
||||
<!-- Create New Playlist Option -->
|
||||
@auth
|
||||
<div style="margin-bottom: 16px;">
|
||||
<button onclick="showCreatePlaylistInModal()" class="create-playlist-btn" style="width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: transparent; border: none; color: #fff; cursor: pointer; border-radius: 8px; transition: background 0.2s; font-size: 14px;">
|
||||
<span style="width: 36px; height: 36px; background: #3f3f3f; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-plus-lg" style="font-size: 18px;"></i>
|
||||
</span>
|
||||
<span style="font-weight: 500;">Create new playlist</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Playlist Form (Hidden by default) -->
|
||||
<div id="createPlaylistInModal" style="display: none; margin-bottom: 16px; padding: 16px; background: #1f1f1f; border-radius: 10px; border: 1px solid #3f3f3f;">
|
||||
<input type="text" id="newPlaylistName" placeholder="Playlist name"
|
||||
style="width: 100%; padding: 12px 14px; margin-bottom: 12px; border: 1px solid #3f3f3f; border-radius: 8px; background: #121212; color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s;">
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||
<button onclick="hideCreatePlaylistInModal()" style="padding: 8px 16px; background: #3f3f3f; color: #fff; border: none; border-radius: 18px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;">Cancel</button>
|
||||
<button onclick="createPlaylistFromModal()" style="padding: 8px 16px; background: #e61e1e; color: #fff; border: none; border-radius: 18px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
<!-- Playlist List Container -->
|
||||
<div id="playlistListContainer" style="flex: 1; overflow-y: auto; min-height: 80px;">
|
||||
<!-- Playlist items will be loaded here via JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="padding: 16px 24px; border-top: 1px solid #3f3f3f;">
|
||||
<a href="{{ route('playlists.index') }}" style="display: flex; align-items: center; gap: 10px; color: #fff; text-decoration: none; font-size: 14px; padding: 10px 12px; border-radius: 8px; transition: background 0.2s;">
|
||||
<i class="bi bi-collection-play" style="font-size: 18px;"></i>
|
||||
<span style="font-weight: 500;">View all playlists</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Modal Overlay with proper centering */
|
||||
.playlist-modal-overlay {
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.playlist-modal-content {
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
}
|
||||
|
||||
/* Header button hover */
|
||||
.playlist-modal-header button:hover {
|
||||
background: #3f3f3f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Create playlist button hover */
|
||||
.create-playlist-btn:hover {
|
||||
background: #3f3f3f !important;
|
||||
}
|
||||
|
||||
/* Input focus */
|
||||
#newPlaylistName:focus {
|
||||
border-color: #e61e1e !important;
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
#createPlaylistInModal button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#createPlaylistInModal button:last-child:hover {
|
||||
background: #cc1a1a !important;
|
||||
}
|
||||
|
||||
/* Footer link hover */
|
||||
.playlist-modal-footer a:hover {
|
||||
background: #3f3f3f;
|
||||
}
|
||||
|
||||
/* Playlist items */
|
||||
.playlist-item {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.playlist-item:hover {
|
||||
background: #3f3f3f !important;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
#playlistListContainer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#playlistListContainer::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#playlistListContainer::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#playlistListContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentModalVideoId = null;
|
||||
|
||||
// Check if user is authenticated
|
||||
function isAuthenticated() {
|
||||
return {{ auth()->check() ? 'true' : 'false' }};
|
||||
}
|
||||
|
||||
// Close button event listener
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const closeBtn = document.getElementById('closePlaylistModalBtn');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
closeAddToPlaylistModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function openAddToPlaylistModal(videoId) {
|
||||
currentModalVideoId = videoId;
|
||||
|
||||
// Close any open dropdown menus first
|
||||
const activeDropdowns = document.querySelectorAll('.dropdown-menu.show');
|
||||
activeDropdowns.forEach(function(dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
// Also close Bootstrap dropdowns
|
||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle[aria-expanded="true"]');
|
||||
dropdownToggles.forEach(function(toggle) {
|
||||
toggle.click();
|
||||
});
|
||||
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
document.getElementById('createPlaylistInModal').style.display = 'none';
|
||||
|
||||
// Load playlists when modal opens
|
||||
loadPlaylistsForModal(videoId);
|
||||
}
|
||||
|
||||
function closeAddToPlaylistModal() {
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
modal.style.display = 'none';
|
||||
currentModalVideoId = null;
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('addToPlaylistModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeAddToPlaylistModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeAddToPlaylistModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load playlists for modal
|
||||
function loadPlaylistsForModal(videoId) {
|
||||
const container = document.getElementById('playlistListContainer');
|
||||
|
||||
@auth
|
||||
// Fetch playlists data
|
||||
fetch('{{ route("playlists.userPlaylists") }}', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.playlists && data.playlists.length > 0) {
|
||||
let html = '';
|
||||
data.playlists.forEach(function(playlist) {
|
||||
const isInPlaylist = playlist.video_ids && playlist.video_ids.includes(parseInt(videoId));
|
||||
const visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private';
|
||||
const durationText = playlist.formatted_duration || '0m';
|
||||
html += `
|
||||
<div class="playlist-item" style="display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border-radius: 8px; cursor: pointer; transition: background 0.2s; margin-bottom: 8px;"
|
||||
onmouseover="this.style.background='#3f3f3f'"
|
||||
onmouseout="this.style.background='transparent'"
|
||||
onclick="toggleVideoInPlaylist(${playlist.id}, ${videoId})">
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 0;">
|
||||
<div style="width: 100px; height: 56px; background: #1a1a1a; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative;">
|
||||
${playlist.thumbnail_url
|
||||
? `<img src="${playlist.thumbnail_url}" style="width: 100%; height: 100%; object-fit: cover;">`
|
||||
: `<i class="bi bi-collection-play" style="font-size: 24px; color: #666;"></i>`
|
||||
}
|
||||
${playlist.is_default
|
||||
? `<span style="position: absolute; top: 2px; left: 2px; background: rgba(230,30,30,0.9); color: white; padding: 1px 4px; border-radius: 2px; font-size: 9px; font-weight: 600;">WL</span>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div style="min-width: 0; flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #fff;">${playlist.name}</div>
|
||||
<div style="font-size: 12px; color: #aaa; margin-top: 2px;">
|
||||
${playlist.video_count || 0} videos • ${durationText}
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #777; margin-top: 2px;">
|
||||
${visibilityText}
|
||||
</div>
|
||||
${playlist.description ? `<div style="font-size: 11px; color: #777; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${playlist.description}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex-shrink: 0; margin-left: 8px;">
|
||||
${isInPlaylist
|
||||
? '<i class="bi bi-check-circle-fill" style="color: #e61e1e; font-size: 20px;"></i>'
|
||||
: '<i class="bi bi-plus-circle" style="color: #aaa; font-size: 20px;"></i>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; color: #aaa; padding: 30px 20px;">
|
||||
<i class="bi bi-collection-play" style="font-size: 36px; margin-bottom: 12px; display: block; color: #555;"></i>
|
||||
<p style="margin: 0 0 8px; font-size: 14px; color: #fff;">No playlists yet</p>
|
||||
<p style="margin: 0; font-size: 12px;">Create a playlist to save videos</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading playlists:', error);
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; color: #aaa; padding: 30px 20px;">
|
||||
<i class="bi bi-exclamation-circle" style="font-size: 28px; margin-bottom: 12px; display: block; color: #ef4444;"></i>
|
||||
<p style="margin: 0; font-size: 14px; color: #fff;">Failed to load playlists</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@else
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; color: #aaa; padding: 30px 20px;">
|
||||
<i class="bi bi-person-circle" style="font-size: 40px; margin-bottom: 12px; display: block; color: #555;"></i>
|
||||
<p style="margin: 0 0 8px; font-size: 14px; color: #fff;">Sign in to save videos to playlists</p>
|
||||
<p style="margin: 0 0 16px; font-size: 12px;">Create a playlist to organize your videos</p>
|
||||
<a href="{{ route('login') }}?redirect={{ urlencode(request()->fullUrl()) }}"
|
||||
style="display: inline-block; padding: 10px 24px; background: #e61e1e; color: white; border-radius: 20px; text-decoration: none; font-weight: 500; font-size: 14px; transition: background 0.2s;">
|
||||
Sign In
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
@endauth
|
||||
}
|
||||
|
||||
// Toggle video in playlist
|
||||
function toggleVideoInPlaylist(playlistId, videoId) {
|
||||
if (!videoId) return;
|
||||
|
||||
// Check authentication before adding
|
||||
if (!isAuthenticated()) {
|
||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/playlists/${playlistId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ video_id: videoId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message || 'Video added to playlist');
|
||||
// Reload playlists to update checkmarks
|
||||
loadPlaylistsForModal(videoId);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
// Show create playlist form in modal
|
||||
function showCreatePlaylistInModal() {
|
||||
// Check authentication before creating playlist
|
||||
if (!isAuthenticated()) {
|
||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
return;
|
||||
}
|
||||
document.getElementById('createPlaylistInModal').style.display = 'block';
|
||||
document.getElementById('newPlaylistName').focus();
|
||||
}
|
||||
|
||||
// Hide create playlist form
|
||||
function hideCreatePlaylistInModal() {
|
||||
document.getElementById('createPlaylistInModal').style.display = 'none';
|
||||
document.getElementById('newPlaylistName').value = '';
|
||||
}
|
||||
|
||||
// Create playlist from modal
|
||||
function createPlaylistFromModal() {
|
||||
const name = document.getElementById('newPlaylistName').value.trim();
|
||||
if (!name) {
|
||||
alert('Please enter a playlist name');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ route("playlists.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
visibility: 'private'
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
hideCreatePlaylistInModal();
|
||||
showToast('Playlist created!');
|
||||
// Reload playlists to show new playlist
|
||||
loadPlaylistsForModal(currentModalVideoId);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
// Simple toast notification
|
||||
function showToast(message) {
|
||||
// Remove existing toast if any
|
||||
const existing = document.querySelector('.playlist-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'playlist-toast';
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
color: #0f0f0f;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
animation: fadeInUp 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOutDown 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes fadeOutDown {
|
||||
from { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
to { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()">
|
||||
<i class="bi bi-list fs-5"></i>
|
||||
</button>
|
||||
<a href="/videos" class="yt-logo">
|
||||
<a href="{{ route('home') }}" class="yt-logo">
|
||||
<!-- Mobile logo (visible only on mobile) -->
|
||||
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height: 30px;">
|
||||
<!-- Desktop logo (visible only on desktop) -->
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
<!-- Sidebar -->
|
||||
<nav class="yt-sidebar" id="sidebar">
|
||||
<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>
|
||||
<span>Home</span>
|
||||
</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>
|
||||
<span>Shorts</span>
|
||||
</a>
|
||||
<a href="{{ route('videos.trending') }}" class="yt-sidebar-link {{ request()->is('trending') ? 'active' : '' }}">
|
||||
<i class="bi bi-fire"></i>
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
@auth
|
||||
<a href="#" class="yt-sidebar-link">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
@ -16,13 +20,17 @@
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
|
||||
@auth
|
||||
<div class="yt-sidebar-section">
|
||||
<a href="{{ route('channel', Auth::user()->id) }}" class="yt-sidebar-link">
|
||||
<i class="bi bi-person-video"></i>
|
||||
<span>Your Channel</span>
|
||||
</a>
|
||||
<a href="{{ route('playlists.index') }}" class="yt-sidebar-link {{ request()->is('playlists*') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
<span>Playlists</span>
|
||||
</a>
|
||||
<a href="{{ route('history') }}" class="yt-sidebar-link">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
<span>History</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@yield('title', config('app.name'))</title>
|
||||
|
||||
<!-- Open Graph meta tags default - will be overridden by page-specific tags -->
|
||||
@stack('head')
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
|
||||
@ -18,53 +22,161 @@
|
||||
--text-primary: #f1f1f1;
|
||||
--text-secondary: #aaaaaa;
|
||||
}
|
||||
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.yt-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--bg-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
z-index: 1000;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.yt-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.yt-menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.yt-menu-btn:hover { background: var(--border-color); }
|
||||
|
||||
.yt-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.yt-logo-text {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
/* Search - Hidden on plain layout */
|
||||
.yt-header-center { display: none; }
|
||||
|
||||
/* Header Right */
|
||||
.yt-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.yt-icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.yt-icon-btn:hover { background: var(--border-color); }
|
||||
|
||||
.yt-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.plain-main {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
padding-top: 76px; /* 56px header + 20px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh);
|
||||
}
|
||||
|
||||
|
||||
/* Upload Button */
|
||||
.yt-upload-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-upload-btn:hover { background: #cc1a1a; }
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown-menu-dark {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@yield('extra_styles')
|
||||
</head>
|
||||
<body>
|
||||
<!-- Main Content - No header or sidebar -->
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="plain-main">
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
|
||||
@yield('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
522
resources/views/playlists/index.blade.php
Normal file
522
resources/views/playlists/index.blade.php
Normal file
@ -0,0 +1,522 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'My Playlists | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.playlist-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.playlist-thumbnail {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playlist-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.playlist-thumbnail .placeholder {
|
||||
font-size: 32px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.playlist-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.create-playlist-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.create-playlist-btn:hover {
|
||||
background: #cc1a1a;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.playlist-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.playlist-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playlist-thumbnail {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="playlist-header">
|
||||
<h1 style="font-size: 24px; font-weight: 500;">My Playlists</h1>
|
||||
<button class="create-playlist-btn" onclick="openCreatePlaylistModal()">
|
||||
<i class="bi bi-plus-lg"></i> New Playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($playlists->isEmpty())
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-collection-play empty-icon"></i>
|
||||
<h2 class="empty-title">No playlists yet</h2>
|
||||
<p class="empty-text">Create your first playlist to organize your favorite videos.</p>
|
||||
<button class="create-playlist-btn" onclick="openCreatePlaylistModal()">
|
||||
<i class="bi bi-plus-lg"></i> Create Playlist
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="playlist-grid">
|
||||
@foreach($playlists as $playlist)
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="playlist-card">
|
||||
<div class="playlist-thumbnail">
|
||||
@if($playlist->thumbnail_url)
|
||||
<img src="{{ $playlist->thumbnail_url }}" alt="{{ $playlist->name }}">
|
||||
@else
|
||||
<span class="placeholder">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-name">{{ $playlist->name }}</div>
|
||||
<div class="playlist-meta">
|
||||
{{ $playlist->video_count }} videos • {{ $playlist->formatted_duration }}
|
||||
</div>
|
||||
@if($playlist->description)
|
||||
<div class="playlist-description">{{ $playlist->description }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Create Playlist Modal - Modern YouTube Style -->
|
||||
<div id="createPlaylistModal" class="playlist-create-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 9999; align-items: center; justify-content: center; backdrop-filter: blur(2px);">
|
||||
<div class="playlist-create-modal-content" style="background: #282828; border-radius: 12px; width: 90%; max-width: 480px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); overflow: hidden; animation: modalSlideIn 0.2s ease;">
|
||||
<!-- Header -->
|
||||
<div class="playlist-create-modal-header" style="display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid #3f3f3f;">
|
||||
<h2 style="font-size: 18px; font-weight: 600; margin: 0; color: #fff;">Create new playlist</h2>
|
||||
<button type="button" id="closeCreatePlaylistModalBtn" style="background: transparent; border: none; color: #aaa; cursor: pointer; font-size: 24px; padding: 4px; line-height: 1; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: all 0.2s;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding: 24px;">
|
||||
<form id="createPlaylistForm" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<!-- Thumbnail Upload -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; color: #fff;">Thumbnail</label>
|
||||
<div id="playlist-thumbnail-dropzone" class="playlist-thumbnail-dropzone" style="border: 2px dashed #3f3f3f; border-radius: 12px; padding: 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #1a1a1a;"
|
||||
onmouseover="this.style.borderColor='#e61e1e'; this.style.background='rgba(230,30,30,0.05)'"
|
||||
onmouseout="this.style.borderColor='#3f3f3f'; this.style.background='#1a1a1a'">
|
||||
<input type="file" name="thumbnail" id="playlist-thumbnail-input" accept="image/*" style="display: none;">
|
||||
<div id="playlist-thumbnail-default">
|
||||
<div style="font-size: 36px; color: #666; margin-bottom: 8px;">
|
||||
<i class="bi bi-card-image"></i>
|
||||
</div>
|
||||
<p style="color: #aaa; font-size: 13px; margin: 0 0 4px;">Click to upload thumbnail</p>
|
||||
<p style="color: #555; font-size: 11px; margin: 0;">JPG, PNG, GIF, WebP (max 5MB)</p>
|
||||
</div>
|
||||
<div id="playlist-thumbnail-preview" class="playlist-thumbnail-preview" style="display: none;">
|
||||
<img id="playlist-thumbnail-img" src="" alt="Thumbnail preview" style="max-width: 100%; max-height: 160px; border-radius: 8px; object-fit: cover;">
|
||||
<button type="button" onclick="removePlaylistThumbnail(event)" style="position: absolute; top: -8px; right: -8px; width: 24px; height: 24px; background: #e61e1e; color: white; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px;">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Name -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; color: #fff;">Name *</label>
|
||||
<input type="text" name="name" required
|
||||
placeholder="Enter playlist name"
|
||||
style="width: 100%; padding: 12px 14px; border: 1px solid #3f3f3f; border-radius: 8px; background: #121212; color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s; box-sizing: border-box;"
|
||||
onfocus="this.style.borderColor = '#e61e1e';"
|
||||
onblur="this.style.borderColor = '#3f3f3f';">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; color: #fff;">Description</label>
|
||||
<textarea name="description" rows="3"
|
||||
placeholder="Add a description (optional)"
|
||||
style="width: 100%; padding: 12px 14px; border: 1px solid #3f3f3f; border-radius: 8px; background: #121212; color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s; resize: none; box-sizing: border-box;"
|
||||
onfocus="this.style.borderColor = '#e61e1e';"
|
||||
onblur="this.style.borderColor = '#3f3f3f';"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div class="form-group" style="margin-bottom: 24px;">
|
||||
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 10px 12px; border-radius: 8px; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#3f3f3f'"
|
||||
onmouseout="this.style.background='transparent'">
|
||||
<input type="checkbox" name="visibility" value="public"
|
||||
style="width: 20px; height: 20px; accent-color: #e61e1e; cursor: pointer;">
|
||||
<div style="flex: 1;">
|
||||
<span style="color: #fff; font-size: 14px; font-weight: 500;">Make playlist public</span>
|
||||
<div style="color: #aaa; font-size: 12px; margin-top: 2px;">Anyone can search for and view this playlist</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button type="button" onclick="closeCreatePlaylistModal()"
|
||||
style="padding: 10px 20px; background: #3f3f3f; color: #fff; border: none; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#555'"
|
||||
onmouseout="this.style.background='#3f3f3f'">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
style="padding: 10px 24px; background: #e61e1e; color: #fff; border: none; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#cc1a1a'"
|
||||
onmouseout="this.style.background='#e61e1e'">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
<style>
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header button hover */
|
||||
#closeCreatePlaylistModalBtn:hover {
|
||||
background: #3f3f3f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
// Thumbnail upload handling
|
||||
const thumbnailDropzone = document.getElementById('playlist-thumbnail-dropzone');
|
||||
const thumbnailInput = document.getElementById('playlist-thumbnail-input');
|
||||
const thumbnailDefault = document.getElementById('playlist-thumbnail-default');
|
||||
const thumbnailPreview = document.getElementById('playlist-thumbnail-preview');
|
||||
const thumbnailImg = document.getElementById('playlist-thumbnail-img');
|
||||
|
||||
// Click to upload
|
||||
thumbnailDropzone.addEventListener('click', function(e) {
|
||||
if (e.target.closest('button')) return;
|
||||
thumbnailInput.click();
|
||||
});
|
||||
|
||||
// File input change
|
||||
thumbnailInput.addEventListener('change', function() {
|
||||
handleThumbnailSelect(this);
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
thumbnailDropzone.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderColor = '#e61e1e';
|
||||
this.style.background = 'rgba(230,30,30,0.1)';
|
||||
});
|
||||
|
||||
thumbnailDropzone.addEventListener('dragleave', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderColor = '#3f3f3f';
|
||||
this.style.background = '#1a1a1a';
|
||||
});
|
||||
|
||||
thumbnailDropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderColor = '#3f3f3f';
|
||||
this.style.background = '#1a1a1a';
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
thumbnailInput.files = e.dataTransfer.files;
|
||||
handleThumbnailSelect(thumbnailInput);
|
||||
}
|
||||
});
|
||||
|
||||
function handleThumbnailSelect(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
|
||||
let isValidType = validTypes.includes(file.type);
|
||||
let fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
if (!isValidType) {
|
||||
isValidType = validExtensions.includes(fileExtension);
|
||||
}
|
||||
|
||||
if (!isValidType) {
|
||||
showPlaylistToast('Invalid image format. Please select JPG, PNG, GIF, or WebP.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
showPlaylistToast('File size exceeds 5MB limit. Please select a smaller image.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
thumbnailImg.src = e.target.result;
|
||||
thumbnailDefault.style.display = 'none';
|
||||
thumbnailPreview.style.display = 'block';
|
||||
thumbnailPreview.style.position = 'relative';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function removePlaylistThumbnail(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
thumbnailInput.value = '';
|
||||
thumbnailDefault.style.display = 'block';
|
||||
thumbnailPreview.style.display = 'none';
|
||||
thumbnailImg.src = '';
|
||||
}
|
||||
|
||||
function openCreatePlaylistModal() {
|
||||
document.getElementById('createPlaylistModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeCreatePlaylistModal() {
|
||||
document.getElementById('createPlaylistModal').style.display = 'none';
|
||||
document.getElementById('createPlaylistForm').reset();
|
||||
// Reset thumbnail
|
||||
removePlaylistThumbnail({ preventDefault: function(){}, stopPropagation: function(){} });
|
||||
}
|
||||
|
||||
// Close button event listener
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const closeBtn = document.getElementById('closeCreatePlaylistModalBtn');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
closeCreatePlaylistModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('createPlaylistModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreatePlaylistModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('createPlaylistModal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeCreatePlaylistModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create playlist form submission
|
||||
document.getElementById('createPlaylistForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
fetch('{{ route("playlists.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
closeCreatePlaylistModal();
|
||||
window.location.href = '{{ route("playlists.show", "") }}/' + data.playlist.id;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showPlaylistToast('Failed to create playlist. Please try again.', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// Toast notification function
|
||||
function showPlaylistToast(message, type = 'success') {
|
||||
// Remove existing toast if any
|
||||
const existing = document.querySelector('.playlist-create-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'playlist-create-toast';
|
||||
|
||||
const bgColor = type === 'error' ? 'rgba(239, 68, 68, 0.9)' : '#282828';
|
||||
const borderColor = type === 'error' ? '#ef4444' : '#4ade80';
|
||||
const icon = type === 'error' ? 'bi-exclamation-circle-fill' : 'bi-check-circle-fill';
|
||||
const iconColor = type === 'error' ? '#ef4444' : '#4ade80';
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: ${bgColor};
|
||||
color: #fff;
|
||||
padding: 14px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
animation: toastSlideUp 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
border-left: 4px solid ${borderColor};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
toast.innerHTML = `
|
||||
<i class="bi ${icon}" style="color: ${iconColor}; font-size: 18px;"></i>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'toastSlideDown 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
<style>
|
||||
@keyframes toastSlideUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes toastSlideDown {
|
||||
from { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
to { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
}
|
||||
</style>
|
||||
838
resources/views/playlists/show.blade.php
Normal file
838
resources/views/playlists/show.blade.php
Normal file
@ -0,0 +1,838 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $playlist->name . ' | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.css">
|
||||
<style>
|
||||
.playlist-header {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.playlist-thumbnail {
|
||||
width: 240px;
|
||||
height: 135px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.playlist-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.playlist-thumbnail .placeholder {
|
||||
font-size: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.playlist-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.yt-play-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-play-btn:hover {
|
||||
background: #cc1a1a;
|
||||
}
|
||||
|
||||
.yt-shuffle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-shuffle-btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.playlist-video-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playlist-video-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.playlist-video-item:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.video-position {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-position.with-drag-handle {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.video-thumb {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.video-channel {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.video-channel:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-handle i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Drag and drop styles */
|
||||
.playlist-video-list.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.playlist-video-list.sortable-drag {
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.playlist-video-item.sortable-chosen {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.playlist-video-item.dragging {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.drag-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.playlist-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playlist-thumbnail {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.video-thumb {
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
.playlist-video-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-position {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="playlist-header">
|
||||
<div class="playlist-thumbnail">
|
||||
@if($playlist->thumbnail_url)
|
||||
<img src="{{ $playlist->thumbnail_url }}" alt="{{ $playlist->name }}">
|
||||
@else
|
||||
<span class="placeholder">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<h1 class="playlist-title">{{ $playlist->name }}</h1>
|
||||
<div class="playlist-meta">
|
||||
{{ $playlist->video_count }} videos • {{ $playlist->formatted_duration }} •
|
||||
{{ $playlist->visibility === 'public' ? 'Public' : 'Private' }}
|
||||
@if($playlist->is_default)
|
||||
<span style="background: var(--brand-red); color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">Watch Later</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($playlist->description)
|
||||
<div class="playlist-description">{{ $playlist->description }}</div>
|
||||
@endif
|
||||
<div class="playlist-actions">
|
||||
@if($playlist->video_count > 0)
|
||||
<a href="{{ route('playlists.playAll', $playlist->id) }}" class="yt-play-btn">
|
||||
<i class="bi bi-play-fill"></i> Play All
|
||||
</a>
|
||||
<a href="{{ route('playlists.shuffle', $playlist->id) }}" class="yt-shuffle-btn">
|
||||
<i class="bi bi-shuffle"></i> Shuffle
|
||||
</a>
|
||||
@endif
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<button class="yt-shuffle-btn" onclick="openEditPlaylistModal()">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($videos->isEmpty())
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-collection-play empty-icon"></i>
|
||||
<h2 class="empty-title">No videos in this playlist</h2>
|
||||
<p class="empty-text">Add videos to this playlist to watch them here.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="playlist-video-list" id="playlistVideoList" data-current-page="{{ $videos->currentPage() }}" data-per-page="{{ $videos->perPage() }}">
|
||||
@foreach($videos as $index => $video)
|
||||
<div class="playlist-video-item{{ $playlist->canEdit(Auth::user()) ? ' has-drag-handle' : '' }}" data-video-id="{{ $video->id }}" data-global-position="{{ $index + ($videos->currentPage() - 1) * $videos->perPage() }}">
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<div class="drag-handle" title="Drag to reorder">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
</div>
|
||||
@endif
|
||||
<div class="video-position{{ !$playlist->canEdit(Auth::user()) ? '' : ' with-drag-handle' }}">{{ $index + 1 + ($videos->currentPage() - 1) * $videos->perPage() }}</div>
|
||||
<a href="{{ route('videos.show', $video->id) }}?playlist={{ $playlist->id }}" class="video-thumb">
|
||||
<img src="{{ $video->thumbnail_url }}" alt="{{ $video->title }}">
|
||||
<span class="video-duration">{{ $video->formatted_duration }}</span>
|
||||
</a>
|
||||
<div class="video-details">
|
||||
<div class="video-title">
|
||||
<a href="{{ route('videos.show', $video->id) }}?playlist={{ $playlist->id }}">{{ $video->title }}</a>
|
||||
</div>
|
||||
<div class="video-meta">
|
||||
<a href="{{ route('channel', $video->user_id) }}" class="video-channel">{{ $video->user->name }}</a>
|
||||
• {{ $video->view_count }} views
|
||||
• {{ $video->created_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<div class="video-actions">
|
||||
<form action="{{ route('playlists.removeVideo', [$playlist->id, $video->id]) }}" method="POST">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="remove-btn" title="Remove from playlist">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
{{ $videos->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- SortableJS Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<!-- Edit Playlist Modal -->
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<div id="editPlaylistModal" class="edit-playlist-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 2000; align-items: center; justify-content: center;">
|
||||
<div class="edit-playlist-modal-content" style="background: #282828; border-radius: 16px; width: 90%; max-width: 480px; display: flex; flex-direction: column; box-shadow: 0 12px 48px rgba(0,0,0,0.6); overflow: hidden; animation: modalSlideIn 0.3s ease;">
|
||||
<!-- Header -->
|
||||
<div class="edit-playlist-modal-header" style="display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid #3f3f3f;">
|
||||
<h2 style="font-size: 20px; font-weight: 600; margin: 0; color: #fff;">Edit Details</h2>
|
||||
<button type="button" onclick="closeEditPlaylistModal()" style="background: transparent; border: none; color: #aaa; cursor: pointer; font-size: 24px; padding: 4px; line-height: 1; border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; transition: all 0.2s;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding: 24px; flex: 1; overflow-y: auto;">
|
||||
<!-- Thumbnail Upload -->
|
||||
<div style="margin-bottom: 24px;">
|
||||
<label style="display: block; margin-bottom: 12px; font-weight: 500; color: #fff; font-size: 14px;">Thumbnail</label>
|
||||
<div id="playlistThumbnailContainer" style="position: relative; width: 100%; aspect-ratio: 16/9; max-width: 240px; border-radius: 12px; overflow: hidden; background: #1a1a1a; cursor: pointer; transition: all 0.2s; margin: 0 auto;" onclick="document.getElementById('playlistThumbnailInput').click()">
|
||||
@if($playlist->thumbnail_url && strpos($playlist->thumbnail_url, 'ui-avatars.com') === false)
|
||||
<img id="playlistThumbnailPreview" src="{{ $playlist->thumbnail_url }}" style="width: 100%; height: 100%; object-fit: cover;">
|
||||
@else
|
||||
<img id="playlistThumbnailPreview" src="" style="width: 100%; height: 100%; object-fit: cover; display: none;">
|
||||
@endif
|
||||
<div id="playlistThumbnailPlaceholder" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #1a1a1a; @if($playlist->thumbnail_url && strpos($playlist->thumbnail_url, 'ui-avatars.com') === false)display: none;@endif">
|
||||
<i class="bi bi-image" style="font-size: 32px; color: #555; margin-bottom: 8px;"></i>
|
||||
<span style="font-size: 12px; color: #777;">Upload thumbnail</span>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); padding: 6px 10px; border-radius: 6px; display: flex; align-items: center; gap: 4px;">
|
||||
<i class="bi bi-camera" style="color: white; font-size: 14px;"></i>
|
||||
<span style="color: white; font-size: 12px;">Change</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="playlistThumbnailInput" name="thumbnail" accept="image/*" style="display: none;" onchange="handlePlaylistThumbnailUpload(this)">
|
||||
@if($playlist->thumbnail_url && strpos($playlist->thumbnail_url, 'ui-avatars.com') === false)
|
||||
<button type="button" onclick="removePlaylistThumbnail(event)" style="display: block; margin: 12px auto 0; background: transparent; border: 1px solid #555; color: #aaa; padding: 6px 16px; border-radius: 18px; cursor: pointer; font-size: 12px; transition: all 0.2s;">Remove thumbnail</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Name Input -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #fff; font-size: 14px;">Name</label>
|
||||
<input type="text" name="name" value="{{ $playlist->name }}" required
|
||||
style="width: 100%; padding: 14px 16px; border: 1px solid #3f3f3f; border-radius: 10px; background: #1a1a1a; color: #fff; font-size: 14px; outline: none; transition: all 0.2s;">
|
||||
</div>
|
||||
|
||||
<!-- Description Input -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #fff; font-size: 14px;">Description</label>
|
||||
<textarea name="description" class="form-control" rows="3" placeholder="Tell viewers about your playlist"
|
||||
style="width: 100%; padding: 14px 16px; border: 1px solid #3f3f3f; border-radius: 10px; background: #1a1a1a; color: #fff; font-size: 14px; outline: none; transition: all 0.2s; resize: none;">{{ $playlist->description }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Toggle -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 12px 16px; background: #1a1a1a; border-radius: 10px; border: 1px solid #3f3f3f; transition: all 0.2s;">
|
||||
<input type="checkbox" name="visibility" value="public" {{ $playlist->visibility === 'public' ? 'checked' : '' }}
|
||||
style="width: 20px; height: 20px; accent-color: #e61e1e; cursor: pointer;">
|
||||
<div style="flex: 1;">
|
||||
<span style="color: #fff; font-size: 14px; font-weight: 500;">Make playlist public</span>
|
||||
<p style="margin: 2px 0 0; font-size: 12px; color: #777;">Anyone can search for and view this playlist</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="padding: 16px 24px; border-top: 1px solid #3f3f3f; display: flex; gap: 12px; justify-content: space-between;">
|
||||
@if(!$playlist->is_default)
|
||||
<button type="button" onclick="deletePlaylist()" style="background: transparent; border: 1px solid #e61e1e; color: #e61e1e; padding: 12px 20px; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s;">
|
||||
<i class="bi bi-trash" style="margin-right: 6px;"></i>Delete
|
||||
</button>
|
||||
@else
|
||||
<div></div>
|
||||
@endif
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<button type="button" onclick="closeEditPlaylistModal()" style="background: #3f3f3f; border: none; color: #fff; padding: 12px 20px; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s;">Cancel</button>
|
||||
<button type="submit" form="editPlaylistForm" style="background: #e61e1e; border: none; color: #fff; padding: 12px 24px; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s;">
|
||||
<i class="bi bi-check-lg" style="margin-right: 4px;"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="editPlaylistForm" method="POST" action="{{ route('playlists.update', $playlist->id) }}" enctype="multipart/form-data" style="display: none;">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="name" value="{{ $playlist->name }}">
|
||||
<input type="hidden" name="description" value="{{ $playlist->description }}">
|
||||
<input type="hidden" name="visibility" value="{{ $playlist->visibility }}">
|
||||
<input type="file" name="thumbnail" id="editPlaylistThumbnailFile" accept="image/*">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Open Edit Playlist Modal
|
||||
function openEditPlaylistModal() {
|
||||
const modal = document.getElementById('editPlaylistModal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Sync form values
|
||||
const nameInput = modal.querySelector('input[name="name"]');
|
||||
const descInput = modal.querySelector('textarea[name="description"]');
|
||||
const visInput = modal.querySelector('input[name="visibility"]');
|
||||
|
||||
if (nameInput) nameInput.value = '{{ $playlist->name }}';
|
||||
if (descInput) descInput.value = '{{ $playlist->description }}';
|
||||
}
|
||||
|
||||
// Close Edit Playlist Modal
|
||||
function closeEditPlaylistModal() {
|
||||
const modal = document.getElementById('editPlaylistModal');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Handle Thumbnail Upload
|
||||
function handlePlaylistThumbnailUpload(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Image size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview image
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const preview = document.getElementById('playlistThumbnailPreview');
|
||||
const placeholder = document.getElementById('playlistThumbnailPlaceholder');
|
||||
|
||||
preview.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Copy file to hidden form input
|
||||
const formFileInput = document.getElementById('editPlaylistThumbnailFile');
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
formFileInput.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Thumbnail
|
||||
function removePlaylistThumbnail(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (confirm('Are you sure you want to remove the thumbnail?')) {
|
||||
// Show placeholder
|
||||
const preview = document.getElementById('playlistThumbnailPreview');
|
||||
const placeholder = document.getElementById('playlistThumbnailPlaceholder');
|
||||
|
||||
preview.src = '';
|
||||
preview.style.display = 'none';
|
||||
if (placeholder) placeholder.style.display = 'flex';
|
||||
|
||||
// Clear file inputs
|
||||
document.getElementById('playlistThumbnailInput').value = '';
|
||||
document.getElementById('editPlaylistThumbnailFile').value = '';
|
||||
|
||||
// Add hidden input to indicate thumbnail removal
|
||||
const form = document.getElementById('editPlaylistForm');
|
||||
let removeInput = form.querySelector('input[name="remove_thumbnail"]');
|
||||
if (!removeInput) {
|
||||
removeInput = document.createElement('input');
|
||||
removeInput.type = 'hidden';
|
||||
removeInput.name = 'remove_thumbnail';
|
||||
removeInput.value = '1';
|
||||
form.appendChild(removeInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit Edit Playlist Form
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const editForm = document.getElementById('editPlaylistForm');
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Sync visible form values to hidden form
|
||||
const modal = document.getElementById('editPlaylistModal');
|
||||
const visibleName = modal.querySelector('input[name="name"]');
|
||||
const visibleDesc = modal.querySelector('textarea[name="description"]');
|
||||
const visibleVis = modal.querySelector('input[name="visibility"]');
|
||||
|
||||
const hiddenName = editForm.querySelector('input[name="name"]');
|
||||
const hiddenDesc = editForm.querySelector('input[name="description"]');
|
||||
const hiddenVis = editForm.querySelector('input[name="visibility"]');
|
||||
|
||||
if (hiddenName) hiddenName.value = visibleName ? visibleName.value : '';
|
||||
if (hiddenDesc) hiddenDesc.value = visibleDesc ? visibleDesc.value : '';
|
||||
if (hiddenVis) hiddenVis.value = visibleVis.checked ? 'public' : 'private';
|
||||
|
||||
// Submit the form via AJAX
|
||||
const formData = new FormData(editForm);
|
||||
|
||||
fetch(editForm.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('Playlist updated!');
|
||||
closeEditPlaylistModal();
|
||||
// Reload page to show updated info
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.message || 'Failed to update playlist');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to update playlist');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Simple toast notification
|
||||
function showToast(message) {
|
||||
const existing = document.querySelector('.edit-playlist-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'edit-playlist-toast';
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
color: #0f0f0f;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10001;
|
||||
animation: fadeInUp 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOutDown 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// Initialize Sortable for drag and drop reordering
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const videoList = document.getElementById('playlistVideoList');
|
||||
|
||||
if (videoList && typeof Sortable !== 'undefined') {
|
||||
new Sortable(videoList, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
easing: 'cubic-bezier(1, 0, 0, 1)',
|
||||
delay: 0,
|
||||
forceFallback: false,
|
||||
|
||||
onStart: function(evt) {
|
||||
evt.item.classList.add('dragging');
|
||||
},
|
||||
|
||||
onEnd: function(evt) {
|
||||
evt.item.classList.remove('dragging');
|
||||
|
||||
// Get the new order of video IDs with their global positions
|
||||
const items = videoList.querySelectorAll('.playlist-video-item');
|
||||
|
||||
// Collect video IDs in new order
|
||||
const videoIds = Array.from(items).map(item => parseInt(item.dataset.videoId));
|
||||
|
||||
// Get global starting position from the first item
|
||||
const firstItem = items[0];
|
||||
const startPosition = firstItem ? parseInt(firstItem.dataset.globalPosition) : 0;
|
||||
|
||||
// Update position numbers visually (relative to current page)
|
||||
items.forEach((item, index) => {
|
||||
const positionEl = item.querySelector('.video-position');
|
||||
if (positionEl) {
|
||||
positionEl.textContent = index + 1;
|
||||
}
|
||||
// Update global position data attribute for future reorders
|
||||
item.dataset.globalPosition = startPosition + index;
|
||||
});
|
||||
|
||||
// Send AJAX request to save the new order
|
||||
fetch('{{ route("playlists.reorder", $playlist->id) }}', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ video_ids: videoIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Playlist reordered successfully');
|
||||
} else {
|
||||
console.error('Failed to reorder playlist:', data.message);
|
||||
// Optionally reload to restore original order on error
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error reordering playlist:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function deletePlaylist() {
|
||||
if (confirm('Are you sure you want to delete this playlist?')) {
|
||||
fetch('{{ route("playlists.destroy", $playlist->id) }}', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = '{{ route("playlists.index") }}';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('editPlaylistModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeEditPlaylistModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('editPlaylistModal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeEditPlaylistModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Toast animations */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes fadeOutDown {
|
||||
from { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
to { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
}
|
||||
|
||||
/* Modal animations */
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.edit-playlist-modal-overlay {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Header button hover */
|
||||
.edit-playlist-modal-header button:hover {
|
||||
background: #3f3f3f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Input focus states */
|
||||
.edit-playlist-modal-content input:focus,
|
||||
.edit-playlist-modal-content textarea:focus {
|
||||
border-color: #e61e1e !important;
|
||||
box-shadow: 0 0 0 2px rgba(230, 30, 30, 0.2);
|
||||
}
|
||||
|
||||
/* Thumbnail container hover */
|
||||
#playlistThumbnailContainer:hover {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
.edit-playlist-modal-footer button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Delete button hover */
|
||||
button[onclick="deletePlaylist()"]:hover {
|
||||
background: #cc1a1a !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Save button hover */
|
||||
button[type="submit"][form="editPlaylistForm"]:hover {
|
||||
background: #cc1a1a !important;
|
||||
}
|
||||
</style>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
@ -18,44 +18,44 @@
|
||||
object-fit: cover;
|
||||
border: 3px solid var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
|
||||
|
||||
.channel-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stat-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
/* Video Grid */
|
||||
.yt-video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
@ -63,7 +63,7 @@
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@ -72,7 +72,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@ -84,11 +84,11 @@
|
||||
transition: opacity 0.3s ease;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb video.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
@ -100,13 +100,13 @@
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-info {
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.yt-channel-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@ -114,12 +114,12 @@
|
||||
background: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
@ -131,12 +131,12 @@
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
@ -144,7 +144,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
/* More button */
|
||||
.yt-more-btn {
|
||||
background: transparent;
|
||||
@ -160,15 +160,15 @@
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.yt-more-btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.yt-more-btn:active {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.yt-more-dropdown {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
@ -176,7 +176,7 @@
|
||||
padding: 8px 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
|
||||
.yt-more-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -191,30 +191,30 @@
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.yt-more-dropdown-item:hover { background: var(--border-color); }
|
||||
|
||||
|
||||
/* Empty State */
|
||||
.yt-empty {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
|
||||
.yt-empty-icon {
|
||||
font-size: 80px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.yt-empty-title {
|
||||
font-size: 24px;
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-empty-text {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.yt-upload-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
@ -229,15 +229,15 @@
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.yt-upload-btn:hover {
|
||||
background: #cc1a1a;
|
||||
}
|
||||
|
||||
|
||||
.yt-upload-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
|
||||
/* Channel action buttons container */
|
||||
.channel-actions {
|
||||
display: flex;
|
||||
@ -245,250 +245,250 @@
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
MOBILE RESPONSIVE STYLES
|
||||
============================================ */
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.channel-header {
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 20px;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
|
||||
.channel-meta {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.yt-channel-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.yt-more-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
|
||||
.channel-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.channel-actions .yt-upload-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.channel-header {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
.channel-header .d-flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 16px !important;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 18px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
|
||||
.channel-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stat-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-grid {
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-info {
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-channel-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-details {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 13px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
|
||||
.yt-empty {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
|
||||
.yt-empty-icon {
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
|
||||
.yt-empty-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Very small screens */
|
||||
@media (max-width: 360px) {
|
||||
.channel-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-info {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-details {
|
||||
max-width: calc(100% - 36px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Landscape mobile */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.channel-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 16px;
|
||||
margin: 8px 0 2px;
|
||||
}
|
||||
|
||||
|
||||
.channel-meta {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Pagination improvements */
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-link {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background: var(--border-color);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background: var(--brand-red);
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-item.disabled .page-link {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-link {
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-item:not(.active):not(.disabled) .page-link {
|
||||
min-width: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Smooth scroll behavior */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
|
||||
/* Video card skeleton loading */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border-color) 50%, var(--bg-secondary) 75%);
|
||||
@ -496,18 +496,18 @@
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
|
||||
/* Mobile touch optimizations */
|
||||
@media (hover: none) {
|
||||
.yt-video-card:hover .yt-video-thumb video {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-card.playing .yt-video-thumb video {
|
||||
opacity: 1;
|
||||
}
|
||||
@ -523,11 +523,11 @@
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="channel-avatar">
|
||||
@endif
|
||||
|
||||
|
||||
<div class="channel-info">
|
||||
<h1 class="channel-name">{{ $user->name }}</h1>
|
||||
<p class="channel-meta">Joined {{ $user->created_at->format('F d, Y') }}</p>
|
||||
|
||||
|
||||
<div class="channel-stats">
|
||||
<div>
|
||||
<span class="channel-stat-value">{{ $videos->total() }}</span>
|
||||
@ -538,7 +538,7 @@
|
||||
<span class="channel-meta"> views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@auth
|
||||
@if(Auth::user()->id === $user->id)
|
||||
<div class="channel-actions">
|
||||
@ -554,7 +554,42 @@
|
||||
</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())
|
||||
<div class="yt-empty">
|
||||
<i class="bi bi-camera-video yt-empty-icon"></i>
|
||||
@ -574,10 +609,10 @@
|
||||
<x-video-card :video="$video" size="small" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4">{{ $videos->links() }}</div>
|
||||
@endif
|
||||
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@endsection
|
||||
|
||||
@ -588,7 +623,7 @@ let currentPlayingVideo = null;
|
||||
function playVideo(card) {
|
||||
// Skip on touch devices - handled by touch events
|
||||
if ('ontouchstart' in window) return;
|
||||
|
||||
|
||||
const video = card.querySelector('video');
|
||||
if (video) {
|
||||
video.currentTime = 0;
|
||||
@ -628,7 +663,7 @@ document.addEventListener('touchstart', function(e) {
|
||||
if (currentPlayingVideo && currentPlayingVideo !== card) {
|
||||
stopVideo(currentPlayingVideo);
|
||||
}
|
||||
|
||||
|
||||
const video = card.querySelector('video');
|
||||
if (video) {
|
||||
if (card.classList.contains('playing')) {
|
||||
|
||||
@ -4,193 +4,187 @@
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.profile-header {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-email {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.profile-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
background: #121212;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #cc1a1a;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border: 1px solid #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
.profile-header { background: var(--bg-secondary); border-radius: 12px; padding: 32px; margin-bottom: 24px; position: relative; overflow: hidden; }
|
||||
.profile-banner { position: absolute; top: 0; left: 0; right: 0; height: 120px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); z-index: 0; }
|
||||
.profile-content { position: relative; z-index: 1; margin-top: 60px; }
|
||||
.profile-avatar { width: 120px; height: 120px; border-radius: 50%; object-fit: cover; border: 4px solid var(--bg-secondary); margin-bottom: 16px; }
|
||||
.profile-name { font-size: 28px; font-weight: 600; margin-bottom: 4px; }
|
||||
.profile-username { color: var(--text-secondary); margin-bottom: 12px; font-size: 14px; }
|
||||
.profile-bio { color: var(--text-primary); margin-bottom: 16px; max-width: 600px; line-height: 1.6; }
|
||||
.profile-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; color: var(--text-secondary); font-size: 14px; }
|
||||
.profile-meta-item { display: flex; align-items: center; gap: 6px; }
|
||||
.profile-stats { display: flex; gap: 32px; margin-top: 16px; padding-top: 20px; border-top: 1px solid var(--border-color); }
|
||||
.profile-stat { text-align: center; }
|
||||
.profile-stat-value { font-size: 22px; font-weight: 700; }
|
||||
.profile-stat-label { font-size: 13px; color: var(--text-secondary); }
|
||||
.social-links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.social-link { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: var(--bg-primary); color: var(--text-primary); text-decoration: none; transition: all 0.2s; font-size: 18px; }
|
||||
.social-link:hover { transform: translateY(-2px); background: var(--brand-red); color: white; }
|
||||
.social-link.twitter:hover { background: #1da1f2; }
|
||||
.social-link.instagram:hover { background: #e4405f; }
|
||||
.social-link.facebook:hover { background: #1877f2; }
|
||||
.social-link.youtube:hover { background: #ff0000; }
|
||||
.social-link.linkedin:hover { background: #0077b5; }
|
||||
.social-link.tiktok:hover { background: #000000; }
|
||||
.form-card { background: var(--bg-secondary); border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
||||
.form-title { font-size: 18px; font-weight: 600; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; gap: 8px; }
|
||||
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-label { display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; }
|
||||
.form-input, .form-textarea { width: 100%; background: #121212; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px 16px; color: var(--text-primary); font-size: 14px; transition: border-color 0.2s; }
|
||||
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--brand-red); }
|
||||
.form-textarea { min-height: 100px; resize: vertical; }
|
||||
.form-hint { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
||||
.btn-primary { background: var(--brand-red); color: white; border: none; padding: 12px 32px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.btn-primary:hover { background: #cc1a1a; }
|
||||
.btn-secondary { background: transparent; color: var(--text-primary); border: 1px solid var(--border-color); padding: 12px 24px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; }
|
||||
.btn-secondary:hover { background: var(--bg-secondary); border-color: var(--text-secondary); }
|
||||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.alert-success { background: rgba(34, 197, 94, 0.15); border: 1px solid #22c55e; color: #22c55e; }
|
||||
.section-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 8px; }
|
||||
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
|
||||
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-secondary); }
|
||||
.empty-state i { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
|
||||
.w-100 { width: 100%; }
|
||||
.mt-4 { margin-top: 24px; }
|
||||
.text-center { text-align: center; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.d-flex { display: flex; }
|
||||
.flex-column { flex-direction: column; }
|
||||
.video-card { background: var(--bg-primary); border-radius: 8px; overflow: hidden; transition: transform 0.2s; }
|
||||
.video-card:hover { transform: translateY(-4px); }
|
||||
.video-thumbnail { position: relative; aspect-ratio: 16/9; background: #000; }
|
||||
.video-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.video-duration { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||
.video-info { padding: 12px; }
|
||||
.video-title { font-size: 14px; font-weight: 500; margin-bottom: 6px; line-height: 1.4; }
|
||||
.video-title a { color: var(--text-primary); text-decoration: none; }
|
||||
.video-title a:hover { color: var(--brand-red); }
|
||||
.video-meta { font-size: 12px; color: var(--text-secondary); }
|
||||
@media (max-width: 768px) { .profile-stats { justify-content: center; } .profile-meta { justify-content: center; } .social-links { justify-content: center; } }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="profile-header text-center">
|
||||
@if($user->avatar)
|
||||
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar">
|
||||
@endif
|
||||
|
||||
<h1 class="profile-name">{{ $user->name }}</h1>
|
||||
<p class="profile-email">{{ $user->email }}</p>
|
||||
|
||||
<div class="profile-stats justify-content-center">
|
||||
<div class="profile-stat">
|
||||
<div class="profile-stat-value">{{ $user->videos->count() }}</div>
|
||||
<div class="profile-stat-label">Videos</div>
|
||||
<div class="profile-header">
|
||||
<div class="profile-banner"></div>
|
||||
<div class="profile-content text-center">
|
||||
@if($user->avatar)
|
||||
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar">
|
||||
@endif
|
||||
<h1 class="profile-name">{{ $user->name }}</h1>
|
||||
@if($user->username)
|
||||
<p class="profile-username">@ {{ $user->username }}</p>
|
||||
@endif
|
||||
@if($user->bio)
|
||||
<p class="profile-bio">{{ $user->bio }}</p>
|
||||
@endif
|
||||
<div class="profile-meta justify-content-center">
|
||||
@if($user->location)
|
||||
<div class="profile-meta-item"><i class="bi bi-geo-alt"></i><span>{{ $user->location }}</span></div>
|
||||
@endif
|
||||
@if($user->website)
|
||||
<div class="profile-meta-item"><i class="bi bi-link-45deg"></i><a href="{{ $user->website_url }}" target="_blank" style="color: inherit;">{{ Str::limit($user->website, 30) }}</a></div>
|
||||
@endif
|
||||
<div class="profile-meta-item"><i class="bi bi-calendar3"></i><span>Joined {{ $user->created_at->format('M Y') }}</span></div>
|
||||
</div>
|
||||
<div class="profile-stat">
|
||||
<div class="profile-stat-value">{{ $user->likes->count() }}</div>
|
||||
<div class="profile-stat-label">Likes</div>
|
||||
</div>
|
||||
<div class="profile-stat">
|
||||
<div class="profile-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</div>
|
||||
<div class="profile-stat-label">Total Views</div>
|
||||
@if($user->hasSocialLinks())
|
||||
<div class="social-links justify-content-center">
|
||||
@if($user->twitter)<a href="https://twitter.com/{{ $user->twitter }}" target="_blank" class="social-link twitter" title="Twitter"><i class="bi bi-twitter-x"></i></a>@endif
|
||||
@if($user->instagram)<a href="https://instagram.com/{{ $user->instagram }}" target="_blank" class="social-link instagram" title="Instagram"><i class="bi bi-instagram"></i></a>@endif
|
||||
@if($user->facebook)<a href="https://facebook.com/{{ $user->facebook }}" target="_blank" class="social-link facebook" title="Facebook"><i class="bi bi-facebook"></i></a>@endif
|
||||
@if($user->youtube)<a href="https://youtube.com/@{{ $user->youtube }}" target="_blank" class="social-link youtube" title="YouTube"><i class="bi bi-youtube"></i></a>@endif
|
||||
@if($user->linkedin)<a href="https://linkedin.com/in/{{ $user->linkedin }}" target="_blank" class="social-link linkedin" title="LinkedIn"><i class="bi bi-linkedin"></i></a>@endif
|
||||
@if($user->tiktok)<a href="https://tiktok.com/@{{ $user->tiktok }}" target="_blank" class="social-link tiktok" title="TikTok"><i class="bi bi-tiktok"></i></a>@endif
|
||||
@if($user->website)<a href="{{ $user->website_url }}" target="_blank" class="social-link" title="Website"><i class="bi bi-globe"></i></a>@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="profile-stats justify-content-center">
|
||||
<a href="{{ route('channel', $user->id) }}" class="profile-stat"><div class="profile-stat-value">{{ $user->videos->count() }}</div><div class="profile-stat-label">Videos</div></a>
|
||||
<div class="profile-stat"><div class="profile-stat-value">{{ number_format($user->subscriber_count) }}</div><div class="profile-stat-label">Subscribers</div></div>
|
||||
<div class="profile-stat"><div class="profile-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</div><div class="profile-stat-label">Views</div></div>
|
||||
<div class="profile-stat"><div class="profile-stat-value">{{ $user->likes->count() }}</div><div class="profile-stat-label">Likes</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="col-lg-8">
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">Edit Profile</h2>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success">
|
||||
{{ session('success') }}
|
||||
<h2 class="section-title"><i class="bi bi-play-circle"></i> Recent Videos</h2>
|
||||
@if($user->videos->count() > 0)
|
||||
<div class="video-grid">
|
||||
@foreach($user->videos->take(4) as $video)
|
||||
<div class="video-card">
|
||||
<a href="{{ route('videos.show', $video) }}">
|
||||
<div class="video-thumbnail">
|
||||
<img src="{{ $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : 'https://i.ytimg.com/vi/default.jpg' }}" alt="{{ $video->title }}">
|
||||
<span class="video-duration">{{ $video->duration ?? '0:00' }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="video-info">
|
||||
<h3 class="video-title"><a href="{{ route('videos.show', $video) }}">{{ Str::limit($video->title, 50) }}</a></h3>
|
||||
<div class="video-meta"><span>{{ number_format($video->view_count ?? 0) }} views</span><span> • </span><span>{{ $video->created_at->diffForHumans() }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@if($user->videos->count() > 4)
|
||||
<div class="text-center mt-4"><a href="{{ route('channel', $user->id) }}" class="btn-secondary">View All Videos <i class="bi bi-arrow-right"></i></a></div>
|
||||
@endif
|
||||
@else
|
||||
<div class="empty-state"><i class="bi bi-camera-video"></i><p>No videos yet</p></div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Avatar</label>
|
||||
<input type="file" name="avatar" class="form-input" accept="image/*">
|
||||
<small class="text-muted">Max size: 5MB. Supported: JPG, PNG, WebP</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="col-lg-4">
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">Quick Links</h2>
|
||||
|
||||
<a href="{{ route('channel', $user->id) }}" class="btn-primary d-inline-block text-decoration-none">
|
||||
<i class="bi bi-play-btn"></i> View My Channel
|
||||
</a>
|
||||
|
||||
<a href="{{ route('settings') }}" class="btn-primary d-inline-block text-decoration-none ms-2">
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</a>
|
||||
<h2 class="form-title"><i class="bi bi-pencil-square"></i> Edit Profile</h2>
|
||||
@if(session('success'))<div class="alert alert-success">{{ session('success') }}</div>@endif
|
||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||
@csrf @method('PUT')
|
||||
<div class="form-group"><label class="form-label">Name</label><input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required></div>
|
||||
<div class="form-group"><label class="form-label">Avatar</label><input type="file" name="avatar" class="form-input" accept="image/*"><small class="form-hint">Max: 5MB</small></div>
|
||||
<div class="form-group"><label class="form-label">Bio</label><textarea name="bio" class="form-textarea" placeholder="Tell us about yourself...">{{ old('bio', $user->bio) }}</textarea></div>
|
||||
<div class="form-group"><label class="form-label">Location</label><input type="text" name="location" class="form-input" value="{{ old('location', $user->location) }}" placeholder="City, Country"></div>
|
||||
<div class="form-group"><label class="form-label">Birthday</label><input type="date" name="birthday" class="form-input" value="{{ old('birthday', $user->birthday) }}"></div>
|
||||
<button type="submit" class="btn-primary w-100"><i class="bi bi-check-lg"></i> Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<h2 class="form-title"><i class="bi bi-share"></i> Social Links</h2>
|
||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||
@csrf @method('PUT')
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-twitter-x"></i> Twitter</label><input type="text" name="twitter" class="form-input" value="{{ old('twitter', $user->twitter) }}" placeholder="username"></div>
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-instagram"></i> Instagram</label><input type="text" name="instagram" class="form-input" value="{{ old('instagram', $user->instagram) }}" placeholder="username"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-facebook"></i> Facebook</label><input type="text" name="facebook" class="form-input" value="{{ old('facebook', $user->facebook) }}" placeholder="username"></div>
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-youtube"></i> YouTube</label><input type="text" name="youtube" class="form-input" value="{{ old('youtube', $user->youtube) }}" placeholder="channel"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-linkedin"></i> LinkedIn</label><input type="text" name="linkedin" class="form-input" value="{{ old('linkedin', $user->linkedin) }}" placeholder="username"></div>
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-tiktok"></i> TikTok</label><input type="text" name="tiktok" class="form-input" value="{{ old('tiktok', $user->tiktok) }}" placeholder="username"></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-globe"></i> Website</label><input type="url" name="website" class="form-input" value="{{ old('website', $user->website) }}" placeholder="https://example.com"></div>
|
||||
<button type="submit" class="btn-primary w-100"><i class="bi bi-check-lg"></i> Save Social Links</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<h2 class="form-title"><i class="bi bi-link-45deg"></i> Quick Links</h2>
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<a href="{{ route('channel', $user->id) }}" class="btn-secondary text-center"><i class="bi bi-play-btn"></i> View My Channel</a>
|
||||
<a href="{{ route('settings') }}" class="btn-secondary text-center"><i class="bi bi-gear"></i> Account Settings</a>
|
||||
<a href="{{ route('history') }}" class="btn-secondary text-center"><i class="bi bi-clock-history"></i> Watch History</a>
|
||||
<a href="{{ route('liked') }}" class="btn-secondary text-center"><i class="bi bi-heart"></i> Liked Videos</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
353
resources/views/videos/shorts.blade.php
Normal file
353
resources/views/videos/shorts.blade.php
Normal file
@ -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">
|
||||
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
||||
<div class="trending-filters">
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ == 24 ? 'active' : '' }}">Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ == 48 ? 'active' : '' }}">This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ == 168 ? 'active' : '' }}">This Month</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(())
|
||||
@if($videos->isEmpty())
|
||||
<div class="empty-trending">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
<h3>No trending videos yet</h3>
|
||||
@ -191,25 +191,8 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="yt-video-grid">
|
||||
@foreach( as )
|
||||
<a href="{{ route('videos.show', ) }}" class="yt-video-card">
|
||||
<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>
|
||||
@foreach($videos as $video)
|
||||
<x-video-card :video="$video" />
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,7 @@
|
||||
<style>
|
||||
/* Video Section */
|
||||
.yt-video-section { flex: 1; min-width: 0; }
|
||||
|
||||
|
||||
/* Video Player */
|
||||
.video-container {
|
||||
position: relative;
|
||||
@ -20,20 +20,122 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-container.portrait,
|
||||
.video-container.square,
|
||||
|
||||
.video-container.portrait,
|
||||
.video-container.square,
|
||||
.video-container.ultrawide {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
||||
.video-container.portrait { aspect-ratio: 9/16; max-width: 50vh; }
|
||||
.video-container.square { aspect-ratio: 1/1; max-width: 70vh; }
|
||||
.video-container.ultrawide { aspect-ratio: 21/9; max-width: 100%; }
|
||||
|
||||
|
||||
.video-container 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-title {
|
||||
font-size: 20px;
|
||||
@ -41,7 +143,7 @@
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.video-stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -51,15 +153,15 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.video-stats-left { display: flex; align-items: center; gap: 16px; color: var(--text-secondary); }
|
||||
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -73,11 +175,11 @@
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.yt-action-btn:hover { background: var(--border-color); }
|
||||
|
||||
|
||||
.yt-action-btn.liked { color: var(--brand-red); }
|
||||
|
||||
|
||||
/* Channel Row */
|
||||
.channel-row {
|
||||
display: flex;
|
||||
@ -85,27 +187,27 @@
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
|
||||
.channel-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%; background: #555;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.channel-subs {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.subscribe-btn {
|
||||
background: white;
|
||||
color: black;
|
||||
@ -117,7 +219,7 @@
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* Description */
|
||||
.video-description {
|
||||
background: var(--bg-secondary);
|
||||
@ -125,26 +227,26 @@
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.description-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* Sidebar */
|
||||
.yt-sidebar-container {
|
||||
width: 400px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-video-card {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-thumb {
|
||||
width: 168px;
|
||||
aspect-ratio: 16/9;
|
||||
@ -153,11 +255,11 @@
|
||||
background: #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
|
||||
.sidebar-info { flex: 1; min-width: 0; }
|
||||
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@ -167,21 +269,20 @@
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-meta { font-size: 12px; color: var(--text-secondary); }
|
||||
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1300px) {
|
||||
.yt-sidebar-container { width: 300px; }
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.yt-main { margin-left: 0; flex-direction: column; }
|
||||
.yt-sidebar-container { width: 100%; }
|
||||
.yt-header-center { display: none; }
|
||||
.sidebar-video-card { flex-direction: column; }
|
||||
.sidebar-thumb { width: 100%; }
|
||||
|
||||
|
||||
.video-layout-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
@ -194,12 +295,12 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.video-stats-row { flex-direction: column; align-items: flex-start; }
|
||||
.video-actions { width: 100%; overflow-x: auto; justify-content: flex-start; }
|
||||
.yt-main { padding: 12px !important; }
|
||||
|
||||
|
||||
.video-container {
|
||||
max-height: 50vh !important;
|
||||
border-radius: 0 !important;
|
||||
@ -241,14 +342,55 @@
|
||||
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
|
||||
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Playlist Navigation Controls (only show when in playlist) -->
|
||||
@if($nextVideo || $previousVideo)
|
||||
<div class="playlist-controls" id="playlistControls">
|
||||
<!-- Previous Video -->
|
||||
@if($previousVideo)
|
||||
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
|
||||
<i class="bi bi-skip-backward-fill"></i>
|
||||
</button>
|
||||
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
|
||||
@else
|
||||
<button class="playlist-nav-btn" disabled title="No previous video">
|
||||
<i class="bi bi-skip-backward-fill"></i>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<!-- Autoplay Toggle -->
|
||||
<div class="autoplay-toggle">
|
||||
<label for="autoplayToggle">Autoplay</label>
|
||||
<label class="autoplay-switch">
|
||||
<input type="checkbox" id="autoplayToggle" checked>
|
||||
<span class="autoplay-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Next Video -->
|
||||
@if($nextVideo)
|
||||
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
|
||||
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
|
||||
<i class="bi bi-skip-forward-fill"></i>
|
||||
</button>
|
||||
@else
|
||||
<button class="playlist-nav-btn" disabled title="No next video">
|
||||
<i class="bi bi-skip-forward-fill"></i>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="keyboard-hint">
|
||||
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Video Title with Trophy Icon (Match Type) -->
|
||||
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
|
||||
<i class="bi bi-trophy" style="color: #ef4444;"></i>
|
||||
<span>{{ $video->title }}</span>
|
||||
</h1>
|
||||
|
||||
|
||||
<!-- Stats Row - Hidden, shown in description box -->
|
||||
<div class="video-stats-row" style="display: none;">
|
||||
<div class="video-stats-left">
|
||||
@ -257,7 +399,7 @@
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Channel Row - All in one line -->
|
||||
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; flex-wrap: wrap; gap: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
@ -273,7 +415,7 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
@auth
|
||||
@if(Auth::id() !== $video->user_id)
|
||||
@ -286,7 +428,7 @@
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="subscribe-btn">Subscribe</a>
|
||||
@endauth
|
||||
|
||||
|
||||
@auth
|
||||
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
|
||||
@csrf
|
||||
@ -300,7 +442,7 @@
|
||||
<i class="bi bi-hand-thumbs-up"></i> Like
|
||||
</a>
|
||||
@endauth
|
||||
|
||||
|
||||
@if($video->isShareable())
|
||||
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
@ -308,7 +450,7 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Description Box -->
|
||||
@if($video->description)
|
||||
@php
|
||||
@ -316,7 +458,7 @@
|
||||
$shortDescription = Str::limit($fullDescription, 200);
|
||||
$needsExpand = strlen($fullDescription) > 200;
|
||||
@endphp
|
||||
|
||||
|
||||
<div class="video-description-box" style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
|
||||
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
@ -341,13 +483,13 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function toggleDescription() {
|
||||
const descShort = document.getElementById('descShort');
|
||||
const descFull = document.getElementById('descFull');
|
||||
const toggleBtn = document.getElementById('descToggleBtn');
|
||||
|
||||
|
||||
if (descShort.style.display !== 'none') {
|
||||
descShort.style.display = 'none';
|
||||
descFull.style.display = 'block';
|
||||
@ -359,27 +501,27 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.video-description-box .description-text { font-size: 14px; line-height: 1.5; color: var(--text-primary); }
|
||||
.video-description-box .description-text p { margin-bottom: 8px; }
|
||||
.video-description-box .description-text a { color: #3ea6ff; }
|
||||
</style>
|
||||
@endif
|
||||
|
||||
|
||||
<!-- Comment Section -->
|
||||
<div class="comments-section" style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;">
|
||||
Comments <span style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span>
|
||||
</h3>
|
||||
|
||||
|
||||
@auth
|
||||
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;" alt="{{ Auth::user()->name }}">
|
||||
<div style="flex: 1;">
|
||||
<textarea
|
||||
id="commentBody"
|
||||
class="form-control"
|
||||
<textarea
|
||||
id="commentBody"
|
||||
class="form-control"
|
||||
placeholder="Add a comment... Use @ to mention someone"
|
||||
rows="3"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
|
||||
@ -395,7 +537,7 @@
|
||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
|
||||
<div id="commentsList">
|
||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
||||
@include('videos.partials.comment', ['comment' => $comment])
|
||||
@ -404,12 +546,12 @@
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function submitComment(videoId) {
|
||||
const body = document.getElementById('commentBody').value.trim();
|
||||
if (!body) return;
|
||||
|
||||
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -426,10 +568,10 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||
|
||||
|
||||
fetch(`/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@ -443,7 +585,7 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const commentTexts = document.querySelectorAll('.comment-body');
|
||||
commentTexts.forEach(text => {
|
||||
@ -451,12 +593,12 @@
|
||||
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: {
|
||||
@ -474,18 +616,99 @@
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
|
||||
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
|
||||
{{ $playlist->name }}
|
||||
<span style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }} videos)</span>
|
||||
</h3>
|
||||
<div class="recommended-videos-list">
|
||||
@foreach($playlistVideos as $index => $playlistVideo)
|
||||
@if($playlistVideo->id !== $video->id)
|
||||
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
|
||||
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($playlistVideo->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}" alt="{{ $playlistVideo->title }}">
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180" alt="{{ $playlistVideo->title }}">
|
||||
@endif
|
||||
@if ($playlistVideo->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
|
||||
@endif
|
||||
@if ($playlistVideo->is_shorts)
|
||||
<span class="yt-shorts-badge" style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
<!-- Playlist position indicator -->
|
||||
<span style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
|
||||
{{ $index + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
{{ Str::limit($playlistVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
|
||||
<div>{{ number_format($playlistVideo->view_count) }} views • {{ $playlistVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn" style="margin-top: 12px; display: inline-block;">
|
||||
<i class="bi bi-pencil"></i> Edit Playlist
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
@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>
|
||||
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
|
||||
|
||||
@if(Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@auth
|
||||
<script>
|
||||
@ -495,17 +718,82 @@
|
||||
</script>
|
||||
@endauth
|
||||
@endif
|
||||
|
||||
|
||||
<script>
|
||||
// Playlist navigation URLs
|
||||
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
|
||||
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
|
||||
|
||||
// Autoplay management
|
||||
let autoplayEnabled = true;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var videoPlayer = document.getElementById('videoPlayer');
|
||||
var playlistControls = document.getElementById('playlistControls');
|
||||
var autoplayToggle = document.getElementById('autoplayToggle');
|
||||
|
||||
if (videoPlayer) {
|
||||
videoPlayer.volume = 0.5;
|
||||
var playPromise = videoPlayer.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); });
|
||||
}
|
||||
|
||||
// Handle video ended - autoplay next
|
||||
if (nextVideoUrl) {
|
||||
videoPlayer.addEventListener('ended', function() {
|
||||
if (autoplayEnabled) {
|
||||
console.log('Video ended, going to next...');
|
||||
window.location.href = nextVideoUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show playlist controls on hover
|
||||
if (playlistControls) {
|
||||
const videoContainer = document.getElementById('videoContainer');
|
||||
videoContainer.addEventListener('mouseenter', function() {
|
||||
playlistControls.classList.add('visible');
|
||||
});
|
||||
videoContainer.addEventListener('mouseleave', function() {
|
||||
playlistControls.classList.remove('visible');
|
||||
});
|
||||
|
||||
// Initially show for a few seconds
|
||||
setTimeout(function() { playlistControls.classList.add('visible'); }, 2000);
|
||||
setTimeout(function() { playlistControls.classList.remove('visible'); }, 6000);
|
||||
}
|
||||
|
||||
// Autoplay toggle handler
|
||||
if (autoplayToggle) {
|
||||
autoplayToggle.addEventListener('change', function() {
|
||||
autoplayEnabled = this.checked;
|
||||
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
|
||||
});
|
||||
|
||||
// Load saved preference
|
||||
const savedAutoplay = localStorage.getItem('playlistAutoplay');
|
||||
if (savedAutoplay !== null) {
|
||||
autoplayEnabled = savedAutoplay === '1';
|
||||
autoplayToggle.checked = autoplayEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); goToPreviousVideo(); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); goToNextVideo(); }
|
||||
});
|
||||
});
|
||||
|
||||
function goToPreviousVideo() {
|
||||
if (previousVideoUrl) { window.location.href = previousVideoUrl; }
|
||||
}
|
||||
|
||||
function goToNextVideo() {
|
||||
if (nextVideoUrl) { window.location.href = nextVideoUrl; }
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
<style>
|
||||
/* Video Section */
|
||||
.yt-video-section { flex: 1; min-width: 0; }
|
||||
|
||||
|
||||
/* Video Player */
|
||||
.video-container {
|
||||
position: relative;
|
||||
@ -20,20 +20,122 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-container.portrait,
|
||||
.video-container.square,
|
||||
|
||||
.video-container.portrait,
|
||||
.video-container.square,
|
||||
.video-container.ultrawide {
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
||||
.video-container.portrait { aspect-ratio: 9/16; max-width: 50vh; }
|
||||
.video-container.square { aspect-ratio: 1/1; max-width: 70vh; }
|
||||
.video-container.ultrawide { aspect-ratio: 21/9; max-width: 100%; }
|
||||
|
||||
|
||||
.video-container 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-title {
|
||||
font-size: 20px;
|
||||
@ -41,7 +143,7 @@
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.video-stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -51,15 +153,15 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.video-stats-left { display: flex; align-items: center; gap: 16px; color: var(--text-secondary); }
|
||||
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -73,11 +175,11 @@
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.yt-action-btn:hover { background: var(--border-color); }
|
||||
|
||||
|
||||
.yt-action-btn.liked { color: var(--brand-red); }
|
||||
|
||||
|
||||
/* Channel Row */
|
||||
.channel-row {
|
||||
display: flex;
|
||||
@ -85,27 +187,27 @@
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
|
||||
.channel-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%; background: #555;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.channel-subs {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.subscribe-btn {
|
||||
background: white;
|
||||
color: black;
|
||||
@ -117,7 +219,7 @@
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* Description */
|
||||
.video-description {
|
||||
background: var(--bg-secondary);
|
||||
@ -125,26 +227,26 @@
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.description-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* Sidebar */
|
||||
.yt-sidebar-container {
|
||||
width: 400px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-video-card {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-thumb {
|
||||
width: 168px;
|
||||
aspect-ratio: 16/9;
|
||||
@ -153,11 +255,11 @@
|
||||
background: #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
|
||||
.sidebar-info { flex: 1; min-width: 0; }
|
||||
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@ -167,21 +269,20 @@
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-meta { font-size: 12px; color: var(--text-secondary); }
|
||||
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1300px) {
|
||||
.yt-sidebar-container { width: 300px; }
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.yt-main { margin-left: 0; flex-direction: column; }
|
||||
.yt-sidebar-container { width: 100%; }
|
||||
.yt-header-center { display: none; }
|
||||
.sidebar-video-card { flex-direction: column; }
|
||||
.sidebar-thumb { width: 100%; }
|
||||
|
||||
|
||||
.video-layout-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
@ -194,12 +295,12 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.video-stats-row { flex-direction: column; align-items: flex-start; }
|
||||
.video-actions { width: 100%; overflow-x: auto; justify-content: flex-start; }
|
||||
.yt-main { padding: 12px !important; }
|
||||
|
||||
|
||||
.video-container {
|
||||
max-height: 50vh !important;
|
||||
border-radius: 0 !important;
|
||||
@ -241,14 +342,55 @@
|
||||
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
|
||||
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Playlist Navigation Controls (only show when in playlist) -->
|
||||
@if($nextVideo || $previousVideo)
|
||||
<div class="playlist-controls" id="playlistControls">
|
||||
<!-- Previous Video -->
|
||||
@if($previousVideo)
|
||||
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
|
||||
<i class="bi bi-skip-backward-fill"></i>
|
||||
</button>
|
||||
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
|
||||
@else
|
||||
<button class="playlist-nav-btn" disabled title="No previous video">
|
||||
<i class="bi bi-skip-backward-fill"></i>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<!-- Autoplay Toggle -->
|
||||
<div class="autoplay-toggle">
|
||||
<label for="autoplayToggle">Autoplay</label>
|
||||
<label class="autoplay-switch">
|
||||
<input type="checkbox" id="autoplayToggle" checked>
|
||||
<span class="autoplay-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Next Video -->
|
||||
@if($nextVideo)
|
||||
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
|
||||
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
|
||||
<i class="bi bi-skip-forward-fill"></i>
|
||||
</button>
|
||||
@else
|
||||
<button class="playlist-nav-btn" disabled title="No next video">
|
||||
<i class="bi bi-skip-forward-fill"></i>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="keyboard-hint">
|
||||
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Video Title with Music Note Icon (Music Type) -->
|
||||
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
|
||||
<i class="bi bi-music-note" style="color: #ef4444;"></i>
|
||||
<span>{{ $video->title }}</span>
|
||||
</h1>
|
||||
|
||||
|
||||
<!-- Stats Row - Hidden, shown in description box -->
|
||||
<div class="video-stats-row" style="display: none;">
|
||||
<div class="video-stats-left">
|
||||
@ -257,7 +399,7 @@
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Channel Row - All in one line -->
|
||||
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; flex-wrap: wrap; gap: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
@ -273,7 +415,7 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
@auth
|
||||
@if(Auth::id() !== $video->user_id)
|
||||
@ -286,7 +428,7 @@
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="subscribe-btn">Subscribe</a>
|
||||
@endauth
|
||||
|
||||
|
||||
@auth
|
||||
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
|
||||
@csrf
|
||||
@ -300,7 +442,7 @@
|
||||
<i class="bi bi-hand-thumbs-up"></i> Like
|
||||
</a>
|
||||
@endauth
|
||||
|
||||
|
||||
@if($video->isShareable())
|
||||
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
@ -308,7 +450,7 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Description Box -->
|
||||
@if($video->description)
|
||||
@php
|
||||
@ -316,7 +458,7 @@
|
||||
$shortDescription = Str::limit($fullDescription, 200);
|
||||
$needsExpand = strlen($fullDescription) > 200;
|
||||
@endphp
|
||||
|
||||
|
||||
<div class="video-description-box" style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
|
||||
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
@ -341,13 +483,13 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function toggleDescription() {
|
||||
const descShort = document.getElementById('descShort');
|
||||
const descFull = document.getElementById('descFull');
|
||||
const toggleBtn = document.getElementById('descToggleBtn');
|
||||
|
||||
|
||||
if (descShort.style.display !== 'none') {
|
||||
descShort.style.display = 'none';
|
||||
descFull.style.display = 'block';
|
||||
@ -359,27 +501,27 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.video-description-box .description-text { font-size: 14px; line-height: 1.5; color: var(--text-primary); }
|
||||
.video-description-box .description-text p { margin-bottom: 8px; }
|
||||
.video-description-box .description-text a { color: #3ea6ff; }
|
||||
</style>
|
||||
@endif
|
||||
|
||||
|
||||
<!-- Comment Section -->
|
||||
<div class="comments-section" style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;">
|
||||
Comments <span style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span>
|
||||
</h3>
|
||||
|
||||
|
||||
@auth
|
||||
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;" alt="{{ Auth::user()->name }}">
|
||||
<div style="flex: 1;">
|
||||
<textarea
|
||||
id="commentBody"
|
||||
class="form-control"
|
||||
<textarea
|
||||
id="commentBody"
|
||||
class="form-control"
|
||||
placeholder="Add a comment... Use @ to mention someone"
|
||||
rows="3"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
|
||||
@ -395,7 +537,7 @@
|
||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
|
||||
<div id="commentsList">
|
||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
||||
@include('videos.partials.comment', ['comment' => $comment])
|
||||
@ -404,12 +546,12 @@
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function submitComment(videoId) {
|
||||
const body = document.getElementById('commentBody').value.trim();
|
||||
if (!body) return;
|
||||
|
||||
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -426,10 +568,10 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||
|
||||
|
||||
fetch(`/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@ -443,7 +585,7 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const commentTexts = document.querySelectorAll('.comment-body');
|
||||
commentTexts.forEach(text => {
|
||||
@ -451,12 +593,12 @@
|
||||
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: {
|
||||
@ -474,18 +616,104 @@
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
@if($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
|
||||
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
|
||||
{{ $playlist->name }}
|
||||
<span style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }} videos)</span>
|
||||
</h3>
|
||||
<div class="recommended-videos-list">
|
||||
@foreach($playlistVideos as $index => $playlistVideo)
|
||||
@if($playlistVideo->id !== $video->id)
|
||||
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
|
||||
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($playlistVideo->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}" alt="{{ $playlistVideo->title }}">
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180" alt="{{ $playlistVideo->title }}">
|
||||
@endif
|
||||
@if ($playlistVideo->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
|
||||
@endif
|
||||
@if ($playlistVideo->is_shorts)
|
||||
<span class="yt-shorts-badge" style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
<!-- Playlist position indicator -->
|
||||
<span style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
|
||||
{{ $index + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
{{ Str::limit($playlistVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
|
||||
<div>{{ number_format($playlistVideo->view_count) }} views • {{ $playlistVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn" style="margin-top: 12px; display: inline-block;">
|
||||
<i class="bi bi-pencil"></i> Edit Playlist
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
@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>
|
||||
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
|
||||
|
||||
@if(Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@auth
|
||||
<script>
|
||||
@ -495,17 +723,82 @@
|
||||
</script>
|
||||
@endauth
|
||||
@endif
|
||||
|
||||
|
||||
<script>
|
||||
// Playlist navigation URLs
|
||||
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
|
||||
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
|
||||
|
||||
// Autoplay management
|
||||
let autoplayEnabled = true;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var videoPlayer = document.getElementById('videoPlayer');
|
||||
var playlistControls = document.getElementById('playlistControls');
|
||||
var autoplayToggle = document.getElementById('autoplayToggle');
|
||||
|
||||
if (videoPlayer) {
|
||||
videoPlayer.volume = 0.5;
|
||||
var playPromise = videoPlayer.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); });
|
||||
}
|
||||
|
||||
// Handle video ended - autoplay next
|
||||
if (nextVideoUrl) {
|
||||
videoPlayer.addEventListener('ended', function() {
|
||||
if (autoplayEnabled) {
|
||||
console.log('Video ended, going to next...');
|
||||
window.location.href = nextVideoUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show playlist controls on hover
|
||||
if (playlistControls) {
|
||||
const videoContainer = document.getElementById('videoContainer');
|
||||
videoContainer.addEventListener('mouseenter', function() {
|
||||
playlistControls.classList.add('visible');
|
||||
});
|
||||
videoContainer.addEventListener('mouseleave', function() {
|
||||
playlistControls.classList.remove('visible');
|
||||
});
|
||||
|
||||
// Initially show for a few seconds
|
||||
setTimeout(function() { playlistControls.classList.add('visible'); }, 2000);
|
||||
setTimeout(function() { playlistControls.classList.remove('visible'); }, 6000);
|
||||
}
|
||||
|
||||
// Autoplay toggle handler
|
||||
if (autoplayToggle) {
|
||||
autoplayToggle.addEventListener('change', function() {
|
||||
autoplayEnabled = this.checked;
|
||||
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
|
||||
});
|
||||
|
||||
// Load saved preference
|
||||
const savedAutoplay = localStorage.getItem('playlistAutoplay');
|
||||
if (savedAutoplay !== null) {
|
||||
autoplayEnabled = savedAutoplay === '1';
|
||||
autoplayToggle.checked = autoplayEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; }
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); goToPreviousVideo(); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); goToNextVideo(); }
|
||||
});
|
||||
});
|
||||
|
||||
function goToPreviousVideo() {
|
||||
if (previousVideoUrl) { window.location.href = previousVideoUrl; }
|
||||
}
|
||||
|
||||
function goToNextVideo() {
|
||||
if (nextVideoUrl) { window.location.href = nextVideoUrl; }
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,24 +1,24 @@
|
||||
<?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\SuperAdminController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Http\Controllers\VideoController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Redirect root to videos
|
||||
Route::get('/', function () {
|
||||
return redirect('/videos');
|
||||
});
|
||||
// Root route - show videos
|
||||
Route::get('/', [VideoController::class, 'index'])->name('home');
|
||||
|
||||
// Video routes - public
|
||||
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
|
||||
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
|
||||
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
|
||||
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
|
||||
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
||||
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
|
||||
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
|
||||
Route::get('/videos/{video}/download', [VideoController::class, 'download'])->name('videos.download');
|
||||
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
|
||||
|
||||
// Video routes - auth required
|
||||
Route::middleware('auth')->group(function () {
|
||||
@ -27,7 +27,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
|
||||
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
|
||||
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
|
||||
|
||||
|
||||
// Like/unlike routes
|
||||
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
||||
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
||||
@ -47,11 +47,11 @@ Route::middleware('auth')->group(function () {
|
||||
// Profile
|
||||
Route::get('/profile', [UserController::class, 'profile'])->name('profile');
|
||||
Route::put('/profile', [UserController::class, 'updateProfile'])->name('profile.update');
|
||||
|
||||
|
||||
// Settings
|
||||
Route::get('/settings', [UserController::class, 'settings'])->name('settings');
|
||||
Route::put('/settings', [UserController::class, 'updateSettings'])->name('settings.update');
|
||||
|
||||
|
||||
// History & Liked
|
||||
Route::get('/history', [UserController::class, 'history'])->name('history');
|
||||
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
|
||||
@ -60,6 +60,37 @@ Route::middleware('auth')->group(function () {
|
||||
// Channel - public for viewing, own channel requires auth
|
||||
Route::get('/channel/{userId?}', [UserController::class, 'channel'])->name('channel');
|
||||
|
||||
// Playlist routes
|
||||
use App\Http\Controllers\PlaylistController;
|
||||
|
||||
// Get user playlists (for dropdown) - MUST be before /playlists/{playlist} route
|
||||
Route::get('/user/playlists', [PlaylistController::class, 'userPlaylists'])->name('playlists.userPlaylists');
|
||||
|
||||
// Public playlist routes
|
||||
Route::get('/playlists', [PlaylistController::class, 'index'])->name('playlists.index');
|
||||
Route::get('/playlists/{playlist}', [PlaylistController::class, 'show'])->name('playlists.show');
|
||||
|
||||
// Authenticated playlist routes
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/playlists/create', [PlaylistController::class, 'create'])->name('playlists.create');
|
||||
Route::post('/playlists', [PlaylistController::class, 'store'])->name('playlists.store');
|
||||
Route::get('/playlists/{playlist}/edit', [PlaylistController::class, 'edit'])->name('playlists.edit');
|
||||
Route::put('/playlists/{playlist}', [PlaylistController::class, 'update'])->name('playlists.update');
|
||||
Route::delete('/playlists/{playlist}', [PlaylistController::class, 'destroy'])->name('playlists.destroy');
|
||||
|
||||
// Playlist video management
|
||||
Route::post('/playlists/{playlist}/videos', [PlaylistController::class, 'addVideo'])->name('playlists.addVideo');
|
||||
Route::delete('/playlists/{playlist}/videos/{video}', [PlaylistController::class, 'removeVideo'])->name('playlists.removeVideo');
|
||||
Route::put('/playlists/{playlist}/reorder', [PlaylistController::class, 'reorder'])->name('playlists.reorder');
|
||||
|
||||
// Playlist actions
|
||||
Route::get('/playlists/{playlist}/play', [PlaylistController::class, 'playAll'])->name('playlists.playAll');
|
||||
Route::get('/playlists/{playlist}/shuffle', [PlaylistController::class, 'shuffle'])->name('playlists.shuffle');
|
||||
|
||||
// Watch Later
|
||||
Route::post('/videos/{video}/watch-later', [PlaylistController::class, 'watchLater'])->name('videos.watchLater');
|
||||
});
|
||||
|
||||
// Authentication Routes
|
||||
require __DIR__.'/auth.php';
|
||||
|
||||
@ -67,17 +98,16 @@ require __DIR__.'/auth.php';
|
||||
Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->group(function () {
|
||||
// Dashboard
|
||||
Route::get('/dashboard', [SuperAdminController::class, 'dashboard'])->name('dashboard');
|
||||
|
||||
|
||||
// User Management
|
||||
Route::get('/users', [SuperAdminController::class, 'users'])->name('users');
|
||||
Route::get('/users/{user}/edit', [SuperAdminController::class, 'editUser'])->name('users.edit');
|
||||
Route::put('/users/{user}', [SuperAdminController::class, 'updateUser'])->name('users.update');
|
||||
Route::delete('/users/{user}', [SuperAdminController::class, 'deleteUser'])->name('users.delete');
|
||||
|
||||
|
||||
// Video Management
|
||||
Route::get('/videos', [SuperAdminController::class, 'videos'])->name('videos');
|
||||
Route::get('/videos/{video}/edit', [SuperAdminController::class, 'editVideo'])->name('videos.edit');
|
||||
Route::put('/videos/{video}', [SuperAdminController::class, 'updateVideo'])->name('videos.update');
|
||||
Route::delete('/videos/{video}', [SuperAdminController::class, 'deleteVideo'])->name('videos.delete');
|
||||
});
|
||||
|
||||
|
||||
0
show.blade.php/resources/views/videos
Normal file
0
show.blade.php/resources/views/videos
Normal file
Loading…
x
Reference in New Issue
Block a user