middleware('auth')->except(['channel']); } // Typeahead search for members (used by the "Share to member" picker) public function searchUsers(Request $request) { $q = trim((string) $request->query('q', '')); if (mb_strlen($q) < 1) { return response()->json(['users' => []]); } $users = User::where('id', '!=', Auth::id()) ->where(function ($w) use ($q) { $w->where('name', 'like', "%{$q}%") ->orWhere('username', 'like', "%{$q}%"); }) ->orderBy('name') ->limit(8) ->get(['id', 'name', 'username', 'avatar']); return response()->json([ 'users' => $users->map(fn ($u) => [ 'id' => $u->id, 'name' => $u->name, 'channel' => $u->channel, 'avatar' => $u->avatar_url, ]), ]); } // Profile page - personal overview for the authenticated user public function profile() { $user = Auth::user(); return view('user.profile', compact('user')); } // Update profile public function updateProfile(Request $request) { $authUser = Auth::user(); // Super admins may edit any user's profile by passing _edit_user_id if ($authUser->isSuperAdmin() && $request->filled('_edit_user_id')) { $user = User::findOrFail($request->input('_edit_user_id')); } else { $user = $authUser; } $request->validate([ 'name' => 'required|string|max:255', 'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120', 'bio' => 'nullable|string|max:500', 'birthday' => 'nullable|date', 'location' => 'nullable|string|max:100', 'gender' => 'nullable|in:male,female,prefer_not_to_say', 'nationality' => 'nullable|string|size:2', 'phone_code' => 'nullable|string|max:20', 'phone_number' => 'nullable|string|max:30', 'timezone' => 'nullable|timezone:all', 'slink' => 'nullable|array', 'slink.*.platform' => 'required_with:slink|string|max:30', 'slink.*.value' => 'required_with:slink|string|max:500', 'slink.*.visibility' => 'nullable|in:public,registered,subscribers,only_me', ]); $data = [ 'name' => $request->name, 'bio' => $request->bio, 'birthday' => $request->birthday, 'location' => $request->location, 'gender' => $request->gender ?: null, 'nationality' => $request->nationality ?: null, 'phone_code' => $request->phone_code ?: null, 'phone_number' => $request->phone_number ?: null, 'timezone' => $request->timezone ?: null, ]; $nas = app(\App\Services\NasSyncService::class); if ($request->hasFile('avatar')) { // Delete old avatar (handles both flat and new relative-path formats) $nas->deleteLocalAvatar($user); $ext = $request->file('avatar')->getClientOriginalExtension() ?: 'webp'; $profileDir = $nas->localProfileDir($user); $destFilename = "avatar.{$ext}"; $destPath = "{$profileDir}/{$destFilename}"; $relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}"; @mkdir($profileDir, 0755, true); $request->file('avatar')->move($profileDir, $destFilename); $data['avatar'] = $relPath; } $user->update($data); // Push avatar to NAS and remove local copy when NAS is primary storage if ($nas->isEnabled()) { if ($request->hasFile('avatar')) { $destPath = storage_path('app/' . $data['avatar']); if (file_exists($destPath)) { $nas->syncAvatar($user, $destPath); $nas->deleteLocalAvatar($user); } } } // Sync social links $user->socialLinks()->delete(); $order = 0; foreach ($request->input('slink', []) as $entry) { $platform = trim($entry['platform'] ?? ''); $value = trim($entry['value'] ?? ''); if ($platform && $value) { $user->socialLinks()->create([ 'platform' => $platform, 'value' => $value, 'visibility' => $entry['visibility'] ?? 'public', 'sort_order' => $order++, ]); } } // Redirect back to profile page, or channel settings if admin edited another user if ($authUser->isSuperAdmin() && $authUser->id !== $user->id) { return redirect()->route('channel', $user->channel)->with('toast_success', 'Profile updated!')->with('_open_tab', 'settings'); } return redirect()->route('profile')->with('toast_success', 'Profile updated!'); } // Settings page - redirects to channel settings tab public function settings() { return redirect()->route('channel')->with('_open_tab', 'settings'); } // 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), ]); AuditLog::record('user.password_changed'); return redirect()->route('channel')->with('toast_success', 'Password updated!')->with('_open_tab', 'settings'); } // Save a single notification preference toggle (AJAX) public function updateNotificationPreferences(Request $request) { $request->validate([ 'key' => ['required', 'string', 'in:' . implode(',', array_keys(User::notifDefaults()))], 'value' => ['required', 'boolean'], ]); $user = Auth::user(); $prefs = $user->notification_preferences ?? []; $prefs[$request->key] = (bool) $request->value; $user->update(['notification_preferences' => $prefs]); return response()->json(['ok' => true]); } // Logout all other devices public function logoutAllDevices(Request $request) { $request->validate(['password' => 'required']); if (! Hash::check($request->password, Auth::user()->password)) { return back()->withErrors(['logout_password' => 'Incorrect password.'])->with('_open_tab', 'settings'); } Auth::logoutOtherDevices($request->password); // AuthenticateSession stores a hash of the password; logoutOtherDevices rehashes it, // so we must update the session's copy or the current session gets invalidated too. $request->session()->put( 'password_hash_' . Auth::getDefaultDriver(), Auth::user()->getAuthPassword() ); AuditLog::record('user.logout_all'); return redirect()->route('channel')->with('toast_success', 'All other sessions have been logged out.')->with('_open_tab', 'settings'); } // User's channel page - view videos public function channel($username = null) { if ($username) { // Look up by username slug only — never by sequential ID $user = User::where('username', $username)->firstOrFail(); } else { $user = Auth::user(); if (! $user) { return redirect()->route('login'); } $user->channel; // triggers auto-generation if missing } $sort = request('sort', 'latest'); $isOwner = Auth::check() && Auth::user()->id === $user->id; $preview = $isOwner && request()->boolean('preview'); // owner viewing as visitor $isOwner = $isOwner && !$preview; $baseQuery = $isOwner ? Video::where('user_id', $user->id) : Video::public()->where('user_id', $user->id); $allQuery = clone $baseQuery; switch ($sort) { case 'popular': $baseQuery->withCount('viewers')->orderByDesc('viewers_count'); break; case 'oldest': $baseQuery->oldest(); break; default: $baseQuery->latest(); } $videos = $baseQuery->where('is_shorts', false)->get(); $shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get(); $withFirstVideo = fn($q) => $q->orderBy('playlist_videos.position')->limit(1); $playlists = $isOwner ? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get() : $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get(); $totalViews = \DB::table('video_views') ->whereIn('video_id', $user->videos()->pluck('id')) ->count(); // Filter social links by visibility for the current viewer $viewer = Auth::user(); $isOwner = $viewer && $viewer->id === $user->id; $isSubscriber = $viewer && !$isOwner && $viewer->isSubscribedTo($user); $socialLinks = $user->socialLinks() ->orderBy('sort_order') ->get() ->filter(function ($link) use ($isOwner, $viewer, $isSubscriber) { if ($isOwner) return true; return match ($link->visibility) { 'public' => true, 'registered' => (bool) $viewer, 'subscribers' => $viewer && $isSubscriber, 'only_me' => false, default => false, }; }); // Posts for the Wall tab $posts = Post::where('user_id', $user->id) ->with(['user', 'video', 'reactions', 'postImages', 'postVideos.video']) ->latest() ->get(); // Horoscope $horoscope = Horoscope::getSign($user->birthday); $viewerSign = null; $compatibility = null; if ($viewer && ! $isOwner && $viewer->birthday) { $viewerSign = Horoscope::getSign($viewer->birthday); $compatibility = Horoscope::compatibility($horoscope, $viewerSign); } $canEdit = $isOwner || (Auth::check() && Auth::user()->isSuperAdmin()); return view('user.channel', compact( 'user', 'videos', 'shorts', 'playlists', 'totalViews', 'sort', 'socialLinks', 'isSubscriber', 'isOwner', 'canEdit', 'posts', 'horoscope', 'viewerSign', 'compatibility', 'preview' )); } // 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() ->sortBy(function ($video) use ($videoIds) { return $videoIds->search($video->id); }); return view('user.history', compact('videos')); } // Clear watch history public function clearHistory() { \DB::table('video_views') ->where('user_id', Auth::id()) ->delete(); return redirect()->route('history')->with('toast_success', 'Watch history cleared.'); } // 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; if ($video->user_id && $video->user_id !== $user->id) { try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {} } } return response()->json([ 'liked' => $liked, 'like_count' => $video->like_count, ]); } public function recordProfileVisit(Request $request, User $user) { // Don't record self-visits or repeated visits from the same person in the last 30 minutes $visitorId = Auth::id(); $deviceId = $request->cookie('_did'); if ($visitorId && $visitorId === $user->id) { return response()->json(['ok' => true, 'skipped' => 'self']); } $sourceVideoId = $request->integer('source_video_id') ?: null; if ($sourceVideoId && ! Video::whereKey($sourceVideoId)->exists()) { $sourceVideoId = null; } $dedup = \App\Models\ProfileVisit::where('profile_user_id', $user->id) ->where('created_at', '>=', now()->subMinutes(30)) ->when($visitorId, fn ($q) => $q->where('visitor_user_id', $visitorId)) ->when(! $visitorId && $deviceId, fn ($q) => $q->whereNull('visitor_user_id')->where('device_id', $deviceId)) ->when($sourceVideoId, fn ($q) => $q->where('source_video_id', $sourceVideoId)) ->exists(); if ($dedup) { return response()->json(['ok' => true, 'skipped' => 'dedup']); } $ip = $request->header('CF-Connecting-IP') ?? $request->header('X-Real-IP') ?? $request->ip(); $geo = \App\Services\GeoIpService::lookup($ip); \App\Models\ProfileVisit::create([ 'profile_user_id' => $user->id, 'visitor_user_id' => $visitorId, 'device_id' => $deviceId, 'source_video_id' => $sourceVideoId, 'ip_address' => $ip, 'country' => $geo['country'] ?? null, 'created_at' => now(), ]); return response()->json(['ok' => true]); } public function toggleSubscribe(Request $request, User $user) { $me = Auth::user(); if ($me->id === $user->id) { return response()->json(['error' => 'Cannot subscribe to yourself'], 422); } if ($me->isSubscribedTo($user)) { $me->subscriptions()->detach($user->id); $subscribed = false; } else { $sourceVideoId = $request->integer('source_video_id') ?: null; if ($sourceVideoId && ! \App\Models\Video::whereKey($sourceVideoId)->exists()) { $sourceVideoId = null; } $me->subscriptions()->attach($user->id, ['source_video_id' => $sourceVideoId]); $subscribed = true; try { $user->notify(new NewSubscriberNotification($me)); } catch (\Throwable) {} } return response()->json([ 'subscribed' => $subscribed, 'subscriber_count' => $user->fresh()->subscriber_count, ]); } public function notificationCount() { return response()->json(['unread_count' => Auth::user()->unreadNotifications()->count()]); } public function fetchNotifications() { $user = Auth::user(); $rawNotifications = $user->notifications()->latest()->take(50)->get(); // Bulk-fetch video state only for video-linked notifications $videoIds = $rawNotifications ->pluck('data.video_id') ->filter() ->unique() ->values(); $videos = $videoIds->isNotEmpty() ? \App\Models\Video::whereIn('id', $videoIds) ->whereIn('visibility', ['public', 'unlisted']) ->get(['id', 'thumbnail', 'visibility']) ->keyBy('id') : collect(); $notifications = $rawNotifications ->filter(function ($n) use ($videos) { $videoId = $n->data['video_id'] ?? null; // Non-video notifications (subscriber, like, post, new_user) always pass if (!$videoId) return true; // Video notifications only if the video is still visible return $videos->has($videoId); }) ->take(30) ->map(function ($n) use ($videos) { $data = $n->data; if (!empty($data['video_id'])) { $data['video_thumbnail'] = $videos->get($data['video_id'])?->thumbnail ?? null; } return [ 'id' => $n->id, 'read' => ! is_null($n->read_at), 'time' => $n->created_at->diffForHumans(), 'data' => $data, ]; }) ->values(); return response()->json([ 'notifications' => $notifications, 'unread_count' => $user->unreadNotifications()->count(), ]); } public function markNotificationRead(string $id) { $notification = Auth::user()->notifications()->findOrFail($id); $notification->markAsRead(); return response()->json(['ok' => true]); } public function markAllNotificationsRead() { Auth::user()->unreadNotifications->markAsRead(); return response()->json(['ok' => true]); } public function updateAvatar(Request $request) { $request->validate(['path' => 'required|string|max:300']); $user = Auth::user(); $filename = basename($request->path); $tempPath = storage_path('app/public/avatars/' . $filename); $nas = app(\App\Services\NasSyncService::class); // Move temp file into the user's profile directory $profileDir = $nas->localProfileDir($user); $ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp'; $destFilename = "avatar.{$ext}"; $destPath = "{$profileDir}/{$destFilename}"; $relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}"; // Delete old avatar before moving new one in (handles both path formats) $nas->deleteLocalAvatar($user); @mkdir($profileDir, 0755, true); if (file_exists($tempPath)) { rename($tempPath, $destPath); } $user->update(['avatar' => $relPath]); if ($nas->isEnabled() && file_exists($destPath)) { $nas->syncAvatar($user, $destPath); $nas->deleteLocalAvatar($user); } return response()->json(['ok' => true]); } public function updateBanner(Request $request) { $request->validate(['path' => 'required|string|max:300']); $user = Auth::user(); $filename = basename($request->path); $tempPath = storage_path('app/public/banners/' . $filename); $nas = app(\App\Services\NasSyncService::class); // Move temp file into the user's profile directory $profileDir = $nas->localProfileDir($user); $ext = pathinfo($filename, PATHINFO_EXTENSION) ?: 'webp'; $destFilename = "cover.{$ext}"; $destPath = "{$profileDir}/{$destFilename}"; $relPath = 'users/' . $nas->userSlug($user) . "/profile/{$destFilename}"; // Delete old banner before moving new one in (handles both path formats) $nas->deleteLocalBanner($user); @mkdir($profileDir, 0755, true); if (file_exists($tempPath)) { rename($tempPath, $destPath); } $user->update(['banner' => $relPath]); if ($nas->isEnabled() && file_exists($destPath)) { $nas->syncCover($user, $destPath); $nas->deleteLocalBanner($user); } return response()->json(['ok' => true]); } }