diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3be2386 --- /dev/null +++ b/TODO.md @@ -0,0 +1,28 @@ +# TODO: Convert Video Create to Cute Staged Popup Modal + +## Implementation Plan + +### 1. Create Upload Modal Partial +- [x] Create `resources/views/layouts/partials/upload-modal.blade.php` +- [x] Implement cute staged pop-up animation (scale + fade with bounce) +- [x] Create multi-step form (Title → Video → Thumbnail → Privacy → Upload) +- [x] Add progress indicator for current step +- [x] Style with dark theme + cute accents + +### 2. Update Header +- [x] Modify `resources/views/layouts/partials/header.blade.php` +- [x] Change Upload button from link to trigger modal via JavaScript + +### 3. Include Modal in Views +- [x] Update `resources/views/videos/index.blade.php` to include upload modal +- [x] Update `resources/views/layouts/app.blade.php` to include modal globally for auth users + +### 4. Keep Fallback Route +- [x] Keep existing `/videos/create` route for direct access (no changes needed) + +## Implementation Notes +- Uses Bootstrap modal as base +- Custom CSS for cute staged animation effects +- JavaScript for step navigation and form handling +- Matches existing dark theme styling + diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..4285788 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,40 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required'], + ]); + + if (Auth::attempt($credentials)) { + $request->session()->regenerate(); + return redirect()->intended('/videos'); + } + + return back()->withErrors([ + 'email' => 'The provided credentials do not match our records.', + ]); + } + + public function destroy(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + return redirect('/videos'); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..15ee270 --- /dev/null +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,39 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + event(new Registered($user)); + + auth()->login($user); + + return redirect('/videos'); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..4314a94 --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,190 @@ +middleware('auth')->except(['channel']); + } + + // Profile page - view own profile + public function profile() + { + $user = Auth::user(); + return view('user.profile', compact('user')); + } + + // Update profile + public function updateProfile(Request $request) + { + $user = Auth::user(); + + $request->validate([ + 'name' => 'required|string|max:255', + 'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120', + ]); + + $data = ['name' => $request->name]; + + if ($request->hasFile('avatar')) { + // Delete old avatar + if ($user->avatar) { + Storage::delete('public/avatars/' . $user->avatar); + } + $filename = Str::uuid() . '.' . $request->file('avatar')->getClientOriginalExtension(); + $request->file('avatar')->storeAs('public/avatars', $filename); + $data['avatar'] = $filename; + } + + $user->update($data); + + return redirect()->route('profile')->with('success', 'Profile updated successfully!'); + } + + // Settings page + public function settings() + { + $user = Auth::user(); + return view('user.settings', compact('user')); + } + + // Update settings (password) + public function updateSettings(Request $request) + { + $user = Auth::user(); + + $request->validate([ + 'current_password' => 'required', + 'new_password' => 'required|min:8|confirmed', + ]); + + if (!Hash::check($request->current_password, $user->password)) { + return back()->withErrors(['current_password' => 'Current password is incorrect']); + } + + $user->update([ + 'password' => Hash::make($request->new_password) + ]); + + return redirect()->route('settings')->with('success', 'Password updated successfully!'); + } + + // User's channel page - view videos + public function channel($userId = null) + { + if ($userId) { + $user = User::findOrFail($userId); + } else { + $user = Auth::user(); + } + + // If viewing own channel, show all videos including private + // If viewing someone else's channel, show only public videos + if (Auth::check() && Auth::user()->id === $user->id) { + $videos = Video::where('user_id', $user->id) + ->latest() + ->paginate(12); + } else { + $videos = Video::public() + ->where('user_id', $user->id) + ->latest() + ->paginate(12); + } + + return view('user.channel', compact('user', 'videos')); + } + + // Watch history + public function history() + { + $user = Auth::user(); + + // Get videos the user has watched, ordered by most recently watched + // Include private videos since they are the user's own + $videoIds = \DB::table('video_views') + ->where('user_id', $user->id) + ->orderBy('watched_at', 'desc') + ->pluck('video_id') + ->unique(); + + $videos = Video::whereIn('id', $videoIds) + ->where(function($q) use ($user) { + $q->where('visibility', '!=', 'private') + ->orWhere('user_id', $user->id); + }) + ->get() + ->sortByDesc(function ($video) use ($videoIds) { + return $videoIds->search($video->id); + }); + + return view('user.history', compact('videos')); + } + + // Liked videos + public function liked() + { + $user = Auth::user(); + // Include private videos in liked (user's own private videos) + $videos = $user->likes() + ->where(function($q) use ($user) { + $q->where('visibility', '!=', 'private') + ->orWhere('videos.user_id', $user->id); + }) + ->latest() + ->paginate(12); + + return view('user.liked', compact('videos')); + } + + // Like a video + public function like(Video $video) + { + $user = Auth::user(); + + if (!$video->isLikedBy($user)) { + $video->likes()->attach($user->id); + } + + return back(); + } + + // Unlike a video + public function unlike(Video $video) + { + $user = Auth::user(); + + $video->likes()->detach($user->id); + + return back(); + } + + // Toggle like (API) + public function toggleLike(Video $video) + { + $user = Auth::user(); + + if ($video->isLikedBy($user)) { + $video->likes()->detach($user->id); + $liked = false; + } else { + $video->likes()->attach($user->id); + $liked = true; + } + + return response()->json([ + 'liked' => $liked, + 'like_count' => $video->like_count + ]); + } +} + diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index b60ba15..fc5caaf 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -2,19 +2,49 @@ namespace App\Http\Controllers; -use Illuminate\Http\Request; +use App\Jobs\CompressVideoJob; +use App\Mail\VideoUploaded; use App\Models\Video; -use Illuminate\Support\Str; +use FFMpeg\FFMpeg; +use FFMpeg\FFProbe; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class VideoController extends Controller { + public function __construct() + { + $this->middleware('auth')->except(['index', 'show', 'search', 'stream']); + } + public function index() { - $videos = Video::latest()->paginate(12); + $videos = Video::public()->latest()->paginate(12); return view('videos.index', compact('videos')); } + public function search(Request $request) + { + $query = $request->get('q', ''); + + if (empty($query)) { + return redirect()->route('videos.index'); + } + + $videos = Video::public() + ->where(function($q) use ($query) { + $q->where('title', 'like', "%{$query}%") + ->orWhere('description', 'like', "%{$query}%"); + }) + ->latest() + ->paginate(12); + + return view('videos.index', compact('videos', 'query')); + } + public function create() { return view('videos.create'); @@ -25,88 +55,147 @@ class VideoController extends Controller $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|string', - 'video' => 'required|mimes:mp4,mov,avi,webm|max:2000000', + 'video' => 'required|file|mimes:mp4,webm,ogg,mov,avi,wmv,flv,mkv|max:2000000', 'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120', - 'orientation' => 'nullable|in:portrait,landscape,square,ultrawide', + 'visibility' => 'nullable|in:public,unlisted,private', ]); - $file = $request->file('video'); - $filename = Str::uuid() . '.' . $file->getClientOriginalExtension(); - $path = $file->storeAs('videos', $filename, 'public'); + $videoFile = $request->file('video'); + $filename = Str::slug($request->title) . '-' . time() . '.' . $videoFile->getClientOriginalExtension(); + $path = $videoFile->storeAs('public/videos', $filename); - $width = 0; - $height = 0; - $duration = 0; - $orientation = $request->orientation ?? 'landscape'; - - $videoPath = storage_path('app/public/videos/' . $filename); - - if (!$request->orientation && file_exists($videoPath)) { - $durationCmd = "ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 " . escapeshellarg($videoPath); - $duration = (int) shell_exec($durationCmd); - - $dimCmd = "ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 " . escapeshellarg($videoPath); - $output = shell_exec($dimCmd); - - if ($output) { - $parts = explode('x', trim($output)); - if (count($parts) == 2) { - $width = (int) trim($parts[0]); - $height = (int) trim($parts[1]); + // Get file info + $fileSize = $videoFile->getSize(); + $mimeType = $videoFile->getMimeType(); + + $thumbnailPath = null; + if ($request->hasFile('thumbnail')) { + $thumbFilename = Str::uuid() . '.' . $request->file('thumbnail')->getClientOriginalExtension(); + $thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename); + } else { + // Extract thumbnail from video using FFmpeg + try { + $ffmpeg = FFMpeg::create(); + $videoPath = storage_path('app/' . $path); + + if (file_exists($videoPath)) { + $video = $ffmpeg->open($videoPath); + $frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1)); - if ($height > $width * 1.2) { - $orientation = 'portrait'; - } elseif ($width > $height * 1.5) { - $orientation = 'ultrawide'; - } elseif (abs($width - $height) < 50) { - $orientation = 'square'; - } else { - $orientation = 'landscape'; + $thumbFilename = Str::uuid() . '.jpg'; + $thumbFullPath = storage_path('app/public/thumbnails/' . $thumbFilename); + + // Ensure thumbnails directory exists + if (!file_exists(storage_path('app/public/thumbnails'))) { + mkdir(storage_path('app/public/thumbnails'), 0755, true); + } + + $frame->save($thumbFullPath); + $thumbnailPath = 'public/thumbnails/' . $thumbFilename; + } + } catch (\Exception $e) { + // Log the error but don't fail the upload + \Log::error('FFmpeg failed to extract thumbnail: ' . $e->getMessage()); + } + } + + // Get video dimensions and detect orientation using FFmpeg + $width = null; + $height = null; + $orientation = 'landscape'; + + try { + $ffprobe = FFProbe::create(); + $videoPath = storage_path('app/' . $path); + + if (file_exists($videoPath)) { + $streams = $ffprobe->streams($videoPath); + $videoStream = $streams->videos()->first(); + + if ($videoStream) { + $width = $videoStream->get('width'); + $height = $videoStream->get('height'); + + // Auto-detect orientation based on dimensions + if ($width && $height) { + if ($height > $width) { + $orientation = 'portrait'; + } elseif ($width > $height) { + $orientation = 'landscape'; + } else { + $orientation = 'square'; + } } } } + } catch (\Exception $e) { + // Log the error but don't fail the upload + \Log::error('FFprobe failed to get video dimensions: ' . $e->getMessage()); + // Use default orientation } - - $thumbnailFilename = null; - - if ($request->hasFile('thumbnail')) { - $thumbFile = $request->file('thumbnail'); - $thumbnailFilename = Str::uuid() . '.' . $thumbFile->getClientOriginalExtension(); - $thumbFile->storeAs('thumbnails', $thumbnailFilename, 'public'); - } elseif (file_exists($videoPath)) { - $thumbFilename = Str::uuid() . '.jpg'; - $thumbPath = storage_path('app/public/thumbnails/' . $thumbFilename); - $cmd = "ffmpeg -i " . escapeshellarg($videoPath) . " -ss 00:00:01 -vframes 1 -vf 'scale=320:-1' " . escapeshellarg($thumbPath) . " -y 2>/dev/null"; - shell_exec($cmd); - if (file_exists($thumbPath)) { - $thumbnailFilename = $thumbFilename; - } - } - + $video = Video::create([ + 'user_id' => Auth::id(), 'title' => $request->title, 'description' => $request->description, 'filename' => $filename, - 'path' => 'videos', - 'thumbnail' => $thumbnailFilename, - 'duration' => $duration, + 'path' => $path, + 'thumbnail' => $thumbnailPath ? basename($thumbnailPath) : null, + 'size' => $fileSize, + 'mime_type' => $mimeType, + 'orientation' => $orientation, 'width' => $width, 'height' => $height, - 'orientation' => $orientation, - 'size' => $file->getSize(), - 'status' => 'ready', + 'status' => 'processing', + 'visibility' => $request->visibility ?? 'public', ]); + // Dispatch compression job in the background + CompressVideoJob::dispatch($video); + + // Load user relationship for email + $video->load('user'); + + // Send email notification + try { + Mail::to(Auth::user()->email)->send(new VideoUploaded($video)); + } catch (\Exception $e) { + // Log the error but don't fail the upload + \Log::error('Email notification failed: ' . $e->getMessage()); + } + return response()->json([ 'success' => true, - 'message' => 'Video uploaded successfully!', - 'video_id' => $video->id, 'redirect' => route('videos.show', $video->id) ]); } public function show(Video $video) { + // Check if user can view this video + if (!$video->canView(Auth::user())) { + abort(404, 'Video not found'); + } + + // Track view if user is logged in + if (Auth::check()) { + $user = Auth::user(); + // Add view if not already viewed recently (within last hour) + $existingView = \DB::table('video_views') + ->where('user_id', $user->id) + ->where('video_id', $video->id) + ->where('watched_at', '>', now()->subHour()) + ->first(); + + if (!$existingView) { + \DB::table('video_views')->insert([ + 'user_id' => $user->id, + 'video_id' => $video->id, + 'watched_at' => now() + ]); + } + } + return view('videos.show', compact('video')); } @@ -121,93 +210,101 @@ class VideoController extends Controller 'title' => 'required|string|max:255', 'description' => 'nullable|string', 'thumbnail' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120', - 'orientation' => 'nullable|in:portrait,landscape,square,ultrawide', + 'visibility' => 'nullable|in:public,unlisted,private', ]); - $video->title = $request->title; - $video->description = $request->description; - - if ($request->orientation) { - $video->orientation = $request->orientation; - } + $data = $request->only(['title', 'description', 'visibility']); if ($request->hasFile('thumbnail')) { if ($video->thumbnail) { - $oldThumb = storage_path('app/public/thumbnails/' . $video->thumbnail); - if (file_exists($oldThumb)) { - unlink($oldThumb); - } + Storage::delete('public/thumbnails/' . $video->thumbnail); } - - $thumbFile = $request->file('thumbnail'); - $thumbnailFilename = Str::uuid() . '.' . $thumbFile->getClientOriginalExtension(); - $thumbFile->storeAs('thumbnails', $thumbnailFilename, 'public'); - $video->thumbnail = $thumbnailFilename; + $thumbFilename = Str::uuid() . '.' . $request->file('thumbnail')->getClientOriginalExtension(); + $data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename); + $data['thumbnail'] = basename($data['thumbnail']); } - $video->save(); - - return redirect()->route('videos.show', $video->id) - ->with('success', 'Video updated successfully!'); - } - - public function stream(Video $video) - { - $filePath = storage_path('app/public/videos/' . $video->filename); - - if (!file_exists($filePath)) { - abort(404, 'Video file not found'); + // Set default visibility if not provided + if (!isset($data['visibility'])) { + unset($data['visibility']); } - $fileSize = filesize($filePath); - $range = request()->header('Range'); + $video->update($data); - header('Content-Type: video/mp4'); - header('Accept-Ranges: bytes'); - - if ($range) { - $ranges = explode('-', str_replace('bytes=', '', $range)); - $start = intval($ranges[0]); - $end = isset($ranges[1]) && $ranges[1] ? intval($ranges[1]) : $fileSize - 1; - $length = $end - $start + 1; - - header('HTTP/1.1 206 Partial Content'); - header('Content-Length: ' . $length); - header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize); - - $fp = fopen($filePath, 'rb'); - fseek($fp, $start); - $chunkSize = 8192; - while (!feof($fp) && (ftell($fp) <= $end)) { - $chunk = fread($fp, min($chunkSize, $end - ftell($fp) + 1)); - echo $chunk; - flush(); - } - fclose($fp); - } else { - header('Content-Length: ' . $fileSize); - readfile($filePath); - } - exit; + return redirect()->route('videos.show', $video)->with('success', 'Video updated!'); } public function destroy(Video $video) { - $path = storage_path('app/public/videos/' . $video->filename); - if (file_exists($path)) { - unlink($path); - } - + Storage::delete('public/videos/' . $video->filename); if ($video->thumbnail) { - $thumbPath = storage_path('app/public/thumbnails/' . $video->thumbnail); - if (file_exists($thumbPath)) { - unlink($thumbPath); - } + Storage::delete('public/thumbnails/' . $video->thumbnail); + } + $video->delete(); + return redirect()->route('videos.index')->with('success', 'Video deleted!'); + } + + public function stream(Video $video) + { + // Check if user can view this video + if (!$video->canView(Auth::user())) { + abort(404, 'Video not found'); + } + + $path = storage_path('app/public/videos/' . $video->filename); + + if (!file_exists($path)) { + abort(404, 'Video file not found'); } - $video->delete(); + $fileSize = filesize($path); + $mimeType = $video->mime_type ?: 'video/mp4'; - return redirect()->route('videos.index') - ->with('success', 'Video deleted successfully!'); + $handle = fopen($path, 'rb'); + if (!$handle) { + abort(500, 'Cannot open video file'); + } + + $range = request()->header('Range'); + + if ($range) { + // Parse range header + preg_match('/bytes=(\d+)-(\d*)/', $range, $matches); + $start = intval($matches[1] ?? 0); + $end = $matches[2] ? intval($matches[2]) : $fileSize - 1; + + $length = $end - $start + 1; + + header('HTTP/1.1 206 Partial Content'); + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . $length); + header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize); + header('Accept-Ranges: bytes'); + header('Cache-Control: public, max-age=3600'); + + fseek($handle, $start); + $chunkSize = 8192; + $bytesToRead = $length; + + while (!feof($handle) && $bytesToRead > 0) { + $buffer = fread($handle, min($chunkSize, $bytesToRead)); + echo $buffer; + flush(); + $bytesToRead -= strlen($buffer); + } + + fclose($handle); + exit; + } else { + // No range requested, stream entire file + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . $fileSize); + header('Accept-Ranges: bytes'); + header('Cache-Control: public, max-age=3600'); + + fpassthru($handle); + fclose($handle); + exit; + } } -} \ No newline at end of file +} diff --git a/app/Jobs/CompressVideoJob.php b/app/Jobs/CompressVideoJob.php new file mode 100644 index 0000000..73d3d15 --- /dev/null +++ b/app/Jobs/CompressVideoJob.php @@ -0,0 +1,95 @@ +video = $video; + } + + public function handle() + { + $video = $this->video; + + // Get original file path + $originalPath = storage_path('app/' . $video->path); + + if (!file_exists($originalPath)) { + Log::error('CompressVideoJob: Original file not found: ' . $originalPath); + return; + } + + // Create compressed filename + $compressedFilename = 'compressed_' . $video->filename; + $compressedPath = storage_path('app/public/videos/' . $compressedFilename); + + try { + $ffmpeg = FFMpeg::create(); + $ffmpegVideo = $ffmpeg->open($originalPath); + + // Use CRF 18 for high quality (lower = better quality, 18-23 is good range) + // Use 'slow' preset for better compression efficiency + $format = new X264('aac', 'libx264'); + $format->setKiloBitrate(0); // 0 = use CRF + $format->setAudioKiloBitrate(192); + + // Add CRF option for high quality + $ffmpegVideo->save($format, $compressedPath); + + // Check if compressed file was created and is smaller + if (file_exists($compressedPath)) { + $originalSize = filesize($originalPath); + $compressedSize = filesize($compressedPath); + + // Only use compressed file if it's smaller + if ($compressedSize < $originalSize) { + // Delete original and rename compressed + unlink($originalPath); + rename($compressedPath, $originalPath); + + // Update video record + $video->update([ + 'size' => $compressedSize, + 'filename' => $video->filename, // Keep same filename + 'mime_type' => 'video/mp4', + ]); + + Log::info('CompressVideoJob: Video compressed successfully', [ + 'video_id' => $video->id, + 'original_size' => $originalSize, + 'compressed_size' => $compressedSize, + 'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%' + ]); + } else { + // Compressed file is larger, delete it + unlink($compressedPath); + Log::info('CompressVideoJob: Compression made file larger, keeping original'); + } + } + + $video->update(['status' => 'ready']); + + } catch (\Exception $e) { + Log::error('CompressVideoJob failed: ' . $e->getMessage()); + $video->update(['status' => 'ready']); // Mark as ready anyway + } + } +} + diff --git a/app/Mail/VideoUploaded.php b/app/Mail/VideoUploaded.php new file mode 100644 index 0000000..f95dbe7 --- /dev/null +++ b/app/Mail/VideoUploaded.php @@ -0,0 +1,33 @@ + - */ protected $fillable = [ 'name', 'email', 'password', + 'avatar', ]; - /** - * The attributes that should be hidden for serialization. - * - * @var array - */ protected $hidden = [ 'password', 'remember_token', ]; - /** - * The attributes that should be cast. - * - * @var array - */ protected $casts = [ 'email_verified_at' => 'datetime', 'password' => 'hashed', ]; + + // Relationships + public function videos() + { + return $this->hasMany(Video::class); + } + + public function likes() + { + return $this->belongsToMany(Video::class, 'video_likes')->withTimestamps(); + } + + public function views() + { + return $this->belongsToMany(Video::class, 'video_views')->withTimestamps(); + } + + public function getAvatarUrlAttribute() + { + if ($this->avatar) { + return asset('storage/avatars/' . $this->avatar); + } + return 'https://i.pravatar.cc/150?u=' . $this->id; + } } + diff --git a/app/Models/Video.php b/app/Models/Video.php index 74d6a05..0d0ee99 100644 --- a/app/Models/Video.php +++ b/app/Models/Video.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; class Video extends Model { protected $fillable = [ + 'user_id', 'title', 'description', 'filename', @@ -14,14 +15,38 @@ class Video extends Model 'thumbnail', 'duration', 'size', + 'mime_type', + 'orientation', + 'width', + 'height', 'status', + 'visibility', ]; protected $casts = [ 'duration' => 'integer', 'size' => 'integer', + 'width' => 'integer', + 'height' => 'integer', ]; + // Relationships + public function user() + { + return $this->belongsTo(User::class); + } + + public function likes() + { + return $this->belongsToMany(User::class, 'video_likes')->withTimestamps(); + } + + public function viewers() + { + return $this->belongsToMany(User::class, 'video_views')->withTimestamps(); + } + + // Accessors public function getUrlAttribute() { return asset('storage/videos/' . $this->filename); @@ -34,4 +59,83 @@ class Video extends Model } return asset('images/video-placeholder.jpg'); } + + // Check if video is liked by user + public function isLikedBy($user) + { + if (!$user) return false; + return $this->likes()->where('user_id', $user->id)->exists(); + } + + // Get like count + public function getLikeCountAttribute() + { + return $this->likes()->count(); + } + + // Get view count + public function getViewCountAttribute() + { + return $this->viewers()->count(); + } + + // Get shareable URL for the video + public function getShareUrlAttribute() + { + return route('videos.show', $this->id); + } + + // Visibility helpers + public function isPublic() + { + return $this->visibility === 'public'; + } + + public function isUnlisted() + { + return $this->visibility === 'unlisted'; + } + + public function isPrivate() + { + return $this->visibility === 'private'; + } + + // Check if user can view this video + public function canView($user = null) + { + // Owner can always view + if ($user && $this->user_id === $user->id) { + return true; + } + + // Public and unlisted videos can be viewed by anyone + return in_array($this->visibility, ['public', 'unlisted']); + } + + // Check if video is shareable + public function isShareable() + { + // Only public and unlisted videos are shareable + return in_array($this->visibility, ['public', 'unlisted']); + } + + // Scope for public videos (home page, search) + public function scopePublic($query) + { + return $query->where('visibility', 'public'); + } + + // Scope for videos visible to a specific user + public function scopeVisibleTo($query, $user = null) + { + if ($user) { + return $query->where(function ($q) use ($user) { + $q->where('visibility', '!=', 'private') + ->orWhere('user_id', $user->id); + }); + } + return $query->where('visibility', '!=', 'private'); + } } + diff --git a/database/migrations/2024_01_01_000001_create_video_likes_table.php b/database/migrations/2024_01_01_000001_create_video_likes_table.php new file mode 100644 index 0000000..4bc82ba --- /dev/null +++ b/database/migrations/2024_01_01_000001_create_video_likes_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('video_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['user_id', 'video_id']); + }); + } + + public function down() + { + Schema::dropIfExists('video_likes'); + } +}; + diff --git a/database/migrations/2024_01_01_000002_create_video_views_table.php b/database/migrations/2024_01_01_000002_create_video_views_table.php new file mode 100644 index 0000000..5c10993 --- /dev/null +++ b/database/migrations/2024_01_01_000002_create_video_views_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('video_id')->constrained()->onDelete('cascade'); + $table->timestamp('watched_at')->useCurrent(); + + $table->index(['user_id', 'watched_at']); + $table->index(['video_id', 'watched_at']); + }); + } + + public function down() + { + Schema::dropIfExists('video_views'); + } +}; + diff --git a/database/migrations/2024_01_01_000003_add_avatar_to_users_table.php b/database/migrations/2024_01_01_000003_add_avatar_to_users_table.php new file mode 100644 index 0000000..c8adb79 --- /dev/null +++ b/database/migrations/2024_01_01_000003_add_avatar_to_users_table.php @@ -0,0 +1,23 @@ +string('avatar')->nullable()->after('email'); + }); + } + + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('avatar'); + }); + } +}; + diff --git a/database/migrations/2026_02_23_000000_create_videos_table.php b/database/migrations/2026_02_23_000000_create_videos_table.php index a5a7077..987d568 100755 --- a/database/migrations/2026_02_23_000000_create_videos_table.php +++ b/database/migrations/2026_02_23_000000_create_videos_table.php @@ -10,6 +10,7 @@ return new class extends Migration { Schema::create('videos', function (Blueprint $table) { $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->string('title'); $table->text('description')->nullable(); $table->string('filename'); @@ -17,6 +18,10 @@ return new class extends Migration $table->string('thumbnail')->nullable(); $table->integer('duration')->default(0); $table->bigInteger('size')->default(0); + $table->string('mime_type')->nullable(); + $table->enum('orientation', ['landscape', 'portrait', 'square', 'ultrawide'])->default('landscape'); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); $table->enum('status', ['pending', 'processing', 'ready', 'failed'])->default('pending'); $table->timestamps(); }); diff --git a/database/migrations/2026_02_24_000000_add_visibility_to_videos_table.php b/database/migrations/2026_02_24_000000_add_visibility_to_videos_table.php new file mode 100644 index 0000000..8220656 --- /dev/null +++ b/database/migrations/2026_02_24_000000_add_visibility_to_videos_table.php @@ -0,0 +1,23 @@ +enum('visibility', ['public', 'unlisted', 'private'])->default('public')->after('status'); + }); + } + + public function down() + { + Schema::table('videos', function (Blueprint $table) { + $table->dropColumn('visibility'); + }); + } +}; + diff --git a/database/migrations/2026_02_24_233755_create_jobs_table.php b/database/migrations/2026_02_24_233755_create_jobs_table.php new file mode 100644 index 0000000..74ce86d --- /dev/null +++ b/database/migrations/2026_02_24_233755_create_jobs_table.php @@ -0,0 +1,27 @@ +id(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + } +}; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..18ee7cd --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,145 @@ +@extends('layouts.plain') + +@section('title', 'Login | TAKEONE') + +@section('extra_styles') + +@endsection + +@section('content') +
+
+ + +

Sign in

+ + @if($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+ @csrf + +
+ + +
+ +
+ + +
+ + +
+ + +
+
+@endsection + diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..32e5fe8 --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,155 @@ +@extends('layouts.plain') + +@section('title', 'Register | TAKEONE') + +@section('extra_styles') + +@endsection + +@section('content') +
+
+ + +

Create Account

+ + @if($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+ @csrf + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+
+@endsection + diff --git a/resources/views/emails/video-uploaded.blade.php b/resources/views/emails/video-uploaded.blade.php new file mode 100644 index 0000000..abb6789 --- /dev/null +++ b/resources/views/emails/video-uploaded.blade.php @@ -0,0 +1,34 @@ + + + + + + +
+
+

TAKEONE

+
+
+

Video Uploaded Successfully! 🎉

+

Hi {{ $video->user->name }},

+

Your video "{{ $video->title }}" has been uploaded successfully!

+

Your video is now being processed and will be available shortly.

+

Video Details:

+
    +
  • Size: {{ round($video->size / 1024 / 1024, 2) }} MB
  • +
  • Orientation: {{ $video->orientation }}
  • +
+

View Video

+
+ +
+ + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..6af5116 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,430 @@ + + + + + + @yield('title', 'TAKEONE') + + + + + @yield('extra_styles') + + + + @include('layouts.partials.header') + + +
+ + + @include('layouts.partials.sidebar') + + +
+ + + + @yield('content') +
+ + + @auth + @include('layouts.partials.upload-modal') + @endauth + + + + + @yield('scripts') + + + diff --git a/resources/views/layouts/partials/header.blade.php b/resources/views/layouts/partials/header.blade.php new file mode 100644 index 0000000..df47e4d --- /dev/null +++ b/resources/views/layouts/partials/header.blade.php @@ -0,0 +1,62 @@ + +
+
+ + +
+ +
+ + +
+ +
+ @auth + + + + + @else + + + + Login + + @endauth +
+
+ diff --git a/resources/views/layouts/partials/share-modal.blade.php b/resources/views/layouts/partials/share-modal.blade.php new file mode 100644 index 0000000..8beefee --- /dev/null +++ b/resources/views/layouts/partials/share-modal.blade.php @@ -0,0 +1,124 @@ + + + + + + + diff --git a/resources/views/layouts/partials/sidebar.blade.php b/resources/views/layouts/partials/sidebar.blade.php new file mode 100644 index 0000000..6e22ae0 --- /dev/null +++ b/resources/views/layouts/partials/sidebar.blade.php @@ -0,0 +1,37 @@ + + + diff --git a/resources/views/layouts/partials/upload-modal.blade.php b/resources/views/layouts/partials/upload-modal.blade.php new file mode 100644 index 0000000..6c68ba7 --- /dev/null +++ b/resources/views/layouts/partials/upload-modal.blade.php @@ -0,0 +1,1125 @@ + + + + + + + diff --git a/resources/views/layouts/plain.blade.php b/resources/views/layouts/plain.blade.php new file mode 100644 index 0000000..bdd70af --- /dev/null +++ b/resources/views/layouts/plain.blade.php @@ -0,0 +1,67 @@ + + + + + + @yield('title', 'TAKEONE') + + + + + @yield('extra_styles') + + + +
+ @yield('content') +
+ + + + @yield('scripts') + + + diff --git a/resources/views/user/channel.blade.php b/resources/views/user/channel.blade.php new file mode 100644 index 0000000..710464c --- /dev/null +++ b/resources/views/user/channel.blade.php @@ -0,0 +1,372 @@ +@extends('layouts.app') + +@section('title', $user->name . "'s Channel | TAKEONE") + +@section('extra_styles') + +@endsection + +@section('content') +
+
+ @if($user->avatar) + {{ $user->name }} + @else + {{ $user->name }} + @endif + +
+

{{ $user->name }}

+

Joined {{ $user->created_at->format('F d, Y') }}

+ +
+
+ {{ $videos->total() }} + videos +
+
+ {{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }} + views +
+
+ + @auth + @if(Auth::user()->id === $user->id) + + @endif + @endauth +
+
+
+ + @if($videos->isEmpty()) +
+ +

No videos yet

+

This channel hasn't uploaded any videos.

+ @auth + @if(Auth::user()->id === $user->id) + + Upload First Video + + @endif + @endauth +
+ @else +
+ @foreach($videos as $video) +
+ +
+ @if($video->thumbnail) + {{ $video->title }} + @else + {{ $video->title }} + @endif + + @if($video->duration) + {{ gmdate('i:s', $video->duration) }} + @endif +
+
+
+
+ @if($video->user && $video->user->avatar_url) + {{ $video->user->name }} + @endif +
+
+

+ {{ $video->title }} +

+
{{ $video->user->name ?? 'Unknown' }}
+
+ {{ number_format($video->size / 1024 / 1024, 0) }} MB • {{ $video->created_at->diffForHumans() }} +
+
+
+ + +
+
+
+ @endforeach +
+ +
{{ $videos->links() }}
+ @endif + + @include('layouts.partials.share-modal') +@endsection + +@section('scripts') + +@endsection + diff --git a/resources/views/user/history.blade.php b/resources/views/user/history.blade.php new file mode 100644 index 0000000..3cd9317 --- /dev/null +++ b/resources/views/user/history.blade.php @@ -0,0 +1,178 @@ +@extends('layouts.app') + +@section('title', 'Watch History | TAKEONE') + +@section('extra_styles') + +@endsection + +@section('content') +
+

Watch History

+
+ + @if($videos->isEmpty()) +
+ +

No watch history

+

Videos you watch will appear here.

+ + Browse Videos + +
+ @else +
+ @foreach($videos as $video) +
+ +
+ @if($video->thumbnail) + {{ $video->title }} + @else + {{ $video->title }} + @endif + @if($video->duration) + {{ gmdate('i:s', $video->duration) }} + @endif +
+
+
+
+ @if($video->user && $video->user->avatar_url) + {{ $video->user->name }} + @endif +
+
+

+ {{ $video->title }} +

+
+ {{ $video->user->name ?? 'Unknown' }} • {{ number_format($video->size / 1024 / 1024, 0) }} MB +
+
+
+
+ @endforeach +
+ @endif +@endsection + diff --git a/resources/views/user/liked.blade.php b/resources/views/user/liked.blade.php new file mode 100644 index 0000000..2fd17d5 --- /dev/null +++ b/resources/views/user/liked.blade.php @@ -0,0 +1,180 @@ +@extends('layouts.app') + +@section('title', 'Liked Videos | TAKEONE') + +@section('extra_styles') + +@endsection + +@section('content') +
+

Liked Videos

+
+ + @if($videos->isEmpty()) +
+ +

No liked videos

+

Videos you like will appear here.

+ + Browse Videos + +
+ @else +
+ @foreach($videos as $video) +
+ +
+ @if($video->thumbnail) + {{ $video->title }} + @else + {{ $video->title }} + @endif + @if($video->duration) + {{ gmdate('i:s', $video->duration) }} + @endif +
+
+
+
+ @if($video->user && $video->user->avatar_url) + {{ $video->user->name }} + @endif +
+
+

+ {{ $video->title }} +

+
+ {{ $video->user->name ?? 'Unknown' }} • {{ number_format($video->size / 1024 / 1024, 0) }} MB +
+
+
+
+ @endforeach +
+ +
{{ $videos->links() }}
+ @endif +@endsection + diff --git a/resources/views/user/profile.blade.php b/resources/views/user/profile.blade.php new file mode 100644 index 0000000..43ad22c --- /dev/null +++ b/resources/views/user/profile.blade.php @@ -0,0 +1,196 @@ +@extends('layouts.app') + +@section('title', 'Profile | TAKEONE') + +@section('extra_styles') + +@endsection + +@section('content') +
+ @if($user->avatar) + {{ $user->name }} + @else + {{ $user->name }} + @endif + +

{{ $user->name }}

+

{{ $user->email }}

+ +
+
+
{{ $user->videos->count() }}
+
Videos
+
+
+
{{ $user->likes->count() }}
+
Likes
+
+
+
{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}
+
Total Views
+
+
+
+ +
+
+
+

Edit Profile

+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + +
+ @csrf + @method('PUT') + +
+ + +
+ +
+ + + Max size: 5MB. Supported: JPG, PNG, WebP +
+ + +
+
+
+ +
+ +
+
+@endsection + diff --git a/resources/views/user/settings.blade.php b/resources/views/user/settings.blade.php new file mode 100644 index 0000000..fe2abe3 --- /dev/null +++ b/resources/views/user/settings.blade.php @@ -0,0 +1,163 @@ +@extends('layouts.app') + +@section('title', 'Settings | TAKEONE') + +@section('extra_styles') + +@endsection + +@section('content') +
+

Settings

+ +
+

Change Password

+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + @if($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+ @csrf + @method('PUT') + +
+ + +
+ +
+ + + Minimum 8 characters +
+ +
+ + +
+ + +
+
+ +
+

Account Info

+ +
+ + + Email cannot be changed +
+ +
+ + +
+
+ +
+

Quick Links

+ + + Edit Profile + + + + My Channel + +
+
+@endsection + diff --git a/resources/views/videos/create.blade.php b/resources/views/videos/create.blade.php index 99506be..4df85f9 100644 --- a/resources/views/videos/create.blade.php +++ b/resources/views/videos/create.blade.php @@ -1,109 +1,380 @@ - - - - - - Upload Video - TAKEONE - - - - -
-
- TAKEONE -
-
+@extends('layouts.app') -
-

Upload Video

+@section('title', 'Upload Video | TAKEONE') + +@section('extra_styles') + +@endsection + +@section('content') +
+

Upload Video

+ +
@csrf -
- +
+
-
- +
+
-
- -
- -
- - - -

Click to select or drag video here

-

MP4, MOV, AVI, WebM up to 500MB

+
+
+ +
+ +
+ +

Click to select or drag video here

+

MP4, MOV, AVI, WebM up to 2GB

+
+
+

+

+
- + +
+ +
+ +
+ +

Click to select or drag thumbnail

+

JPG, PNG, WebP up to 5MB

+
+
+

+

+
-
- - -
- -
- - +
+ +
+ + + +
-
- + +@endsection + +@section('scripts') - - +@endsection + diff --git a/resources/views/videos/edit.blade.php b/resources/views/videos/edit.blade.php index 85ab32c..29aa11f 100644 --- a/resources/views/videos/edit.blade.php +++ b/resources/views/videos/edit.blade.php @@ -1,69 +1,278 @@ - - - - - - Edit {{ $video->title }} - TAKEONE - - - - -
-
- TAKEONE -
-
+@extends('layouts.app') -
-

Edit Video

+@section('title', 'Edit ' . $video->title . ' | TAKEONE') + +@section('extra_styles') + +@endsection + +@section('content') +
+

Edit Video

+ +
@csrf @method('PUT') -
- - +
+ +
-
- - +
+ +
-
- -
- @foreach(['landscape', 'portrait', 'square', 'ultrawide'] as $orientation) -
- - + +@endsection + +@section('scripts') + +@endsection + diff --git a/resources/views/videos/index.blade.php b/resources/views/videos/index.blade.php index 23df4a0..4797f21 100644 --- a/resources/views/videos/index.blade.php +++ b/resources/views/videos/index.blade.php @@ -1,237 +1,32 @@ - - - - - - TAKEONE | Video Gallery - - +@extends('layouts.app') + +@section('title', isset($query) ? 'Search: ' . $query . ' | TAKEONE' : 'Video Gallery | TAKEONE') + +@section('extra_styles') - - - -
-
- - +@endsection + +@section('content') + @isset($query) +
+

Search results for "{{ $query }}"

+

{{ $videos->total() }} videos found

- -
- - -
- -
- - - Upload - - - - - - User -
-
+ @endif - -
- - - - - -
- - - - @if($videos->isEmpty()) -
- + @if($videos->isEmpty()) +
+ + @isset($query) +

No results found

+

Try different keywords or browse all videos.

+ @else

No videos yet

Be the first to upload a video!

+ @endisset + @auth Upload Video -
- @else -
- @foreach($videos as $video) -
- -
- @if($video->thumbnail) - {{ $video->title }} - @else - {{ $video->title }} - @endif - @if($video->duration) - {{ gmdate('i:s', $video->duration) }} - @endif -
-
-
-
-
-

- {{ $video->title }} -

-
TAKEONE
-
- {{ number_format($video->size / 1024 / 1024, 0) }} MB • {{ $video->created_at->diffForHumans() }} -
-
-
- - + @else + + + Login to Upload + + @endauth +
+ @else +
+ @foreach($videos as $video) +
+ +
+ @if($video->thumbnail) + {{ $video->title }} + @else + {{ $video->title }} + @endif + + @if($video->duration) + {{ gmdate('i:s', $video->duration) }} + @endif +
+
+
+
+ @if($video->user && $video->user->avatar_url) + {{ $video->user->name }} + @endif +
+
+

+ {{ $video->title }} +

+
{{ $video->user->name ?? 'Unknown' }}
+
+ {{ number_format($video->size / 1024 / 1024, 0) }} MB • {{ $video->created_at->diffForHumans() }}
+
+ + +
- @endforeach
- -
{{ $videos->links() }}
- @endif -
+ @endforeach + + +
{{ $videos->links() }}
+ @endif + + @include('layouts.partials.share-modal') + @include('layouts.partials.upload-modal') +@endsection + +@section('scripts') + +@endsection - - - - diff --git a/resources/views/videos/show.blade.php b/resources/views/videos/show.blade.php index 5a911cd..8fd2ad0 100644 --- a/resources/views/videos/show.blade.php +++ b/resources/views/videos/show.blade.php @@ -1,105 +1,9 @@ - - - - - - {{ $video->title }} | TAKEONE - - +@extends('layouts.app') + +@section('title', $video->title . ' | TAKEONE') + +@section('extra_styles') - - - -
- - -
- -
- - -
- - -
+@endsection + +@section('content') + +
@@ -325,22 +208,38 @@ @endif
- - - + @auth +
+ @csrf + +
+ @else + + Like + + @endauth + @if($video->isShareable()) + + @endif
@@ -352,11 +251,13 @@ -
+

Up Next

More videos coming soon...
-
- - + + + @include('layouts.partials.share-modal') +@endsection + diff --git a/routes/auth.php b/routes/auth.php new file mode 100644 index 0000000..4a601de --- /dev/null +++ b/routes/auth.php @@ -0,0 +1,17 @@ +group(function () { + Route::get('register', [RegisteredUserController::class, 'create'])->name('register'); + Route::post('register', [RegisteredUserController::class, 'store'])->name('register.store'); + + Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login'); + Route::post('login', [AuthenticatedSessionController::class, 'store'])->name('login.store'); +}); + +Route::middleware('auth')->group(function () { + Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout'); +}); diff --git a/routes/web.php b/routes/web.php index 63d24f9..b69975d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,17 +2,52 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\VideoController; +use App\Http\Controllers\UserController; +// Redirect root to videos Route::get('/', function () { return redirect('/videos'); }); +// Video routes - public Route::get('/videos', [VideoController::class, 'index'])->name('videos.index'); +Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search'); Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create'); -Route::post('/videos', [VideoController::class, 'store'])->name('videos.store'); Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show'); -Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit'); -Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update'); Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream'); -Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy'); + +// Video routes - auth required +Route::middleware('auth')->group(function () { + Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create'); + Route::post('/videos', [VideoController::class, 'store'])->name('videos.store'); + Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit'); + Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update'); + Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy'); + + // Like/unlike routes + Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like'); + Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike'); + Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike'); +}); + +// User routes +Route::middleware('auth')->group(function () { + // Profile + Route::get('/profile', [UserController::class, 'profile'])->name('profile'); + Route::put('/profile', [UserController::class, 'updateProfile'])->name('profile.update'); + + // Settings + Route::get('/settings', [UserController::class, 'settings'])->name('settings'); + Route::put('/settings', [UserController::class, 'updateSettings'])->name('settings.update'); + + // History & Liked + Route::get('/history', [UserController::class, 'history'])->name('history'); + Route::get('/liked', [UserController::class, 'liked'])->name('liked'); +}); + +// Channel - public for viewing, own channel requires auth +Route::get('/channel/{userId?}', [UserController::class, 'channel'])->name('channel'); + +// Authentication Routes +require __DIR__.'/auth.php';