From 062c0e896f1970b62d3e363f0a5fe6c29d3c8aa6 Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 11 Mar 2026 11:21:33 +0300 Subject: [PATCH] latest update --- .../Http/Controllers/VideoController.php\n" | 0 TODO.md | 63 +- TODO_drag_drop_reorder.md | 35 + TODO_new.md | 41 + TODO_next_prev_controls.md | 37 + TODO_open_graph.md | 31 + TODO_playlists.md | 37 + TODO_shorts_implementation.md | 51 + TODO_upnext_recommendations.md | 15 + app/Http/Controllers/PlaylistController.php | 389 ++++++ app/Http/Controllers/SuperAdminController.php | 3 +- app/Http/Controllers/UserController.php | 61 +- app/Http/Controllers/VideoController.php | 253 ++-- app/Models/Playlist.php | 321 +++++ app/Models/User.php | 56 +- app/Models/Video.php | 210 +++- config/app.php | 2 +- ...11518_add_social_fields_to_users_table.php | 48 + ...0_000000_add_is_shorts_to_videos_table.php | 23 + ...26_03_15_000000_create_playlists_table.php | 29 + ...15_000001_create_playlist_videos_table.php | 33 + .../views/components/video-card.blade.php | 172 ++- resources/views/layouts/app.blade.php | 234 ++-- .../partials/add-to-playlist-modal.blade.php | 401 +++++++ .../views/layouts/partials/header.blade.php | 2 +- .../views/layouts/partials/sidebar.blade.php | 14 +- .../layouts/partials/upload-modal.blade.php | 1038 ++++++---------- resources/views/layouts/plain.blade.php | 140 ++- resources/views/playlists/index.blade.php | 522 ++++++++ resources/views/playlists/show.blade.php | 838 +++++++++++++ resources/views/user/channel.blade.php | 223 ++-- resources/views/user/profile.blade.php | 332 +++--- resources/views/videos/create.blade.php | 1053 +++-------------- resources/views/videos/shorts.blade.php | 353 ++++++ resources/views/videos/show.blade.php | 892 +++++++++----- resources/views/videos/trending.blade.php | 29 +- .../views/videos/types/generic.blade.php | 907 ++++++++++---- resources/views/videos/types/match.blade.php | 420 +++++-- resources/views/videos/types/music.blade.php | 425 +++++-- resources/views/welcome.blade.php | 182 +-- routes/web.php | 58 +- show.blade.php/resources/views/videos | 0 42 files changed, 7052 insertions(+), 2921 deletions(-) create mode 100644 "\n/var/www/videoplatform/app/Http/Controllers/VideoController.php\n" create mode 100644 TODO_drag_drop_reorder.md create mode 100644 TODO_new.md create mode 100644 TODO_next_prev_controls.md create mode 100644 TODO_open_graph.md create mode 100644 TODO_playlists.md create mode 100644 TODO_shorts_implementation.md create mode 100644 TODO_upnext_recommendations.md create mode 100644 app/Http/Controllers/PlaylistController.php create mode 100644 app/Models/Playlist.php create mode 100644 database/migrations/2026_03_03_211518_add_social_fields_to_users_table.php create mode 100644 database/migrations/2026_03_10_000000_add_is_shorts_to_videos_table.php create mode 100644 database/migrations/2026_03_15_000000_create_playlists_table.php create mode 100644 database/migrations/2026_03_15_000001_create_playlist_videos_table.php create mode 100644 resources/views/layouts/partials/add-to-playlist-modal.blade.php create mode 100644 resources/views/playlists/index.blade.php create mode 100644 resources/views/playlists/show.blade.php create mode 100644 resources/views/videos/shorts.blade.php create mode 100644 show.blade.php/resources/views/videos diff --git "a/\n/var/www/videoplatform/app/Http/Controllers/VideoController.php\n" "b/\n/var/www/videoplatform/app/Http/Controllers/VideoController.php\n" new file mode 100644 index 0000000..e69de29 diff --git a/TODO.md b/TODO.md index 6356847..dd3f3e5 100644 --- a/TODO.md +++ b/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) diff --git a/TODO_drag_drop_reorder.md b/TODO_drag_drop_reorder.md new file mode 100644 index 0000000..2cfb637 --- /dev/null +++ b/TODO_drag_drop_reorder.md @@ -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 + diff --git a/TODO_new.md b/TODO_new.md new file mode 100644 index 0000000..a7ef6a7 --- /dev/null +++ b/TODO_new.md @@ -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 diff --git a/TODO_next_prev_controls.md b/TODO_next_prev_controls.md new file mode 100644 index 0000000..20acf26 --- /dev/null +++ b/TODO_next_prev_controls.md @@ -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 + diff --git a/TODO_open_graph.md b/TODO_open_graph.md new file mode 100644 index 0000000..8a31eed --- /dev/null +++ b/TODO_open_graph.md @@ -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 + diff --git a/TODO_playlists.md b/TODO_playlists.md new file mode 100644 index 0000000..6f27cb5 --- /dev/null +++ b/TODO_playlists.md @@ -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 + diff --git a/TODO_shorts_implementation.md b/TODO_shorts_implementation.md new file mode 100644 index 0000000..d6c627d --- /dev/null +++ b/TODO_shorts_implementation.md @@ -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 + diff --git a/TODO_upnext_recommendations.md b/TODO_upnext_recommendations.md new file mode 100644 index 0000000..c3171ea --- /dev/null +++ b/TODO_upnext_recommendations.md @@ -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) diff --git a/app/Http/Controllers/PlaylistController.php b/app/Http/Controllers/PlaylistController.php new file mode 100644 index 0000000..e201cd5 --- /dev/null +++ b/app/Http/Controllers/PlaylistController.php @@ -0,0 +1,389 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/SuperAdminController.php b/app/Http/Controllers/SuperAdminController.php index 29dd307..98d9471 100644 --- a/app/Http/Controllers/SuperAdminController.php +++ b/app/Http/Controllers/SuperAdminController.php @@ -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); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 4314a94..1cffaa2 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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, ]); } } - diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index 406c6e1..cae07d0 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -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')); + } } diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php new file mode 100644 index 0000000..579917b --- /dev/null +++ b/app/Models/Playlist.php @@ -0,0 +1,321 @@ + '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; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 201239e..a04981b 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; + } +} diff --git a/app/Models/Video.php b/app/Models/Video.php index c543a25..a0792fe 100644 --- a/app/Models/Video.php +++ b/app/Models/Video.php @@ -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); } +} diff --git a/config/app.php b/config/app.php index 9207160..61de974 100755 --- a/config/app.php +++ b/config/app.php @@ -16,7 +16,7 @@ return [ | */ - 'name' => env('APP_NAME', 'Laravel'), + 'name' => env('APP_NAME', 'TAKEONE'), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2026_03_03_211518_add_social_fields_to_users_table.php b/database/migrations/2026_03_03_211518_add_social_fields_to_users_table.php new file mode 100644 index 0000000..b7b6d00 --- /dev/null +++ b/database/migrations/2026_03_03_211518_add_social_fields_to_users_table.php @@ -0,0 +1,48 @@ +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' + ]); + }); + } +}; diff --git a/database/migrations/2026_03_10_000000_add_is_shorts_to_videos_table.php b/database/migrations/2026_03_10_000000_add_is_shorts_to_videos_table.php new file mode 100644 index 0000000..e02bb46 --- /dev/null +++ b/database/migrations/2026_03_10_000000_add_is_shorts_to_videos_table.php @@ -0,0 +1,23 @@ +boolean('is_shorts')->default(false)->after('type'); + }); + } + + public function down() + { + Schema::table('videos', function (Blueprint $table) { + $table->dropColumn('is_shorts'); + }); + } +}; + diff --git a/database/migrations/2026_03_15_000000_create_playlists_table.php b/database/migrations/2026_03_15_000000_create_playlists_table.php new file mode 100644 index 0000000..f1df39a --- /dev/null +++ b/database/migrations/2026_03_15_000000_create_playlists_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/database/migrations/2026_03_15_000001_create_playlist_videos_table.php b/database/migrations/2026_03_15_000001_create_playlist_videos_table.php new file mode 100644 index 0000000..1460bd5 --- /dev/null +++ b/database/migrations/2026_03_15_000001_create_playlist_videos_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/resources/views/components/video-card.blade.php b/resources/views/components/video-card.blade.php index 1ba8f81..b1400fe 100644 --- a/resources/views/components/video-card.blade.php +++ b/resources/views/components/video-card.blade.php @@ -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) {{ gmdate('i:s', $video->duration) }} @endif + @if($isShorts) + + SHORTS + + @endif
@@ -139,7 +147,7 @@ $sizeClasses = match($size) {
@csrf @method('PUT') - +
@@ -171,6 +179,16 @@ $sizeClasses = match($size) {
+ +
+ + +
+
@@ -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) { - + @yield('scripts') @@ -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 { diff --git a/resources/views/layouts/partials/add-to-playlist-modal.blade.php b/resources/views/layouts/partials/add-to-playlist-modal.blade.php new file mode 100644 index 0000000..d00f0d3 --- /dev/null +++ b/resources/views/layouts/partials/add-to-playlist-modal.blade.php @@ -0,0 +1,401 @@ + + + + + + + + + diff --git a/resources/views/layouts/partials/header.blade.php b/resources/views/layouts/partials/header.blade.php index 6b6263e..efa243e 100644 --- a/resources/views/layouts/partials/header.blade.php +++ b/resources/views/layouts/partials/header.blade.php @@ -4,7 +4,7 @@ -