admin panel added and comments are working and likes are working
This commit is contained in:
parent
72e9439727
commit
a28023c29b
55
TODO.md
55
TODO.md
@ -1,32 +1,35 @@
|
||||
# TODO: Mobile View Improvements for Channel Page
|
||||
# Video Platform Enhancement Tasks - COMPLETED
|
||||
|
||||
## ✅ Completed Improvements
|
||||
## Phase 1: Database & Backend ✅
|
||||
- [x] Create comments migration table
|
||||
- [x] Create Comment model
|
||||
- [x] Create CommentController
|
||||
- [x] Add routes for comments
|
||||
- [x] Update Video model with subscriber count
|
||||
|
||||
## Phase 1: Channel Header Mobile Improvements ✅
|
||||
- [x] Reduce header padding from 32px to 16px on mobile
|
||||
- [x] Scale down avatar from 120px to 80px on mobile, 60px on very small screens
|
||||
- [x] Adjust channel name font size for mobile
|
||||
- [x] Stack channel stats vertically on very small screens
|
||||
- [x] Handle long channel names with ellipsis
|
||||
- [x] Improve button layout (stack buttons on mobile)
|
||||
## Phase 2: Video Type Views ✅
|
||||
- [x] Update generic.blade.php with video type icon and enhanced channel info
|
||||
- [x] Update music.blade.php with video type icon and enhanced channel info
|
||||
- [x] Update match.blade.php with video type icon and enhanced channel info
|
||||
|
||||
## Phase 2: Video Grid Mobile Improvements ✅
|
||||
- [x] Change grid to 2 columns at 768px (was 480px)
|
||||
- [x] Change grid to 1 column at 480px
|
||||
- [x] Improve video thumbnail aspect ratio handling
|
||||
- [x] Reduce gaps from 24px to 12px on mobile
|
||||
## Phase 3: Comment Section ✅
|
||||
- [x] Add comment section UI to video views
|
||||
- [x] Add @ mention functionality
|
||||
|
||||
## Phase 3: Video Card Mobile Improvements ✅
|
||||
- [x] Reduce video info spacing on mobile
|
||||
- [x] Scale down channel icon on mobile (36px → 28px)
|
||||
- [x] Adjust title font size for mobile
|
||||
- [x] Improve tap targets for more button (32px → 28px)
|
||||
## Features Implemented:
|
||||
1. Video type icons in red color before title:
|
||||
- music → 🎵 (bi-music-note)
|
||||
- match → 🏆 (bi-trophy)
|
||||
- generic → 🎬 (bi-film)
|
||||
|
||||
## Phase 4: Additional Mobile Enhancements ✅
|
||||
- [x] Pagination styling improvements for mobile
|
||||
- [x] Optimize touch interactions (tap to play/pause)
|
||||
- [x] Add skeleton loading animation support
|
||||
- [x] Landscape mobile support
|
||||
- [x] Very small screen (360px) support
|
||||
- [x] Improved header upload button responsive behavior
|
||||
2. Enhanced channel info below title:
|
||||
- Channel picture
|
||||
- Channel name
|
||||
- Number of subscribers
|
||||
- Number of views
|
||||
- Like button with icon and count
|
||||
- Edit & Share buttons
|
||||
|
||||
3. Comment section:
|
||||
- Users can comment on videos
|
||||
- @ mention support to mention other users/channels
|
||||
|
||||
@ -20,7 +20,9 @@ class AuthenticatedSessionController extends Controller
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (Auth::attempt($credentials)) {
|
||||
$remember = $request->filled('remember');
|
||||
|
||||
if (Auth::attempt($credentials, $remember)) {
|
||||
$request->session()->regenerate();
|
||||
return redirect()->intended('/videos');
|
||||
}
|
||||
|
||||
72
app/Http/Controllers/CommentController.php
Normal file
72
app/Http/Controllers/CommentController.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Comment;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index']);
|
||||
}
|
||||
|
||||
public function index(Video $video)
|
||||
{
|
||||
$comments = $video->comments()->whereNull('parent_id')->with(['user', 'replies.user'])->get();
|
||||
return response()->json($comments);
|
||||
}
|
||||
|
||||
public function store(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'body' => 'required|string|max:1000',
|
||||
'parent_id' => 'nullable|exists:comments,id',
|
||||
]);
|
||||
|
||||
$comment = $video->comments()->create([
|
||||
'user_id' => Auth::id(),
|
||||
'body' => $request->body,
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
|
||||
// Handle mentions
|
||||
preg_match_all('/@(\w+)/', $request->body, $matches);
|
||||
if (!empty($matches[1])) {
|
||||
// Mentions found - in production, you would send notifications here
|
||||
// For now, we just parse them
|
||||
}
|
||||
|
||||
return response()->json($comment->load('user'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Comment $comment)
|
||||
{
|
||||
if ($comment->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'body' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$comment->update([
|
||||
'body' => $request->body,
|
||||
]);
|
||||
|
||||
return response()->json($comment->load('user'));
|
||||
}
|
||||
|
||||
public function destroy(Comment $comment)
|
||||
{
|
||||
if ($comment->user_id !== Auth::id()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$comment->delete();
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
248
app/Http/Controllers/SuperAdminController.php
Normal file
248
app/Http/Controllers/SuperAdminController.php
Normal file
@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SuperAdminController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('super_admin');
|
||||
}
|
||||
|
||||
// Dashboard - Overview statistics
|
||||
public function dashboard()
|
||||
{
|
||||
$stats = [
|
||||
'total_users' => User::count(),
|
||||
'total_videos' => Video::count(),
|
||||
'total_views' => \DB::table('video_views')->count('id'),
|
||||
'total_likes' => \DB::table('video_likes')->count('id'),
|
||||
];
|
||||
|
||||
// Recent users
|
||||
$recentUsers = User::latest()->take(5)->get();
|
||||
|
||||
// Recent videos
|
||||
$recentVideos = Video::with('user')->latest()->take(5)->get();
|
||||
|
||||
// Videos by status
|
||||
$videosByStatus = Video::select('status', \DB::raw('count(*) as count'))
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status');
|
||||
|
||||
// Videos by visibility
|
||||
$videosByVisibility = Video::select('visibility', \DB::raw('count(*) as count'))
|
||||
->groupBy('visibility')
|
||||
->pluck('count', 'visibility');
|
||||
|
||||
return view('admin.dashboard', compact('stats', 'recentUsers', 'recentVideos', 'videosByStatus', 'videosByVisibility'));
|
||||
}
|
||||
|
||||
// List all users with search/filter
|
||||
public function users(Request $request)
|
||||
{
|
||||
$query = User::query();
|
||||
|
||||
// Search by name or email
|
||||
if ($request->has('search') && $request->search) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by role
|
||||
if ($request->has('role') && $request->role) {
|
||||
$query->where('role', $request->role);
|
||||
}
|
||||
|
||||
// Sort by
|
||||
$sort = $request->get('sort', 'latest');
|
||||
switch ($sort) {
|
||||
case 'oldest':
|
||||
$query->oldest();
|
||||
break;
|
||||
case 'name_asc':
|
||||
$query->orderBy('name', 'asc');
|
||||
break;
|
||||
case 'name_desc':
|
||||
$query->orderBy('name', 'desc');
|
||||
break;
|
||||
default:
|
||||
$query->latest();
|
||||
}
|
||||
|
||||
$users = $query->paginate(20);
|
||||
$users->appends($request->query());
|
||||
|
||||
return view('admin.users', compact('users'));
|
||||
}
|
||||
|
||||
// Show edit user form
|
||||
public function editUser(User $user)
|
||||
{
|
||||
return view('admin.edit-user', compact('user'));
|
||||
}
|
||||
|
||||
// Update user
|
||||
public function updateUser(Request $request, User $user)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255|unique:users,email,' . $user->id,
|
||||
'role' => 'required|in:user,admin,super_admin',
|
||||
'new_password' => 'nullable|min:8|confirmed',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'role' => $request->role,
|
||||
];
|
||||
|
||||
// Update password if provided
|
||||
if ($request->new_password) {
|
||||
$data['password'] = Hash::make($request->new_password);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return redirect()->route('admin.users')->with('success', 'User updated successfully!');
|
||||
}
|
||||
|
||||
// Delete user
|
||||
public function deleteUser(User $user)
|
||||
{
|
||||
// Prevent deleting yourself
|
||||
if (auth()->id() === $user->id) {
|
||||
return back()->with('error', 'You cannot delete your own account!');
|
||||
}
|
||||
|
||||
// Delete user's videos and associated files
|
||||
foreach ($user->videos as $video) {
|
||||
Storage::delete('public/videos/' . $video->filename);
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
}
|
||||
}
|
||||
$user->videos()->delete();
|
||||
|
||||
// Delete user likes and views - use direct query since relationship is named 'viewers'
|
||||
\DB::table('video_likes')->where('user_id', $user->id)->delete();
|
||||
\DB::table('video_views')->where('user_id', $user->id)->delete();
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('admin.users')->with('success', 'User deleted successfully!');
|
||||
}
|
||||
|
||||
// List all videos
|
||||
public function videos(Request $request)
|
||||
{
|
||||
$query = Video::with('user');
|
||||
|
||||
// Search by title or description
|
||||
if ($request->has('search') && $request->search) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if ($request->has('status') && $request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Filter by visibility
|
||||
if ($request->has('visibility') && $request->visibility) {
|
||||
$query->where('visibility', $request->visibility);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if ($request->has('type') && $request->type) {
|
||||
$query->where('type', $request->type);
|
||||
}
|
||||
|
||||
// Sort by
|
||||
$sort = $request->get('sort', 'latest');
|
||||
switch ($sort) {
|
||||
case 'oldest':
|
||||
$query->oldest();
|
||||
break;
|
||||
case 'title_asc':
|
||||
$query->orderBy('title', 'asc');
|
||||
break;
|
||||
case 'title_desc':
|
||||
$query->orderBy('title', 'desc');
|
||||
break;
|
||||
case 'views':
|
||||
// Can't use withCount for views due to pivot table issue
|
||||
$query->latest();
|
||||
break;
|
||||
case 'likes':
|
||||
$query->withCount('likes')->orderBy('likes_count', 'desc');
|
||||
break;
|
||||
default:
|
||||
$query->latest();
|
||||
}
|
||||
|
||||
$videos = $query->paginate(20);
|
||||
$videos->appends($request->query());
|
||||
|
||||
return view('admin.videos', compact('videos'));
|
||||
}
|
||||
|
||||
// Show edit video form
|
||||
public function editVideo(Video $video)
|
||||
{
|
||||
return view('admin.edit-video', compact('video'));
|
||||
}
|
||||
|
||||
// Update video
|
||||
public function updateVideo(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'visibility' => 'required|in:public,unlisted,private',
|
||||
'type' => 'required|in:generic,music,match',
|
||||
'status' => 'required|in:pending,processing,ready,failed',
|
||||
]);
|
||||
|
||||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status']);
|
||||
|
||||
$video->update($data);
|
||||
|
||||
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
|
||||
}
|
||||
|
||||
// Delete video
|
||||
public function deleteVideo(Video $video)
|
||||
{
|
||||
$videoTitle = $video->title;
|
||||
|
||||
// Delete files
|
||||
Storage::delete('public/videos/' . $video->filename);
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
}
|
||||
|
||||
// Delete likes and views - use direct queries since relationships have timestamp issues
|
||||
\DB::table('video_likes')->where('video_id', $video->id)->delete();
|
||||
\DB::table('video_views')->where('video_id', $video->id)->delete();
|
||||
|
||||
$video->delete();
|
||||
|
||||
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
|
||||
}
|
||||
}
|
||||
@ -198,7 +198,17 @@ class VideoController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return view('videos.show', compact('video'));
|
||||
// Load comments with user relationship
|
||||
$video->load(['comments.user', 'comments.replies.user']);
|
||||
|
||||
// Render the appropriate view based on video type
|
||||
$view = match($video->type) {
|
||||
'match' => 'videos.types.match',
|
||||
'music' => 'videos.types.music',
|
||||
default => 'videos.types.generic',
|
||||
};
|
||||
|
||||
return view($view, compact('video'));
|
||||
}
|
||||
|
||||
public function edit(Video $video, Request $request)
|
||||
|
||||
@ -64,5 +64,6 @@ class Kernel extends HttpKernel
|
||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||
'super_admin' => \App\Http\Middleware\IsSuperAdmin::class,
|
||||
];
|
||||
}
|
||||
|
||||
29
app/Http/Middleware/IsSuperAdmin.php
Normal file
29
app/Http/Middleware/IsSuperAdmin.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class IsSuperAdmin
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
return redirect()->route('login')->with('error', 'Please login to access this page.');
|
||||
}
|
||||
|
||||
if (!Auth::user()->isSuperAdmin()) {
|
||||
abort(403, 'You do not have permission to access this page.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
54
app/Models/Comment.php
Normal file
54
app/Models/Comment.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Comment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'video_id',
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'body',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function video()
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function parent()
|
||||
{
|
||||
return $this->belongsTo(Comment::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function replies()
|
||||
{
|
||||
return $this->hasMany(Comment::class, 'parent_id')->latest();
|
||||
}
|
||||
|
||||
// Get mentioned users from comment body
|
||||
public function getMentionedUsers()
|
||||
{
|
||||
preg_match_all('/@(\w+)/', $this->body, $matches);
|
||||
if (empty($matches[1])) {
|
||||
return collect();
|
||||
}
|
||||
return User::whereIn('username', $matches[1])->get();
|
||||
}
|
||||
|
||||
// Parse mentions and make them clickable
|
||||
public function getParsedBodyAttribute()
|
||||
{
|
||||
$body = e($this->body);
|
||||
$body = preg_replace('/@(\w+)/', '<a href="/channel/$1" class="user-mention">@$1</a>', $body);
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
@ -14,9 +14,11 @@ class User extends Authenticatable
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'username',
|
||||
'email',
|
||||
'password',
|
||||
'avatar',
|
||||
'role',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@ -45,6 +47,11 @@ class User extends Authenticatable
|
||||
return $this->belongsToMany(Video::class, 'video_views')->withTimestamps();
|
||||
}
|
||||
|
||||
public function comments()
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function getAvatarUrlAttribute()
|
||||
{
|
||||
if ($this->avatar) {
|
||||
@ -52,5 +59,28 @@ class User extends Authenticatable
|
||||
}
|
||||
return 'https://i.pravatar.cc/150?u=' . $this->id;
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
public function isSuperAdmin()
|
||||
{
|
||||
return $this->role === 'super_admin';
|
||||
}
|
||||
|
||||
public function isAdmin()
|
||||
{
|
||||
return in_array($this->role, ['admin', 'super_admin']);
|
||||
}
|
||||
|
||||
public function isUser()
|
||||
{
|
||||
return $this->role === 'user' || $this->role === null;
|
||||
}
|
||||
|
||||
// Placeholder for subscriber count (would need a separate table in full implementation)
|
||||
public function getSubscriberCountAttribute()
|
||||
{
|
||||
// For now, return a placeholder - in production this would come from a subscriptions table
|
||||
return rand(100, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +44,9 @@ class Video extends Model
|
||||
|
||||
public function viewers()
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'video_views')->withTimestamps();
|
||||
return $this->belongsToMany(User::class, 'video_views')
|
||||
->withPivot('watched_at')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
// Accessors
|
||||
@ -74,10 +76,10 @@ class Video extends Model
|
||||
return $this->likes()->count();
|
||||
}
|
||||
|
||||
// Get view count
|
||||
// Get view count - use direct query to avoid timestamp issues
|
||||
public function getViewCountAttribute()
|
||||
{
|
||||
return $this->viewers()->count();
|
||||
return \DB::table('video_views')->where('video_id', $this->id)->count();
|
||||
}
|
||||
|
||||
// Get shareable URL for the video
|
||||
@ -149,6 +151,15 @@ class Video extends Model
|
||||
};
|
||||
}
|
||||
|
||||
public function getTypeSymbolAttribute()
|
||||
{
|
||||
return match($this->type) {
|
||||
'music' => '🎵',
|
||||
'match' => '🏆',
|
||||
default => '🎬',
|
||||
};
|
||||
}
|
||||
|
||||
public function isGeneric()
|
||||
{
|
||||
return $this->type === 'generic';
|
||||
@ -163,5 +174,15 @@ class Video extends Model
|
||||
{
|
||||
return $this->type === 'match';
|
||||
}
|
||||
}
|
||||
|
||||
// Comments relationship
|
||||
public function comments()
|
||||
{
|
||||
return $this->hasMany(Comment::class)->latest();
|
||||
}
|
||||
|
||||
public function getCommentCountAttribute()
|
||||
{
|
||||
return $this->comments()->count();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->enum('role', ['user', 'admin', 'super_admin'])->default('user')->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('role');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
<?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('comments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('video_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('parent_id')->nullable()->constrained('comments')->onDelete('cascade');
|
||||
$table->text('body');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('comments');
|
||||
}
|
||||
};
|
||||
@ -2,21 +2,129 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// \App\Models\User::factory(10)->create();
|
||||
// Create sample users
|
||||
$user1Id = DB::table('users')->insertGetId([
|
||||
'name' => 'Music Channel',
|
||||
'email' => 'music@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
'avatar' => null,
|
||||
'role' => 'user',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// \App\Models\User::factory()->create([
|
||||
// 'name' => 'Test User',
|
||||
// 'email' => 'test@example.com',
|
||||
// ]);
|
||||
$user2Id = DB::table('users')->insertGetId([
|
||||
'name' => 'Sports Channel',
|
||||
'email' => 'sports@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
'avatar' => null,
|
||||
'role' => 'user',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$user3Id = DB::table('users')->insertGetId([
|
||||
'name' => 'Entertainment',
|
||||
'email' => 'entertainment@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
'avatar' => null,
|
||||
'role' => 'user',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Create sample videos
|
||||
// Video 1 - Music type
|
||||
DB::table('videos')->insert([
|
||||
'user_id' => $user1Id,
|
||||
'title' => 'Summer Vibes - Official Music Video',
|
||||
'description' => "Check out our latest summer hit! 🎵\n\n#SummerVibes #Music #2024",
|
||||
'filename' => 'sample-video-1.mp4',
|
||||
'path' => 'public/videos/sample-video-1.mp4',
|
||||
'thumbnail' => null,
|
||||
'duration' => 240,
|
||||
'size' => 15000000,
|
||||
'mime_type' => 'video/mp4',
|
||||
'orientation' => 'landscape',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'status' => 'ready',
|
||||
'visibility' => 'public',
|
||||
'type' => 'music',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Video 2 - Match type
|
||||
DB::table('videos')->insert([
|
||||
'user_id' => $user2Id,
|
||||
'title' => 'Championship Finals - Full Match Highlights',
|
||||
'description' => "Watch the exciting championship finals! 🏆\n\n#Championship #Sports #Highlights",
|
||||
'filename' => 'sample-video-2.mp4',
|
||||
'path' => 'public/videos/sample-video-2.mp4',
|
||||
'thumbnail' => null,
|
||||
'duration' => 600,
|
||||
'size' => 35000000,
|
||||
'mime_type' => 'video/mp4',
|
||||
'orientation' => 'landscape',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'status' => 'ready',
|
||||
'visibility' => 'public',
|
||||
'type' => 'match',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Video 3 - Generic type
|
||||
DB::table('videos')->insert([
|
||||
'user_id' => $user3Id,
|
||||
'title' => 'Behind the Scenes - Movie Production',
|
||||
'description' => "Go behind the scenes of our latest movie production! 🎬\n\n#BehindTheScenes #Movie #Production",
|
||||
'filename' => 'sample-video-3.mp4',
|
||||
'path' => 'public/videos/sample-video-3.mp4',
|
||||
'thumbnail' => null,
|
||||
'duration' => 180,
|
||||
'size' => 12000000,
|
||||
'mime_type' => 'video/mp4',
|
||||
'orientation' => 'landscape',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'status' => 'ready',
|
||||
'visibility' => 'public',
|
||||
'type' => 'generic',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Video 4 - Music type
|
||||
DB::table('videos')->insert([
|
||||
'user_id' => $user1Id,
|
||||
'title' => 'Acoustic Sessions - Live Performance',
|
||||
'description' => "Our live acoustic performance at the studio 🎵\n\n#Acoustic #LiveMusic #Sessions",
|
||||
'filename' => 'sample-video-4.mp4',
|
||||
'path' => 'public/videos/sample-video-4.mp4',
|
||||
'thumbnail' => null,
|
||||
'duration' => 300,
|
||||
'size' => 20000000,
|
||||
'mime_type' => 'video/mp4',
|
||||
'orientation' => 'landscape',
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
'status' => 'ready',
|
||||
'visibility' => 'public',
|
||||
'type' => 'music',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
211
resources/views/admin/dashboard.blade.php
Normal file
211
resources/views/admin/dashboard.blade.php
Normal file
@ -0,0 +1,211 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'Admin Dashboard')
|
||||
@section('page_title', 'Dashboard')
|
||||
|
||||
@section('content')
|
||||
<!-- Stats Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-icon">
|
||||
<i class="bi bi-people"></i>
|
||||
</div>
|
||||
<div class="stats-card-value">{{ $stats['total_users'] }}</div>
|
||||
<div class="stats-card-label">Total Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-icon">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
</div>
|
||||
<div class="stats-card-value">{{ $stats['total_videos'] }}</div>
|
||||
<div class="stats-card-label">Total Videos</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-icon">
|
||||
<i class="bi bi-eye"></i>
|
||||
</div>
|
||||
<div class="stats-card-value">{{ number_format($stats['total_views']) }}</div>
|
||||
<div class="stats-card-label">Total Views</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-icon">
|
||||
<i class="bi bi-hand-thumbs-up"></i>
|
||||
</div>
|
||||
<div class="stats-card-value">{{ number_format($stats['total_likes']) }}</div>
|
||||
<div class="stats-card-label">Total Likes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Users & Videos -->
|
||||
<div class="row">
|
||||
<!-- Recent Users -->
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Recent Users</h5>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">View All</a>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentUsers as $user)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
|
||||
<div>
|
||||
<div>{{ $user->name }}</div>
|
||||
<small class="text-secondary">{{ $user->email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($user->role === 'super_admin')
|
||||
<span class="badge-role badge-super-admin">Super Admin</span>
|
||||
@elseif($user->role === 'admin')
|
||||
<span class="badge-role badge-admin">Admin</span>
|
||||
@else
|
||||
<span class="badge-role badge-user">User</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $user->created_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-secondary">No users found</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Videos -->
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Recent Videos</h5>
|
||||
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">View All</a>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Video</th>
|
||||
<th>Status</th>
|
||||
<th>Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentVideos as $video)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if($video->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" style="width: 60px; height: 40px; object-fit: cover; border-radius: 4px;">
|
||||
@else
|
||||
<div style="width: 60px; height: 40px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-play-circle text-secondary"></i>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<div style="max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{ $video->title }}</div>
|
||||
<small class="text-secondary">by {{ $video->user->name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@switch($video->status)
|
||||
@case('ready')
|
||||
<span class="badge-status badge-ready">Ready</span>
|
||||
@break
|
||||
@case('processing')
|
||||
<span class="badge-status badge-processing">Processing</span>
|
||||
@break
|
||||
@case('pending')
|
||||
<span class="badge-status badge-pending">Pending</span>
|
||||
@break
|
||||
@case('failed')
|
||||
<span class="badge-status badge-failed">Failed</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td>{{ $video->created_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-secondary">No videos found</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Videos by Status & Visibility -->
|
||||
<div class="row">
|
||||
<!-- Videos by Status -->
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Videos by Status</h5>
|
||||
</div>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<div class="text-center">
|
||||
<div class="h3 text-success">{{ $videosByStatus['ready'] ?? 0 }}</div>
|
||||
<small class="text-secondary">Ready</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h3 text-primary">{{ $videosByStatus['processing'] ?? 0 }}</div>
|
||||
<small class="text-secondary">Processing</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h3 text-warning">{{ $videosByStatus['pending'] ?? 0 }}</div>
|
||||
<small class="text-secondary">Pending</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h3 text-danger">{{ $videosByStatus['failed'] ?? 0 }}</div>
|
||||
<small class="text-secondary">Failed</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Videos by Visibility -->
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Videos by Visibility</h5>
|
||||
</div>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<div class="text-center">
|
||||
<div class="h3 text-success">{{ $videosByVisibility['public'] ?? 0 }}</div>
|
||||
<small class="text-secondary">Public</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h3 text-warning">{{ $videosByVisibility['unlisted'] ?? 0 }}</div>
|
||||
<small class="text-secondary">Unlisted</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h3 text-secondary">{{ $videosByVisibility['private'] ?? 0 }}</div>
|
||||
<small class="text-secondary">Private</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
133
resources/views/admin/edit-user.blade.php
Normal file
133
resources/views/admin/edit-user.blade.php
Normal file
@ -0,0 +1,133 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'Edit User')
|
||||
@section('page_title', 'Edit User')
|
||||
|
||||
@section('content')
|
||||
<!-- Alerts -->
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<!-- Edit User Form -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Edit User</h5>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Users
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.users.update', $user->id) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<input type="text" class="form-control @error('name') is-invalid @enderror" id="name" name="name" value="{{ old('name', $user->name) }}" required>
|
||||
@error('name')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control @error('email') is-invalid @enderror" id="email" name="email" value="{{ old('email', $user->email) }}" required>
|
||||
@error('email')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Role</label>
|
||||
<select class="form-select @error('role') is-invalid @enderror" id="role" name="role" required>
|
||||
<option value="user" {{ old('role', $user->role) == 'user' ? 'selected' : '' }}>User</option>
|
||||
<option value="admin" {{ old('role', $user->role) == 'admin' ? 'selected' : '' }}>Admin</option>
|
||||
<option value="super_admin" {{ old('role', $user->role) == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
|
||||
</select>
|
||||
@error('role')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<hr style="border-color: var(--border-color);">
|
||||
|
||||
<h6 class="mb-3">Change Password (Optional)</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">New Password</label>
|
||||
<input type="password" class="form-control @error('new_password') is-invalid @enderror" id="new_password" name="new_password" placeholder="Leave blank to keep current password">
|
||||
@error('new_password')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_password_confirmation" class="form-label">Confirm New Password</label>
|
||||
<input type="password" class="form-control" id="new_password_confirmation" name="new_password_confirmation" placeholder="Confirm new password">
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Update User
|
||||
</button>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- User Info -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">User Info</h5>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="rounded-circle" style="width: 80px; height: 80px; object-fit: cover;">
|
||||
<h5 class="mt-2">{{ $user->name }}</h5>
|
||||
<p class="text-secondary mb-1">{{ $user->email }}</p>
|
||||
@if($user->role === 'super_admin')
|
||||
<span class="badge-role badge-super-admin">Super Admin</span>
|
||||
@elseif($user->role === 'admin')
|
||||
<span class="badge-role badge-admin">Admin</span>
|
||||
@else
|
||||
<span class="badge-role badge-user">User</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<hr style="border-color: var(--border-color);">
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">User ID</span>
|
||||
<span>#{{ $user->id }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Joined</span>
|
||||
<span>{{ $user->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Total Videos</span>
|
||||
<span>{{ $user->videos->count() }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-secondary">Email Verified</span>
|
||||
<span>{{ $user->email_verified_at ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
166
resources/views/admin/edit-video.blade.php
Normal file
166
resources/views/admin/edit-video.blade.php
Normal file
@ -0,0 +1,166 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'Edit Video')
|
||||
@section('page_title', 'Edit Video')
|
||||
|
||||
@section('content')
|
||||
<!-- Alerts -->
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<!-- Edit Video Form -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Edit Video</h5>
|
||||
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Videos
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.videos.update', $video->id) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input type="text" class="form-control @error('title') is-invalid @enderror" id="title" name="title" value="{{ old('title', $video->title) }}" required>
|
||||
@error('title')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control @error('description') is-invalid @enderror" id="description" name="description" rows="4">{{ old('description', $video->description) }}</textarea>
|
||||
@error('description')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="visibility" class="form-label">Visibility</label>
|
||||
<select class="form-select @error('visibility') is-invalid @enderror" id="visibility" name="visibility" required>
|
||||
<option value="public" {{ old('visibility', $video->visibility) == 'public' ? 'selected' : '' }}>Public</option>
|
||||
<option value="unlisted" {{ old('visibility', $video->visibility) == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
|
||||
<option value="private" {{ old('visibility', $video->visibility) == 'private' ? 'selected' : '' }}>Private</option>
|
||||
</select>
|
||||
@error('visibility')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="type" class="form-label">Type</label>
|
||||
<select class="form-select @error('type') is-invalid @enderror" id="type" name="type" required>
|
||||
<option value="generic" {{ old('type', $video->type) == 'generic' ? 'selected' : '' }}>Generic</option>
|
||||
<option value="music" {{ old('type', $video->type) == 'music' ? 'selected' : '' }}>Music</option>
|
||||
<option value="match" {{ old('type', $video->type) == 'match' ? 'selected' : '' }}>Match</option>
|
||||
</select>
|
||||
@error('type')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select class="form-select @error('status') is-invalid @enderror" id="status" name="status" required>
|
||||
<option value="pending" {{ old('status', $video->status) == 'pending' ? 'selected' : '' }}>Pending</option>
|
||||
<option value="processing" {{ old('status', $video->status) == 'processing' ? 'selected' : '' }}>Processing</option>
|
||||
<option value="ready" {{ old('status', $video->status) == 'ready' ? 'selected' : '' }}>Ready</option>
|
||||
<option value="failed" {{ old('status', $video->status) == 'failed' ? 'selected' : '' }}>Failed</option>
|
||||
</select>
|
||||
@error('status')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Update Video
|
||||
</button>
|
||||
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Video Info -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Video Info</h5>
|
||||
</div>
|
||||
|
||||
@if($video->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" class="img-fluid rounded mb-3" style="width: 100%;">
|
||||
@else
|
||||
<div class="bg-secondary rounded d-flex align-items-center justify-content-center mb-3" style="height: 180px;">
|
||||
<i class="bi bi-play-circle text-white" style="font-size: 3rem;"></i>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<hr style="border-color: var(--border-color);">
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Video ID</span>
|
||||
<span>#{{ $video->id }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Owner</span>
|
||||
<a href="{{ route('channel', $video->user->id) }}" target="_blank">{{ $video->user->name }}</a>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Uploaded</span>
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">File Size</span>
|
||||
<span>{{ number_format($video->size / 1024 / 1024, 2) }} MB</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Duration</span>
|
||||
<span>{{ $video->duration ? gmdate('H:i:s', $video->duration) : 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Orientation</span>
|
||||
<span class="text-capitalize">{{ $video->orientation }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Dimensions</span>
|
||||
<span>{{ $video->width ?? 'N/A' }} x {{ $video->height ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-secondary">Views</span>
|
||||
<span>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-secondary">Likes</span>
|
||||
<span>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</span>
|
||||
</div>
|
||||
|
||||
<hr style="border-color: var(--border-color);">
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ route('videos.show', $video->id) }}" target="_blank" class="btn btn-outline-light btn-sm">
|
||||
<i class="bi bi-play-circle"></i> View Video
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
611
resources/views/admin/layout.blade.php
Normal file
611
resources/views/admin/layout.blade.php
Normal file
@ -0,0 +1,611 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@yield('title', 'Admin Dashboard') - {{ config('app.name') }}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" 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>
|
||||
:root {
|
||||
--brand-red: #e61e1e;
|
||||
--bg-dark: #0f0f0f;
|
||||
--bg-secondary: #1e1e1e;
|
||||
--border-color: #303030;
|
||||
--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;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.yt-header-center {
|
||||
flex: 1;
|
||||
max-width: 640px;
|
||||
margin: 0 40px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.yt-search {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.yt-search-input {
|
||||
flex: 1;
|
||||
background: #121212;
|
||||
border: 1px solid var(--border-color);
|
||||
border-right: none;
|
||||
border-radius: 20px 0 0 20px;
|
||||
padding: 0 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.yt-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #1c62b9;
|
||||
}
|
||||
|
||||
.yt-search-btn {
|
||||
width: 64px;
|
||||
background: #222;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 20px 20px 0;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.yt-search-btn:hover { background: #303030; }
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.yt-sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
background: var(--bg-dark);
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
transition: transform 0.3s;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.yt-sidebar-section {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.yt-sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.yt-sidebar-link:hover { background: var(--border-color); }
|
||||
|
||||
.yt-sidebar-link.active {
|
||||
background: var(--border-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-sidebar-link i { font-size: 1.2rem; }
|
||||
|
||||
/* Admin Sidebar */
|
||||
.admin-sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 20px 0;
|
||||
z-index: 1000;
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.admin-sidebar-brand {
|
||||
padding: 0 20px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-sidebar-brand h4 {
|
||||
margin: 0;
|
||||
color: var(--brand-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.admin-sidebar-link:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-sidebar-link.active {
|
||||
background: var(--border-color);
|
||||
color: var(--brand-red);
|
||||
border-left: 3px solid var(--brand-red);
|
||||
}
|
||||
|
||||
.admin-sidebar-link i {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.admin-main {
|
||||
margin-top: 56px;
|
||||
margin-left: 240px;
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.admin-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-card-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--brand-red);
|
||||
}
|
||||
|
||||
.stats-card-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stats-card-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-control, .form-select {
|
||||
background: #282828;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
background: #282828;
|
||||
border-color: var(--brand-red);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 2px rgba(230, 30, 30, 0.2);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: var(--brand-red);
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #cc1a1a;
|
||||
border-color: #cc1a1a;
|
||||
}
|
||||
|
||||
.btn-outline-light {
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-outline-light:hover {
|
||||
background: var(--border-color);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge-role {
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-super-admin {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: #fd7e14;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-ready { background: #198754; color: white; }
|
||||
.badge-processing { background: #0d6efd; color: white; }
|
||||
.badge-pending { background: #ffc107; color: black; }
|
||||
.badge-failed { background: #dc3545; color: white; }
|
||||
|
||||
/* Visibility badges */
|
||||
.badge-public { background: #198754; color: white; }
|
||||
.badge-unlisted { background: #fd7e14; color: white; }
|
||||
.badge-private { background: #6c757d; color: white; }
|
||||
|
||||
/* User avatar */
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-form .form-control {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filter-form {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filter-form .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.filter-form label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-form .form-select {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
background: var(--border-color);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-item.active .page-link {
|
||||
background: var(--brand-red);
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert-success {
|
||||
background: #198754;
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: #dc3545;
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.admin-sidebar-brand h4,
|
||||
.admin-sidebar-link span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-sidebar-link {
|
||||
justify-content: center;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
margin-left: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@yield('extra_styles')
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar-brand">
|
||||
<h4><i class="bi bi-speedometer2"></i> Admin</h4>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<a href="{{ route('admin.dashboard') }}" class="admin-sidebar-link {{ request()->routeIs('admin.dashboard') ? 'active' : '' }}">
|
||||
<i class="bi bi-grid"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="{{ route('admin.users') }}" class="admin-sidebar-link {{ request()->routeIs('admin.users*') ? 'active' : '' }}">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Users</span>
|
||||
</a>
|
||||
<a href="{{ route('admin.videos') }}" class="admin-sidebar-link {{ request()->routeIs('admin.videos*') ? 'active' : '' }}">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
<span>Videos</span>
|
||||
</a>
|
||||
<hr style="border-color: var(--border-color); margin: 20px 0;">
|
||||
<a href="{{ route('videos.index') }}" class="admin-sidebar-link">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
<span>Back to Site</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="admin-main">
|
||||
<!-- Page Title -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="margin: 0; font-size: 1.8rem; font-weight: 600;">@yield('page_title', 'Dashboard')</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
@yield('scripts')
|
||||
</body>
|
||||
</html>
|
||||
192
resources/views/admin/users.blade.php
Normal file
192
resources/views/admin/users.blade.php
Normal file
@ -0,0 +1,192 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'User Management')
|
||||
@section('page_title', 'User Management')
|
||||
|
||||
@section('content')
|
||||
<!-- Alerts -->
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Search & Filters -->
|
||||
<div class="admin-card">
|
||||
<form method="GET" action="{{ route('admin.users') }}" class="filter-form">
|
||||
<div class="form-group">
|
||||
<label for="search">Search</label>
|
||||
<input type="text" name="search" id="search" class="form-control" placeholder="Search by name or email..." value="{{ request('search') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="role">Role</label>
|
||||
<select name="role" id="role" class="form-select">
|
||||
<option value="">All Roles</option>
|
||||
<option value="user" {{ request('role') == 'user' ? 'selected' : '' }}>User</option>
|
||||
<option value="admin" {{ request('role') == 'admin' ? 'selected' : '' }}>Admin</option>
|
||||
<option value="super_admin" {{ request('role') == 'super_admin' ? 'selected' : '' }}>Super Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sort">Sort By</label>
|
||||
<select name="sort" id="sort" class="form-select">
|
||||
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
|
||||
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
|
||||
<option value="name_asc" {{ request('sort') == 'name_asc' ? 'selected' : '' }}>Name (A-Z)</option>
|
||||
<option value="name_desc" {{ request('sort') == 'name_desc' ? 'selected' : '' }}>Name (Z-A)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-search"></i> Filter
|
||||
</button>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light">
|
||||
<i class="bi bi-x-circle"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">All Users ({{ $users->total() }})</h5>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Videos</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($users as $user)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
|
||||
<div>
|
||||
<div>{{ $user->name }}</div>
|
||||
@if($user->id === auth()->id())
|
||||
<small class="text-info">(You)</small>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ $user->email }}</td>
|
||||
<td>
|
||||
@if($user->role === 'super_admin')
|
||||
<span class="badge-role badge-super-admin">Super Admin</span>
|
||||
@elseif($user->role === 'admin')
|
||||
<span class="badge-role badge-admin">Admin</span>
|
||||
@else
|
||||
<span class="badge-role badge-user">User</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route('channel', $user->id) }}" target="_blank" class="text-decoration-none">
|
||||
{{ $user->videos->count() }} videos
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ $user->created_at->format('M d, Y') }}</td>
|
||||
<td>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-gear"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ route('admin.users.edit', $user->id) }}">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</li>
|
||||
@if($user->id !== auth()->id())
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" onclick="confirmDeleteUser({{ $user->id }}, '{{ $user->name }}')">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-4">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="d-flex justify-content-center">
|
||||
{{ $users->links() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete User Modal -->
|
||||
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-labelledby="deleteUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
|
||||
<h5 class="modal-title" id="deleteUserModalLabel" style="color: #fff; font-weight: 600;">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
|
||||
Delete User
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 24px;">
|
||||
<p>Are you sure you want to delete this user? This action cannot be undone.</p>
|
||||
<p><strong>User:</strong> <span id="deleteUserName"></span></p>
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
This will also delete all videos uploaded by this user.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
|
||||
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
|
||||
<form id="deleteUserForm" method="POST">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-danger">Delete User</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
let currentDeleteUserId = null;
|
||||
|
||||
function confirmDeleteUser(userId, userName) {
|
||||
currentDeleteUserId = userId;
|
||||
document.getElementById('deleteUserName').textContent = userName;
|
||||
document.getElementById('deleteUserForm').action = '/admin/users/' + userId;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
|
||||
modal.show();
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
243
resources/views/admin/videos.blade.php
Normal file
243
resources/views/admin/videos.blade.php
Normal file
@ -0,0 +1,243 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'Video Management')
|
||||
@section('page_title', 'Video Management')
|
||||
|
||||
@section('content')
|
||||
<!-- Alerts -->
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Search & Filters -->
|
||||
<div class="admin-card">
|
||||
<form method="GET" action="{{ route('admin.videos') }}" class="filter-form">
|
||||
<div class="form-group">
|
||||
<label for="search">Search</label>
|
||||
<input type="text" name="search" id="search" class="form-control" placeholder="Search by title or description..." value="{{ request('search') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select name="status" id="status" class="form-select">
|
||||
<option value="">All Status</option>
|
||||
<option value="ready" {{ request('status') == 'ready' ? 'selected' : '' }}>Ready</option>
|
||||
<option value="processing" {{ request('status') == 'processing' ? 'selected' : '' }}>Processing</option>
|
||||
<option value="pending" {{ request('status') == 'pending' ? 'selected' : '' }}>Pending</option>
|
||||
<option value="failed" {{ request('status') == 'failed' ? 'selected' : '' }}>Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="visibility">Visibility</label>
|
||||
<select name="visibility" id="visibility" class="form-select">
|
||||
<option value="">All Visibility</option>
|
||||
<option value="public" {{ request('visibility') == 'public' ? 'selected' : '' }}>Public</option>
|
||||
<option value="unlisted" {{ request('visibility') == 'unlisted' ? 'selected' : '' }}>Unlisted</option>
|
||||
<option value="private" {{ request('visibility') == 'private' ? 'selected' : '' }}>Private</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select name="type" id="type" class="form-select">
|
||||
<option value="">All Types</option>
|
||||
<option value="generic" {{ request('type') == 'generic' ? 'selected' : '' }}>Generic</option>
|
||||
<option value="music" {{ request('type') == 'music' ? 'selected' : '' }}>Music</option>
|
||||
<option value="match" {{ request('type') == 'match' ? 'selected' : '' }}>Match</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sort">Sort By</label>
|
||||
<select name="sort" id="sort" class="form-select">
|
||||
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Latest First</option>
|
||||
<option value="oldest" {{ request('sort') == 'oldest' ? 'selected' : '' }}>Oldest First</option>
|
||||
<option value="title_asc" {{ request('sort') == 'title_asc' ? 'selected' : '' }}>Title (A-Z)</option>
|
||||
<option value="title_desc" {{ request('sort') == 'title_desc' ? 'selected' : '' }}>Title (Z-A)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-search"></i> Filter
|
||||
</button>
|
||||
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light">
|
||||
<i class="bi bi-x-circle"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Videos Table -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">All Videos ({{ $videos->total() }})</h5>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Video</th>
|
||||
<th>Owner</th>
|
||||
<th>Status</th>
|
||||
<th>Visibility</th>
|
||||
<th>Type</th>
|
||||
<th>Views</th>
|
||||
<th>Likes</th>
|
||||
<th>Uploaded</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($videos as $video)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if($video->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" style="width: 80px; height: 50px; object-fit: cover; border-radius: 4px;">
|
||||
@else
|
||||
<div style="width: 80px; height: 50px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-play-circle text-secondary"></i>
|
||||
</div>
|
||||
@endif
|
||||
<div style="max-width: 200px;">
|
||||
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500;">{{ $video->title }}</div>
|
||||
<small class="text-secondary">{{ Str::limit($video->description, 50) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ route('channel', $video->user->id) }}" target="_blank" class="text-decoration-none">
|
||||
{{ $video->user->name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
@switch($video->status)
|
||||
@case('ready')
|
||||
<span class="badge-status badge-ready">Ready</span>
|
||||
@break
|
||||
@case('processing')
|
||||
<span class="badge-status badge-processing">Processing</span>
|
||||
@break
|
||||
@case('pending')
|
||||
<span class="badge-status badge-pending">Pending</span>
|
||||
@break
|
||||
@case('failed')
|
||||
<span class="badge-status badge-failed">Failed</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td>
|
||||
@switch($video->visibility)
|
||||
@case('public')
|
||||
<span class="badge-status badge-public">Public</span>
|
||||
@break
|
||||
@case('unlisted')
|
||||
<span class="badge-status badge-unlisted">Unlisted</span>
|
||||
@break
|
||||
@case('private')
|
||||
<span class="badge-status badge-private">Private</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-capitalize">{{ $video->type }}</span>
|
||||
</td>
|
||||
<td>{{ number_format(\DB::table('video_views')->where('video_id', $video->id)->count()) }}</td>
|
||||
<td>{{ number_format(\DB::table('video_likes')->where('video_id', $video->id)->count()) }}</td>
|
||||
<td>{{ $video->created_at->format('M d, Y') }}</td>
|
||||
<td>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-light dropdown-toggle" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-gear"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ route('videos.show', $video->id) }}" target="_blank">
|
||||
<i class="bi bi-play-circle"></i> View
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ route('admin.videos.edit', $video->id) }}">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button class="dropdown-item text-danger" onclick="confirmDeleteVideo({{ $video->id }}, '{{ $video->title }}')">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-secondary py-4">
|
||||
No videos found
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="d-flex justify-content-center">
|
||||
{{ $videos->links() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Video Modal -->
|
||||
<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;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid #3f3f3f; padding: 20px 24px;">
|
||||
<h5 class="modal-title" id="deleteVideoModalLabel" style="color: #fff; font-weight: 600;">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger me-2"></i>
|
||||
Delete Video
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 24px;">
|
||||
<p>Are you sure you want to delete this video? This action cannot be undone.</p>
|
||||
<p><strong>Video:</strong> <span id="deleteVideoTitle"></span></p>
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
This will also delete all likes and views associated with this video.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid #3f3f3f; padding: 16px 24px;">
|
||||
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
|
||||
<form id="deleteVideoForm" method="POST">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-danger">Delete Video</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
function confirmDeleteVideo(videoId, videoTitle) {
|
||||
document.getElementById('deleteVideoTitle').textContent = videoTitle;
|
||||
document.getElementById('deleteVideoForm').action = '/admin/videos/' + videoId;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteVideoModal'));
|
||||
modal.show();
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
@ -102,6 +102,21 @@
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-checkbox input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--brand-red);
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@ -135,6 +150,13 @@
|
||||
<input type="password" name="password" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" name="remember" value="true">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Sign in</button>
|
||||
</form>
|
||||
|
||||
|
||||
@ -28,7 +28,8 @@
|
||||
</button>
|
||||
|
||||
@auth
|
||||
<button type="button" class="yt-upload-btn" onclick="openUploadModal()">
|
||||
<!-- Upload Button - Opens Modal -->
|
||||
<button type="button" class="yt-upload-btn" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
@ -43,6 +44,10 @@
|
||||
@endif
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
@if(Auth::user()->isSuperAdmin())
|
||||
<li><a class="dropdown-item" href="{{ route('admin.dashboard') }}"><i class="bi bi-speedometer2"></i> Admin Dashboard</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@endif
|
||||
<li><a class="dropdown-item" href="{{ route('profile') }}"><i class="bi bi-person"></i> Profile</a></li>
|
||||
<li><a class="dropdown-item" href="{{ route('channel', Auth::user()->id) }}"><i class="bi bi-play-btn"></i> My Channel</a></li>
|
||||
<li><a class="dropdown-item" href="{{ route('settings') }}"><i class="bi bi-gear"></i> Settings</a></li>
|
||||
@ -64,4 +69,3 @@
|
||||
@endauth
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@ -31,7 +31,12 @@
|
||||
<i class="bi bi-hand-thumbs-up"></i>
|
||||
<span>Liked Videos</span>
|
||||
</a>
|
||||
@if(Auth::user()->isSuperAdmin())
|
||||
<a href="{{ route('admin.dashboard') }}" class="yt-sidebar-link {{ request()->is('admin*') ? 'active' : '' }}">
|
||||
<i class="bi bi-speedometer2"></i>
|
||||
<span>Admin Panel</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endauth
|
||||
</nav>
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!-- Upload Modal - Cute Staged Pop-up -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadModalLabel" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content upload-modal-content">
|
||||
<!-- Header -->
|
||||
@ -70,7 +70,7 @@
|
||||
<i class="bi bi-cloud-arrow-up"></i>
|
||||
</div>
|
||||
<p class="dropzone-title">Click to select or drag video here</p>
|
||||
<p class="dropzone-hint">MP4, MOV, AVI, WebM up to 512MB</p>
|
||||
<p class="dropzone-hint">MP4, MOV, AVI, WebM up to 512MB</p>
|
||||
</div>
|
||||
<div id="file-info-modal" class="file-info-modal">
|
||||
<div class="file-preview">
|
||||
@ -115,6 +115,44 @@
|
||||
placeholder="Tell viewers about your video"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-collection-play"></i> Video Type
|
||||
</label>
|
||||
<div class="visibility-options-modal" id="type-options-modal">
|
||||
<label class="visibility-option-modal active">
|
||||
<input type="radio" name="type" value="generic" checked>
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-film"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Generic</span>
|
||||
<span class="visibility-desc">Standard video</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option-modal">
|
||||
<input type="radio" name="type" value="music">
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-music-note"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Music</span>
|
||||
<span class="visibility-desc">Music video or song</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option-modal">
|
||||
<input type="radio" name="type" value="match">
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-trophy"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Match</span>
|
||||
<span class="visibility-desc">Sports match or competition</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-navigation">
|
||||
<button type="button" class="btn-prev" onclick="prevStep(1)">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
@ -171,7 +209,7 @@
|
||||
<label class="form-label">
|
||||
<i class="bi bi-shield-lock"></i> Privacy Setting
|
||||
</label>
|
||||
<div class="visibility-options-modal">
|
||||
<div class="visibility-options-modal" id="visibility-options-modal">
|
||||
<label class="visibility-option-modal active">
|
||||
<input type="radio" name="visibility" value="public" checked>
|
||||
<div class="visibility-content-modal">
|
||||
@ -558,6 +596,7 @@
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-preview.thumbnail-preview {
|
||||
@ -698,7 +737,7 @@
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
;
|
||||
}
|
||||
|
||||
.btn-prev {
|
||||
@ -850,12 +889,19 @@ const totalSteps = 4;
|
||||
|
||||
// Initialize modal functions
|
||||
function openUploadModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('uploadModal'));
|
||||
// Check if mobile device - redirect to create page on mobile
|
||||
if (window.innerWidth < 992) {
|
||||
window.location.href = '{{ route("videos.create") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
const modalEl = document.getElementById('uploadModal');
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
|
||||
// Add show class for animation
|
||||
setTimeout(() => {
|
||||
document.getElementById('uploadModal').classList.add('show');
|
||||
modalEl.classList.add('show');
|
||||
}, 10);
|
||||
}
|
||||
|
||||
@ -1199,4 +1245,3 @@ function showErrorModal(message) {
|
||||
document.getElementById('btn-back-step-4').style.display = 'flex';
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,9 +1,167 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('body_class', 'upload-page-only')
|
||||
|
||||
@section('title', 'Upload Video | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
/* Mobile: Full page view */
|
||||
@media (max-width: 991px) {
|
||||
.upload-page-only .yt-header,
|
||||
.upload-page-only .yt-sidebar {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.upload-page-only .yt-main {
|
||||
display: block !important;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive {
|
||||
display: block;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive .upload-modal-standalone {
|
||||
max-width: 100%;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive .upload-modal-header {
|
||||
border-radius: 0;
|
||||
margin: -16px -16px 16px -16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive .upload-modal-body {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive .form-input,
|
||||
.upload-page-only.upload-page-responsive .form-textarea {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive .form-label {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive .visibility-content-modal {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive .visibility-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-page-only.upload-page-responsive .visibility-desc {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: Modal view */
|
||||
@media (min-width: 992px) {
|
||||
.upload-page-only .yt-header,
|
||||
.upload-page-only .yt-sidebar,
|
||||
.upload-page-only .yt-main {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.upload-page-only {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Common Modal Styles */
|
||||
.upload-modal-standalone {
|
||||
background: linear-gradient(145deg, #1e1e1e 0%, #252525 100%);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.6), 0 0 40px rgba(230, 30, 30, 0.1);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
animation: modalPopIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes modalPopIn {
|
||||
0% {
|
||||
transform: scale(0.8) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.02) translateY(-5px);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-modal-header {
|
||||
background: linear-gradient(135deg, #e63030 0%, #ff4d4d 100%);
|
||||
border-bottom: none;
|
||||
padding: 20px 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-modal-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 100%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upload-icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: white;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.upload-modal-header .modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-subtitle {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.upload-modal-body {
|
||||
padding: 24px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Container for mobile */
|
||||
.upload-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
@ -244,140 +402,503 @@
|
||||
margin-left: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop Modal Form Styles */
|
||||
@media (min-width: 992px) {
|
||||
.upload-modal-body .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.upload-modal-body .form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.upload-modal-body .form-label i {
|
||||
color: #e63030;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.upload-modal-body .form-input,
|
||||
.upload-modal-body .form-textarea {
|
||||
width: 100%;
|
||||
background: #121212;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
color: #f1f1f1;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-modal-body .form-input:focus,
|
||||
.upload-modal-body .form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #e63030;
|
||||
box-shadow: 0 0 0 3px rgba(230, 30, 30, 0.15);
|
||||
}
|
||||
|
||||
.upload-modal-body .dropzone-modal {
|
||||
border: 2px dashed #404040;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
background: #151515;
|
||||
}
|
||||
|
||||
.upload-modal-body .dropzone-modal:hover {
|
||||
border-color: #e63030;
|
||||
background: rgba(230, 30, 30, 0.05);
|
||||
}
|
||||
|
||||
.upload-modal-body .dropzone-modal.dragover {
|
||||
border-color: #e63030;
|
||||
background: rgba(230, 30, 30, 0.1);
|
||||
}
|
||||
|
||||
.upload-modal-body .dropzone-modal input[type="file"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-modal-body .dropzone-icon {
|
||||
font-size: 36px;
|
||||
color: #e63030;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-modal-body .dropzone-title {
|
||||
color: #e5e5e5;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.upload-modal-body .dropzone-hint {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-options-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-option-modal {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-option-modal input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-content-modal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-option-modal:hover .visibility-content-modal {
|
||||
border-color: #555;
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-option-modal.active .visibility-content-modal {
|
||||
border-color: #e63030;
|
||||
background: rgba(230, 30, 30, 0.1);
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-content-modal i {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-option-modal.active .visibility-content-modal i {
|
||||
color: #e63030;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.upload-modal-body .visibility-desc {
|
||||
color: #777;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.upload-modal-actions .btn-cancel,
|
||||
.upload-modal-actions .btn-submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.upload-modal-actions .btn-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid #404040;
|
||||
color: #aaa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.upload-modal-actions .btn-cancel:hover {
|
||||
border-color: #666;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.upload-modal-actions .btn-submit {
|
||||
background: linear-gradient(135deg, #e63030 0%, #ff4d4d 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(230, 30, 30, 0.3);
|
||||
}
|
||||
|
||||
.upload-modal-actions .btn-submit:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(230, 30, 30, 0.4);
|
||||
}
|
||||
|
||||
.upload-modal-actions .btn-submit:disabled {
|
||||
background: #555;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="upload-container">
|
||||
<h1 style="font-size: 24px; font-weight: 500; margin-bottom: 24px;">Upload Video</h1>
|
||||
<div class="upload-modal-standalone">
|
||||
<!-- Header -->
|
||||
<div class="upload-modal-header">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="upload-icon-wrapper">
|
||||
<i class="bi bi-cloud-arrow-up-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="modal-title">Upload Video</h5>
|
||||
<span class="upload-subtitle">Share your creativity</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ route('videos.index') }}" class="btn-close btn-close-white" style="position: relative; z-index: 1; text-decoration: none;">×</a>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title *</label>
|
||||
<input type="text" name="title" required
|
||||
class="form-input"
|
||||
placeholder="Enter video title">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" rows="4"
|
||||
class="form-textarea"
|
||||
placeholder="Tell viewers about your video"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="upload-row">
|
||||
<!-- Body -->
|
||||
<div class="upload-modal-body">
|
||||
<form id="upload-form" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Video File *</label>
|
||||
<div id="dropzone">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-card-heading"></i> Title *
|
||||
</label>
|
||||
<input type="text" name="title" required
|
||||
class="form-input"
|
||||
placeholder="Enter video title">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-text-paragraph"></i> Description
|
||||
</label>
|
||||
<textarea name="description" rows="3"
|
||||
class="form-textarea"
|
||||
placeholder="Tell viewers about your video"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-camera-video"></i> Video File *
|
||||
</label>
|
||||
<div id="dropzone" class="dropzone-modal">
|
||||
<input type="file" name="video" id="video" accept="video/*" required>
|
||||
<div id="dropzone-default">
|
||||
<i class="bi bi-camera-video icon"></i>
|
||||
<p>Click to select or drag video here</p>
|
||||
<p class="hint">MP4, MOV, AVI, WebM up to 512MB</p>
|
||||
<div class="dropzone-icon">
|
||||
<i class="bi bi-cloud-arrow-up"></i>
|
||||
</div>
|
||||
<p class="dropzone-title">Click to select or drag video here</p>
|
||||
<p class="dropzone-hint">MP4, MOV, AVI, WebM up to 512MB</p>
|
||||
</div>
|
||||
<div id="file-info">
|
||||
<p class="filename" id="filename"></p>
|
||||
<p id="filesize"></p>
|
||||
<div class="file-preview">
|
||||
<i class="bi bi-film"></i>
|
||||
</div>
|
||||
<div class="file-details">
|
||||
<p class="filename" id="filename"></p>
|
||||
<p id="filesize"></p>
|
||||
</div>
|
||||
<button type="button" class="btn-remove-file" onclick="removeVideo(event)">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Thumbnail (optional)</label>
|
||||
<div id="thumbnail-dropzone">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-image"></i> Thumbnail (optional)
|
||||
</label>
|
||||
<div id="thumbnail-dropzone" class="dropzone-modal">
|
||||
<input type="file" name="thumbnail" id="thumbnail" accept="image/*">
|
||||
<div id="thumbnail-default">
|
||||
<i class="bi bi-image icon"></i>
|
||||
<p>Click to select or drag thumbnail</p>
|
||||
<p class="hint">JPG, PNG, WebP up to 5MB</p>
|
||||
<div class="dropzone-icon">
|
||||
<i class="bi bi-card-image"></i>
|
||||
</div>
|
||||
<p class="dropzone-title">Click to select or drag thumbnail</p>
|
||||
<p class="dropzone-hint">JPG, PNG, WebP up to 5MB</p>
|
||||
</div>
|
||||
<div id="thumbnail-info">
|
||||
<p class="filename" id="thumbnail-filename"></p>
|
||||
<p id="thumbnail-filesize"></p>
|
||||
<div class="file-preview thumbnail-preview">
|
||||
<img id="thumbnail-preview-img" src="" alt="Thumbnail preview">
|
||||
</div>
|
||||
<div class="file-details">
|
||||
<p class="filename" id="thumbnail-filename"></p>
|
||||
<p id="thumbnail-filesize"></p>
|
||||
</div>
|
||||
<button type="button" class="btn-remove-file" onclick="removeThumbnail(event)">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Video Type</label>
|
||||
<div class="visibility-options">
|
||||
<label class="visibility-option active">
|
||||
<input type="radio" name="type" value="generic" checked>
|
||||
<div class="visibility-content">
|
||||
<i class="bi bi-film"></i>
|
||||
<span class="visibility-title">Generic</span>
|
||||
<span class="visibility-desc">Standard video</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option">
|
||||
<input type="radio" name="type" value="music">
|
||||
<div class="visibility-content">
|
||||
<i class="bi bi-music-note"></i>
|
||||
<span class="visibility-title">Music</span>
|
||||
<span class="visibility-desc">Music video or song</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option">
|
||||
<input type="radio" name="type" value="match">
|
||||
<div class="visibility-content">
|
||||
<i class="bi bi-trophy"></i>
|
||||
<span class="visibility-title">Match</span>
|
||||
<span class="visibility-desc">Sports match or competition</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-collection-play"></i> Video Type
|
||||
</label>
|
||||
<div class="visibility-options-modal" id="type-options">
|
||||
<label class="visibility-option-modal active">
|
||||
<input type="radio" name="type" value="generic" checked>
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-film"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Generic</span>
|
||||
<span class="visibility-desc">Standard video</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option-modal">
|
||||
<input type="radio" name="type" value="music">
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-music-note"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Music</span>
|
||||
<span class="visibility-desc">Music video or song</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option-modal">
|
||||
<input type="radio" name="type" value="match">
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-trophy"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Match</span>
|
||||
<span class="visibility-desc">Sports match or competition</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Privacy</label>
|
||||
<div class="visibility-options">
|
||||
<label class="visibility-option active">
|
||||
<input type="radio" name="visibility" value="public" checked>
|
||||
<div class="visibility-content">
|
||||
<i class="bi bi-globe"></i>
|
||||
<span class="visibility-title">Public</span>
|
||||
<span class="visibility-desc">Everyone can see this video</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option">
|
||||
<input type="radio" name="visibility" value="unlisted">
|
||||
<div class="visibility-content">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
<span class="visibility-title">Unlisted</span>
|
||||
<span class="visibility-desc">Only people with the link can see</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option">
|
||||
<input type="radio" name="visibility" value="private">
|
||||
<div class="visibility-content">
|
||||
<i class="bi bi-lock"></i>
|
||||
<span class="visibility-title">Private</span>
|
||||
<span class="visibility-desc">Only you can see this video</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-shield-lock"></i> Privacy Setting
|
||||
</label>
|
||||
<div class="visibility-options-modal" id="visibility-options">
|
||||
<label class="visibility-option-modal active">
|
||||
<input type="radio" name="visibility" value="public" checked>
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-globe"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Public</span>
|
||||
<span class="visibility-desc">Everyone can see this video</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option-modal">
|
||||
<input type="radio" name="visibility" value="unlisted">
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Unlisted</span>
|
||||
<span class="visibility-desc">Only people with the link</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option-modal">
|
||||
<input type="radio" name="visibility" value="private">
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-lock"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Private</span>
|
||||
<span class="visibility-desc">Only you can see</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div id="progress-container">
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="progress-bar" class="progress-bar-fill"></div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div id="progress-container">
|
||||
<div class="progress-bar-wrapper">
|
||||
<div id="progress-bar" class="progress-bar-fill"></div>
|
||||
</div>
|
||||
<p id="progress-text" class="progress-text">Uploading... 0%</p>
|
||||
</div>
|
||||
<p id="progress-text" class="progress-text">Uploading... 0%</p>
|
||||
</div>
|
||||
|
||||
<!-- Status Message -->
|
||||
<div id="status-message"></div>
|
||||
|
||||
<button type="submit" id="submit-btn" class="btn-submit">
|
||||
Upload Video
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Status Message -->
|
||||
<div id="status-message"></div>
|
||||
|
||||
<div class="upload-modal-actions">
|
||||
<a href="{{ route('videos.index') }}" class="btn-cancel">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" id="submit-btn" class="btn-submit">
|
||||
<i class="bi bi-cloud-arrow-up-fill"></i> Upload Video
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop only: modal overlay styles -->
|
||||
<style>
|
||||
@media (min-width: 992px) {
|
||||
.file-preview {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, #e63030 0%, #ff6b6b 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-preview.thumbnail-preview {
|
||||
background: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-preview.thumbnail-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-details .filename {
|
||||
color: #e5e5e5;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
margin: 0 0 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.file-details p {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-remove-file {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #333;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-remove-file:hover {
|
||||
background: #e63030;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#file-info, #thumbnail-info {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #1f1f1f;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
#file-info.active, #thumbnail-info.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#dropzone, #thumbnail-dropzone {
|
||||
padding: 0;
|
||||
border: 2px dashed #404040;
|
||||
}
|
||||
|
||||
#dropzone-default, #thumbnail-default {
|
||||
padding: 32px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
@ -386,12 +907,14 @@
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const fileInput = document.getElementById('video');
|
||||
|
||||
// Add change event listener for video file selection
|
||||
fileInput.addEventListener('change', function() {
|
||||
handleFileSelect(this);
|
||||
});
|
||||
|
||||
dropzone.addEventListener('click', () => fileInput.click());
|
||||
dropzone.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('btn-remove-file')) return;
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
dropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
@ -414,9 +937,8 @@
|
||||
function handleFileSelect(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
const maxSize = 512 * 1024 * 1024; // 512MB in bytes
|
||||
const maxSize = 512 * 1024 * 1024;
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', 'video/x-msvideo', 'video/x-flv', 'video/x-matroska'];
|
||||
const validExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.wmv', '.flv', '.mkv'];
|
||||
|
||||
@ -429,16 +951,12 @@
|
||||
if (!isValidType) {
|
||||
alert('Invalid video format. Please select a valid video file (MP4, MOV, AVI, WebM, OGG, WMV, FLV, MKV).');
|
||||
input.value = '';
|
||||
document.getElementById('dropzone-default').style.display = 'block';
|
||||
document.getElementById('file-info').classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
alert('File size exceeds 512MB limit. Please select a smaller video file.');
|
||||
input.value = ''; // Clear the input
|
||||
document.getElementById('dropzone-default').style.display = 'block';
|
||||
document.getElementById('file-info').classList.remove('active');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -449,11 +967,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
function removeVideo(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileInput.value = '';
|
||||
document.getElementById('dropzone-default').style.display = 'block';
|
||||
document.getElementById('file-info').classList.remove('active');
|
||||
}
|
||||
|
||||
// Thumbnail Dropzone
|
||||
const thumbnailDropzone = document.getElementById('thumbnail-dropzone');
|
||||
const thumbnailInput = document.getElementById('thumbnail');
|
||||
|
||||
thumbnailDropzone.addEventListener('click', () => thumbnailInput.click());
|
||||
thumbnailDropzone.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('btn-remove-file')) return;
|
||||
thumbnailInput.click();
|
||||
});
|
||||
|
||||
thumbnailDropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
@ -482,13 +1011,37 @@
|
||||
const file = input.files[0];
|
||||
document.getElementById('thumbnail-filename').textContent = file.name;
|
||||
document.getElementById('thumbnail-filesize').textContent = (file.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
document.getElementById('thumbnail-preview-img').src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
document.getElementById('thumbnail-default').style.display = 'none';
|
||||
document.getElementById('thumbnail-info').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function removeThumbnail(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
thumbnailInput.value = '';
|
||||
document.getElementById('thumbnail-default').style.display = 'block';
|
||||
document.getElementById('thumbnail-info').classList.remove('active');
|
||||
}
|
||||
|
||||
// Visibility option handling
|
||||
const visibilityOptions = document.querySelectorAll('.visibility-option');
|
||||
const typeOptions = document.querySelectorAll('#type-options .visibility-option-modal');
|
||||
typeOptions.forEach(option => {
|
||||
const radio = option.querySelector('input[type="radio"]');
|
||||
radio.addEventListener('change', function() {
|
||||
typeOptions.forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
const visibilityOptions = document.querySelectorAll('#visibility-options .visibility-option-modal');
|
||||
visibilityOptions.forEach(option => {
|
||||
const radio = option.querySelector('input[type="radio"]');
|
||||
radio.addEventListener('change', function() {
|
||||
@ -505,7 +1058,7 @@
|
||||
|
||||
document.getElementById('progress-container').classList.add('active');
|
||||
document.getElementById('submit-btn').disabled = true;
|
||||
document.getElementById('submit-btn').textContent = 'Uploading...';
|
||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading...';
|
||||
document.getElementById('status-message').className = '';
|
||||
|
||||
xhr.upload.addEventListener('progress', function(e) {
|
||||
@ -544,11 +1097,10 @@
|
||||
|
||||
function showError(message) {
|
||||
const status = document.getElementById('status-message');
|
||||
status.textContent = message;
|
||||
status.innerHTML = '<i class="bi bi-exclamation-circle-fill"></i> ' + message;
|
||||
status.className = 'error';
|
||||
document.getElementById('submit-btn').disabled = false;
|
||||
document.getElementById('submit-btn').textContent = 'Upload Video';
|
||||
document.getElementById('submit-btn').innerHTML = '<i class="bi bi-cloud-arrow-up-fill"></i> Upload Video';
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
|
||||
@ -401,7 +401,7 @@
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('body_class', 'edit-page-only')
|
||||
@section('body_class', 'edit-page-only edit-page-responsive')
|
||||
|
||||
@section('content')
|
||||
<div class="edit-modal-standalone">
|
||||
@ -473,12 +473,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Type -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-collection-play"></i> Video Type
|
||||
</label>
|
||||
<div class="visibility-options-modal" id="type-options-modal">
|
||||
<label class="visibility-option-modal {{ ($video->type ?? 'generic') == 'generic' ? 'active' : '' }}">
|
||||
<input type="radio" name="type" value="generic" {{ ($video->type ?? 'generic') == 'generic' ? 'checked' : '' }}>
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-film"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Generic</span>
|
||||
<span class="visibility-desc">Standard video</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option-modal {{ $video->type == 'music' ? 'active' : '' }}">
|
||||
<input type="radio" name="type" value="music" {{ $video->type == 'music' ? 'checked' : '' }}>
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-music-note"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Music</span>
|
||||
<span class="visibility-desc">Music video or song</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option-modal {{ $video->type == 'match' ? 'active' : '' }}">
|
||||
<input type="radio" name="type" value="match" {{ $video->type == 'match' ? 'checked' : '' }}>
|
||||
<div class="visibility-content-modal">
|
||||
<i class="bi bi-trophy"></i>
|
||||
<div class="visibility-text">
|
||||
<span class="visibility-title">Match</span>
|
||||
<span class="visibility-desc">Sports match or competition</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-shield-lock"></i> Privacy Setting
|
||||
</label>
|
||||
<div class="visibility-options-modal">
|
||||
<div class="visibility-options-modal" id="visibility-options-modal">
|
||||
<label class="visibility-option-modal {{ ($video->visibility ?? 'public') == 'public' ? 'active' : '' }}">
|
||||
<input type="radio" name="visibility" value="public" {{ ($video->visibility ?? 'public') == 'public' ? 'checked' : '' }}>
|
||||
<div class="visibility-content-modal">
|
||||
@ -541,7 +580,16 @@
|
||||
@section('scripts')
|
||||
<script>
|
||||
// Visibility option handling
|
||||
const visibilityOptions = document.querySelectorAll('.visibility-option-modal');
|
||||
const typeOptionsModal = document.querySelectorAll('#type-options-modal .visibility-option-modal');
|
||||
typeOptionsModal.forEach(option => {
|
||||
const radio = option.querySelector('input[type="radio"]');
|
||||
radio.addEventListener('change', function() {
|
||||
typeOptionsModal.forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
const visibilityOptions = document.querySelectorAll('#visibility-options-modal .visibility-option-modal');
|
||||
visibilityOptions.forEach(option => {
|
||||
const radio = option.querySelector('input[type="radio"]');
|
||||
radio.addEventListener('change', function() {
|
||||
|
||||
56
resources/views/videos/partials/comment.blade.php
Normal file
56
resources/views/videos/partials/comment.blade.php
Normal file
@ -0,0 +1,56 @@
|
||||
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-{{ $comment->id }}">
|
||||
<img src="{{ $comment->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;" alt="{{ $comment->user->name }}">
|
||||
<div style="flex: 1;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
||||
<span style="font-weight: 600; font-size: 14px;">{{ $comment->user->name }}</span>
|
||||
<span style="color: var(--text-secondary); font-size: 12px;">{{ $comment->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
|
||||
{{ $comment->body }}
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; margin-top: 8px;">
|
||||
@auth
|
||||
<button onclick="toggleReplyForm({{ $comment->id }})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
||||
Reply
|
||||
</button>
|
||||
@if(Auth::id() === $comment->user_id)
|
||||
<button onclick="deleteComment({{ $comment->id }})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
||||
Delete
|
||||
</button>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
<!-- Reply Form -->
|
||||
<div id="replyForm{{ $comment->id }}" style="display: none; margin-top: 12px;">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<textarea
|
||||
class="form-control"
|
||||
placeholder="Write a reply..."
|
||||
rows="2"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px; width: 100%; resize: none; font-size: 14px;"
|
||||
></textarea>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" style="font-size: 12px; padding: 6px 12px;" onclick="toggleReplyForm({{ $comment->id }})">Cancel</button>
|
||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white; font-size: 12px; padding: 6px 12px;" onclick="submitReply({{ $video->id ?? $comment->video_id }}, {{ $comment->id }})">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
@if($comment->replies && $comment->replies->count() > 0)
|
||||
<div style="margin-left: 24px; margin-top: 12px; border-left: 2px solid var(--border-color); padding-left: 12px;">
|
||||
@foreach($comment->replies as $reply)
|
||||
@include('videos.partials.comment', ['comment' => $reply, 'video' => $video ?? null])
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleReplyForm(commentId) {
|
||||
const form = document.getElementById('replyForm' + commentId);
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
291
resources/views/videos/partials/video-details.blade.php
Normal file
291
resources/views/videos/partials/video-details.blade.php
Normal file
@ -0,0 +1,291 @@
|
||||
{{-- Video Type Icon (using Bootstrap Icons like video-card.blade.php) --}}
|
||||
@php
|
||||
$typeIcon = match($video->type) {
|
||||
'music' => 'bi-music-note',
|
||||
'match' => 'bi-trophy',
|
||||
default => 'bi-film',
|
||||
};
|
||||
@endphp
|
||||
|
||||
{{-- Video Title with Type Icon Inline --}}
|
||||
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
|
||||
<i class="bi {{ $typeIcon }}" style="color: #ef4444; margin-right: 6px;"></i>
|
||||
<span>{{ $video->title }}</span>
|
||||
</h1>
|
||||
|
||||
{{-- Channel Row with Actions Inline --}}
|
||||
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 6px 0; flex-wrap: wrap; gap: 12px;">
|
||||
{{-- Left: Channel Info --}}
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
|
||||
@if($video->user)
|
||||
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}">
|
||||
@else
|
||||
<div class="channel-avatar" style="width: 36px; height: 36px;"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
|
||||
<div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{{-- Subscribe Button --}}
|
||||
@auth
|
||||
@if(Auth::id() !== $video->user_id)
|
||||
<button class="subscribe-btn" style="background: white; color: black; border: none; padding: 8px 16px; border-radius: 18px; font-weight: 600; font-size: 14px; cursor: pointer; white-space: nowrap;">
|
||||
Subscribe
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="subscribe-btn" style="background: white; color: black; border: none; padding: 8px 16px; border-radius: 18px; font-weight: 600; font-size: 14px; text-decoration: none; white-space: nowrap; display: inline-block;">
|
||||
Subscribe
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
{{-- Right: Action Buttons (Like, Edit, Share) --}}
|
||||
<div class="video-actions">
|
||||
@auth
|
||||
{{-- Like Button with Icon and Count --}}
|
||||
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
|
||||
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{-- Edit Button - Only for video owner --}}
|
||||
@if(Auth::id() === $video->user_id)
|
||||
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-action-btn">
|
||||
<i class="bi bi-hand-thumbs-up"></i> Like
|
||||
</a>
|
||||
@endauth
|
||||
|
||||
{{-- Share Button --}}
|
||||
@if($video->isShareable())
|
||||
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Description Box (Expandable) --}}
|
||||
@if($video->description)
|
||||
@php
|
||||
// Parse markdown description
|
||||
$fullDescription = $video->description;
|
||||
$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;">
|
||||
{{-- Fixed Stats Row (views + date - cannot be manipulated) --}}
|
||||
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Separator Line --}}
|
||||
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
||||
|
||||
{{-- Description Content --}}
|
||||
<div class="description-content" id="descriptionContent">
|
||||
@if($needsExpand)
|
||||
<div class="description-short" id="descShort">
|
||||
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
||||
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
|
||||
</div>
|
||||
<div class="description-full" id="descFull" style="display: none;">
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
</div>
|
||||
<button onclick="toggleDescription()" id="descToggleBtn" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
|
||||
Show more
|
||||
</button>
|
||||
@else
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
@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';
|
||||
toggleBtn.textContent = 'Show less';
|
||||
} else {
|
||||
descShort.style.display = 'block';
|
||||
descFull.style.display = 'none';
|
||||
toggleBtn.textContent = 'Show more';
|
||||
}
|
||||
}
|
||||
</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 p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.video-description-box .description-text a {
|
||||
color: #3ea6ff;
|
||||
}
|
||||
.video-description-box .description-text code {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.video-description-box .description-text pre {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.video-description-box .description-text ul,
|
||||
.video-description-box .description-text ol {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.video-description-box .description-text strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
</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>
|
||||
|
||||
{{-- Comment Form --}}
|
||||
@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"
|
||||
placeholder="Add a comment... Use @ to mention someone"
|
||||
rows="3"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
|
||||
></textarea>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
|
||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
{{-- Comments List --}}
|
||||
<div id="commentsList">
|
||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
||||
@include('videos.partials.comment', ['comment' => $comment])
|
||||
@empty
|
||||
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the first to comment!</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function submitComment(videoId) {
|
||||
const body = document.getElementById('commentBody').value.trim();
|
||||
if (!body) return;
|
||||
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ body: body })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('commentBody').value = '';
|
||||
loadComments(videoId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadComments(videoId) {
|
||||
fetch(`/videos/${videoId}/comments`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Reload page to show new comments
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||
|
||||
fetch(`/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight @mentions in comments
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const commentTexts = document.querySelectorAll('.comment-body');
|
||||
commentTexts.forEach(text => {
|
||||
const html = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
||||
text.innerHTML = html;
|
||||
});
|
||||
});
|
||||
|
||||
function submitReply(videoId, parentId) {
|
||||
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
||||
const body = textarea.value.trim();
|
||||
if (!body) return;
|
||||
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ body: body, parent_id: parentId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@ -123,13 +123,15 @@
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
background: white;
|
||||
color: black;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 18px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
@ -259,19 +261,26 @@
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- Video Title -->
|
||||
<h1 class="video-title">{{ $video->title }}</h1>
|
||||
@php
|
||||
$typeIcon = match($video->type) {
|
||||
'music' => 'bi-music-note',
|
||||
'match' => 'bi-trophy',
|
||||
default => 'bi-film',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<!-- Video Title with Type Icon -->
|
||||
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px;">
|
||||
<i class="bi {{ $typeIcon }}" style="color: #ef4444;"></i>
|
||||
<span>{{ $video->title }}</span>
|
||||
</h1>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="video-stats-row">
|
||||
<div class="video-stats-left">
|
||||
<span>{{ number_format($video->size / 1024 / 1024, 0) }} MB</span>
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
@if($video->width && $video->height)
|
||||
<span>•</span>
|
||||
<span>{{ $video->width }}x{{ $video->height }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="video-actions">
|
||||
@auth
|
||||
@ -280,7 +289,7 @@
|
||||
@csrf
|
||||
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
|
||||
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
||||
{{ $video->like_count > 0 ? $video->like_count : 'Like' }}
|
||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@ -302,32 +311,156 @@
|
||||
</div>
|
||||
|
||||
<!-- Channel Row -->
|
||||
<div class="channel-row">
|
||||
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit;">
|
||||
@if($video->user)
|
||||
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" alt="{{ $video->user->name }}">
|
||||
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
|
||||
@if($video->user)
|
||||
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}">
|
||||
@else
|
||||
<div class="channel-avatar" style="width: 36px; height: 36px;"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
|
||||
<div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{{-- Subscribe Button --}}
|
||||
@auth
|
||||
@if(Auth::id() !== $video->user_id)
|
||||
<button class="subscribe-btn">Subscribe</button>
|
||||
@endif
|
||||
@else
|
||||
<div class="channel-avatar"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
|
||||
<div class="channel-subs">Video Creator</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="{{ route('login') }}" class="subscribe-btn">Subscribe</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@if($video->description)
|
||||
<div class="video-description">
|
||||
<p class="description-text">{{ $video->description }}</p>
|
||||
@php
|
||||
$fullDescription = $video->description;
|
||||
$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-top: 12px;">
|
||||
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
||||
<div class="description-content">
|
||||
@if($needsExpand)
|
||||
<div class="description-short">
|
||||
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
||||
<span style="color: var(--text-secondary);">... </span>
|
||||
</div>
|
||||
<div class="description-full" style="display: none;">
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
</div>
|
||||
<button onclick="toggleDescription()" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">Show more</button>
|
||||
@else
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleDescription() {
|
||||
const short = document.querySelector('.description-short');
|
||||
const full = document.querySelector('.description-full');
|
||||
const btn = document.querySelector('.video-description-box button');
|
||||
if (short.style.display !== 'none') {
|
||||
short.style.display = 'none';
|
||||
full.style.display = 'block';
|
||||
btn.textContent = 'Show less';
|
||||
} else {
|
||||
short.style.display = 'block';
|
||||
full.style.display = 'none';
|
||||
btn.textContent = 'Show more';
|
||||
}
|
||||
}
|
||||
</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
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="comments-section" style="margin-top: 24px; padding-top: 16: 1px solid var(--borderpx; border-top-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" placeholder="Add a comment... Use @ to mention someone" rows="3" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"></textarea>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
|
||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
<div id="commentsList">
|
||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
||||
@include('videos.partials.comment', ['comment' => $comment])
|
||||
@empty
|
||||
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the first to comment!</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function submitComment(videoId) {
|
||||
const body = document.getElementById('commentBody').value.trim();
|
||||
if (!body) return;
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ body: body })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.success) { document.getElementById('commentBody').value = ''; location.reload(); }
|
||||
});
|
||||
}
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Delete this comment?')) return;
|
||||
fetch(`/comments/${commentId}`, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }})
|
||||
.then(r => r.json()).then(data => { if (data.success) location.reload(); });
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.comment-body').forEach(text => {
|
||||
text.innerHTML = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
||||
});
|
||||
});
|
||||
function submitReply(videoId, parentId) {
|
||||
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
||||
const body = textarea.value.trim();
|
||||
if (!body) return;
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
body: JSON.stringify({ body: body, parent_id: parentId })
|
||||
}).then(r => r.json()).then(data => { if (data.success) location.reload(); });
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
<!-- Placeholder for recommended videos - would be dynamic in full implementation -->
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
</div>
|
||||
|
||||
@ -340,7 +473,6 @@
|
||||
@auth
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-open edit modal when redirected from /videos/{id}/edit
|
||||
openEditVideoModal({{ $video->id }});
|
||||
});
|
||||
</script>
|
||||
@ -348,24 +480,13 @@
|
||||
@endif
|
||||
|
||||
<script>
|
||||
// Auto-play video with sound when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var videoPlayer = document.getElementById('videoPlayer');
|
||||
if (videoPlayer) {
|
||||
// Set volume to 50%
|
||||
videoPlayer.volume = 0.5;
|
||||
|
||||
// Try to autoplay with sound
|
||||
var playPromise = videoPlayer.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.then(function() {
|
||||
// Autoplay started successfully
|
||||
console.log('Video autoplayed with sound at 50% volume');
|
||||
}).catch(function(error) {
|
||||
// Autoplay was prevented
|
||||
console.log('Autoplay blocked');
|
||||
});
|
||||
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,123 +1,511 @@
|
||||
<!-- Video Layout Container -->
|
||||
<div class="video-layout-container" style="display: flex; gap: 24px; max-width: 1800px; margin: 0 auto;">
|
||||
@extends('layouts.app')
|
||||
|
||||
<!-- Video Section -->
|
||||
<div class="yt-video-section">
|
||||
<!-- Video Player -->
|
||||
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer">
|
||||
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
|
||||
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- Video Title -->
|
||||
<h1 class="video-title">{{ $video->title }}</h1>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="video-stats-row">
|
||||
<div class="video-stats-left">
|
||||
<span>{{ number_format($video->size / 1024 / 1024, 0) }} MB</span>
|
||||
<span>•</span>
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
@if($video->width && $video->height)
|
||||
<span>•</span>
|
||||
<span>{{ $video->width }}x{{ $video->height }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="video-actions">
|
||||
@auth
|
||||
<!-- Like Button -->
|
||||
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
|
||||
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
||||
{{ $video->like_count > 0 ? $video->like_count : 'Like' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Edit Button - Only for video owner -->
|
||||
@if(Auth::id() === $video->user_id)
|
||||
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-action-btn">
|
||||
<i class="bi bi-hand-thumbs-up"></i> Like
|
||||
</a>
|
||||
@endauth
|
||||
@if($video->isShareable())
|
||||
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')"><i class="bi bi-share"></i> Share</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Row -->
|
||||
<div class="channel-row">
|
||||
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit;">
|
||||
@if($video->user)
|
||||
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" alt="{{ $video->user->name }}">
|
||||
@else
|
||||
<div class="channel-avatar"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
|
||||
<div class="channel-subs">Video Creator</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@if($video->description)
|
||||
<div class="video-description">
|
||||
<p class="description-text">{{ $video->description }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
<!-- Placeholder for recommended videos - would be dynamic in full implementation -->
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@section('title', $video->title . ' | ' . config('app.name'))
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
|
||||
@if(Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@auth
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-open edit modal when redirected from /videos/{id}/edit
|
||||
openEditVideoModal({{ $video->id }});
|
||||
});
|
||||
</script>
|
||||
@endauth
|
||||
@endif
|
||||
|
||||
<script>
|
||||
// Auto-play video with sound when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var videoPlayer = document.getElementById('videoPlayer');
|
||||
if (videoPlayer) {
|
||||
// Set volume to 50%
|
||||
videoPlayer.volume = 0.5;
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
/* Video Section */
|
||||
.yt-video-section { flex: 1; min-width: 0; }
|
||||
|
||||
/* Video Player */
|
||||
.video-container {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* Video Info */
|
||||
.video-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.video-stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
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;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-action-btn:hover { background: var(--border-color); }
|
||||
|
||||
.yt-action-btn.liked { color: var(--brand-red); }
|
||||
|
||||
/* Channel Row */
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 18px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.video-description {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
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;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
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;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-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%; }
|
||||
|
||||
// Try to autoplay with sound
|
||||
var playPromise = videoPlayer.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.then(function() {
|
||||
// Autoplay started successfully
|
||||
console.log('Video autoplayed with sound at 50% volume');
|
||||
}).catch(function(error) {
|
||||
// Autoplay was prevented
|
||||
console.log('Autoplay blocked');
|
||||
});
|
||||
.video-layout-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.yt-video-section {
|
||||
width: 100% !important;
|
||||
flex: none !important;
|
||||
}
|
||||
.yt-sidebar-container {
|
||||
width: 100% !important;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@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;
|
||||
}
|
||||
.video-container video {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
.video-title {
|
||||
font-size: 16px !important;
|
||||
margin: 12px 0 6px !important;
|
||||
}
|
||||
.channel-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
}
|
||||
.channel-info {
|
||||
width: 100%;
|
||||
}
|
||||
.subscribe-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.video-description {
|
||||
padding: 12px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
|
||||
<!-- Video Layout Container -->
|
||||
<div class="video-layout-container" style="display: flex; gap: 24px; max-width: 1800px; margin: 0 auto;">
|
||||
|
||||
<!-- Video Section -->
|
||||
<div class="yt-video-section">
|
||||
<!-- Video Player -->
|
||||
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer">
|
||||
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
|
||||
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- Video Title with Film Icon (Generic Type) -->
|
||||
<h1 class="video-title" style="display: flex; align-items: center; gap: 10px; margin: 8px 0 6px;">
|
||||
<i class="bi bi-film" 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">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
<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;">
|
||||
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
|
||||
@if($video->user)
|
||||
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;" alt="{{ $video->user->name }}">
|
||||
@else
|
||||
<div class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="channel-name" style="font-weight: 600;">{{ $video->user->name ?? 'Unknown' }}</div>
|
||||
<div class="channel-subs" style="font-size: 12px; color: var(--text-secondary);">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
@auth
|
||||
@if(Auth::id() !== $video->user_id)
|
||||
<button class="subscribe-btn">Subscribe</button>
|
||||
@else
|
||||
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="subscribe-btn">Subscribe</a>
|
||||
@endauth
|
||||
|
||||
@auth
|
||||
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
|
||||
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-action-btn">
|
||||
<i class="bi bi-hand-thumbs-up"></i> Like
|
||||
</a>
|
||||
@endauth
|
||||
|
||||
@if($video->isShareable())
|
||||
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Box -->
|
||||
@if($video->description)
|
||||
@php
|
||||
$fullDescription = $video->description;
|
||||
$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>
|
||||
<span>•</span>
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
||||
<div class="description-content" id="descriptionContent">
|
||||
@if($needsExpand)
|
||||
<div class="description-short" id="descShort">
|
||||
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
||||
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
|
||||
</div>
|
||||
<div class="description-full" id="descFull" style="display: none;">
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
</div>
|
||||
<button onclick="toggleDescription()" id="descToggleBtn" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
|
||||
Show more
|
||||
</button>
|
||||
@else
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
@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';
|
||||
toggleBtn.textContent = 'Show less';
|
||||
} else {
|
||||
descShort.style.display = 'block';
|
||||
descFull.style.display = 'none';
|
||||
toggleBtn.textContent = 'Show more';
|
||||
}
|
||||
}
|
||||
</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"
|
||||
placeholder="Add a comment... Use @ to mention someone"
|
||||
rows="3"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
|
||||
></textarea>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
|
||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
<div id="commentsList">
|
||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
||||
@include('videos.partials.comment', ['comment' => $comment])
|
||||
@empty
|
||||
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the first to comment!</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function submitComment(videoId) {
|
||||
const body = document.getElementById('commentBody').value.trim();
|
||||
if (!body) return;
|
||||
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ body: body })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('commentBody').value = '';
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||
|
||||
fetch(`/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const commentTexts = document.querySelectorAll('.comment-body');
|
||||
commentTexts.forEach(text => {
|
||||
const html = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
||||
text.innerHTML = html;
|
||||
});
|
||||
});
|
||||
|
||||
function submitReply(videoId, parentId) {
|
||||
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
||||
const body = textarea.value.trim();
|
||||
if (!body) return;
|
||||
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ body: body, parent_id: parentId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
|
||||
@if(Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@auth
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
openEditVideoModal({{ $video->id }});
|
||||
});
|
||||
</script>
|
||||
@endauth
|
||||
@endif
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var videoPlayer = document.getElementById('videoPlayer');
|
||||
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'); });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,511 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $video->title . ' | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
/* Video Section */
|
||||
.yt-video-section { flex: 1; min-width: 0; }
|
||||
|
||||
/* Video Player */
|
||||
.video-container {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* Video Info */
|
||||
.video-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
margin: 16px 0 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.video-stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
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;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-action-btn:hover { background: var(--border-color); }
|
||||
|
||||
.yt-action-btn.liked { color: var(--brand-red); }
|
||||
|
||||
/* Channel Row */
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 18px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.video-description {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
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;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
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;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-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;
|
||||
}
|
||||
.yt-video-section {
|
||||
width: 100% !important;
|
||||
flex: none !important;
|
||||
}
|
||||
.yt-sidebar-container {
|
||||
width: 100% !important;
|
||||
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;
|
||||
}
|
||||
.video-container video {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
.video-title {
|
||||
font-size: 16px !important;
|
||||
margin: 12px 0 6px !important;
|
||||
}
|
||||
.channel-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
}
|
||||
.channel-info {
|
||||
width: 100%;
|
||||
}
|
||||
.subscribe-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.video-description {
|
||||
padding: 12px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
|
||||
<!-- Video Layout Container -->
|
||||
<div class="video-layout-container" style="display: flex; gap: 24px; max-width: 1800px; margin: 0 auto;">
|
||||
|
||||
<!-- Video Section -->
|
||||
<div class="yt-video-section">
|
||||
<!-- Video Player -->
|
||||
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer">
|
||||
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
|
||||
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
|
||||
</video>
|
||||
</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">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
<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;">
|
||||
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
|
||||
@if($video->user)
|
||||
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;" alt="{{ $video->user->name }}">
|
||||
@else
|
||||
<div class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="channel-name" style="font-weight: 600;">{{ $video->user->name ?? 'Unknown' }}</div>
|
||||
<div class="channel-subs" style="font-size: 12px; color: var(--text-secondary);">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
@auth
|
||||
@if(Auth::id() !== $video->user_id)
|
||||
<button class="subscribe-btn">Subscribe</button>
|
||||
@else
|
||||
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="subscribe-btn">Subscribe</a>
|
||||
@endauth
|
||||
|
||||
@auth
|
||||
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
|
||||
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-action-btn">
|
||||
<i class="bi bi-hand-thumbs-up"></i> Like
|
||||
</a>
|
||||
@endauth
|
||||
|
||||
@if($video->isShareable())
|
||||
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Box -->
|
||||
@if($video->description)
|
||||
@php
|
||||
$fullDescription = $video->description;
|
||||
$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>
|
||||
<span>•</span>
|
||||
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
||||
</div>
|
||||
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
||||
<div class="description-content" id="descriptionContent">
|
||||
@if($needsExpand)
|
||||
<div class="description-short" id="descShort">
|
||||
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
||||
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
|
||||
</div>
|
||||
<div class="description-full" id="descFull" style="display: none;">
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
</div>
|
||||
<button onclick="toggleDescription()" id="descToggleBtn" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
|
||||
Show more
|
||||
</button>
|
||||
@else
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
@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';
|
||||
toggleBtn.textContent = 'Show less';
|
||||
} else {
|
||||
descShort.style.display = 'block';
|
||||
descFull.style.display = 'none';
|
||||
toggleBtn.textContent = 'Show more';
|
||||
}
|
||||
}
|
||||
</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"
|
||||
placeholder="Add a comment... Use @ to mention someone"
|
||||
rows="3"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
|
||||
></textarea>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
|
||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
<div id="commentsList">
|
||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
||||
@include('videos.partials.comment', ['comment' => $comment])
|
||||
@empty
|
||||
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the first to comment!</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function submitComment(videoId) {
|
||||
const body = document.getElementById('commentBody').value.trim();
|
||||
if (!body) return;
|
||||
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ body: body })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('commentBody').value = '';
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||
|
||||
fetch(`/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const commentTexts = document.querySelectorAll('.comment-body');
|
||||
commentTexts.forEach(text => {
|
||||
const html = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
||||
text.innerHTML = html;
|
||||
});
|
||||
});
|
||||
|
||||
function submitReply(videoId, parentId) {
|
||||
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
||||
const body = textarea.value.trim();
|
||||
if (!body) return;
|
||||
|
||||
fetch(`/videos/${videoId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ body: body, parent_id: parentId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
|
||||
@if(Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@auth
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
openEditVideoModal({{ $video->id }});
|
||||
});
|
||||
</script>
|
||||
@endauth
|
||||
@endif
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var videoPlayer = document.getElementById('videoPlayer');
|
||||
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'); });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
@ -3,6 +3,8 @@
|
||||
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;
|
||||
|
||||
// Redirect root to videos
|
||||
Route::get('/', function () {
|
||||
@ -31,6 +33,14 @@ Route::middleware('auth')->group(function () {
|
||||
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
|
||||
});
|
||||
|
||||
// Comment routes
|
||||
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
|
||||
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
|
||||
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
|
||||
});
|
||||
|
||||
// User routes
|
||||
Route::middleware('auth')->group(function () {
|
||||
// Profile
|
||||
@ -52,3 +62,21 @@ Route::get('/channel/{userId?}', [UserController::class, 'channel'])->name('chan
|
||||
// Authentication Routes
|
||||
require __DIR__.'/auth.php';
|
||||
|
||||
// Super Admin Routes
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user