Add NAS file manager integration and all pending platform changes
- Installed p7h/nas-file-manager package via private VCS repo - Published config/nas-file-manager.php with super_admin middleware restriction - Added NAS env vars to .env.example - Created admin/nas-storage page with connection info panel and file browser widget - Added NAS Storage link to admin sidebar (super_admin only) - Added SuperAdminController@nasStorage method and admin.nas-storage route - Includes all accumulated branch changes: profile wall, 2FA, audit logs, settings panel, country/phone/timezone components, posts, slideshow, playlist shares, video downloads/shares, comment likes, notifications, social links, and more Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
d44490dfe0
commit
0b2e95ea65
161 files changed
+27496
-7250
No files matched your search
@@ -2,13 +2,15 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\Horoscope;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
@@ -17,7 +19,7 @@ class UserController extends Controller
|
||||
$this->middleware('auth')->except(['channel']);
|
||||
}
|
||||
|
||||
// Profile page - view own profile
|
||||
// Profile page - personal overview for the authenticated user
|
||||
public function profile()
|
||||
{
|
||||
$user = Auth::user();
|
||||
@@ -28,58 +30,83 @@ class UserController extends Controller
|
||||
// Update profile
|
||||
public function updateProfile(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$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',
|
||||
'website' => 'nullable|string|max:255',
|
||||
'twitter' => 'nullable|string|max:100',
|
||||
'instagram' => 'nullable|string|max:100',
|
||||
'facebook' => 'nullable|string|max:100',
|
||||
'youtube' => 'nullable|string|max:100',
|
||||
'linkedin' => 'nullable|string|max:100',
|
||||
'tiktok' => 'nullable|string|max:100',
|
||||
'birthday' => 'nullable|date',
|
||||
'location' => 'nullable|string|max:100',
|
||||
'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,
|
||||
'website' => $request->website,
|
||||
'twitter' => $request->twitter,
|
||||
'instagram' => $request->instagram,
|
||||
'facebook' => $request->facebook,
|
||||
'youtube' => $request->youtube,
|
||||
'linkedin' => $request->linkedin,
|
||||
'tiktok' => $request->tiktok,
|
||||
'birthday' => $request->birthday,
|
||||
'location' => $request->location,
|
||||
'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,
|
||||
];
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
// Delete old avatar
|
||||
if ($user->avatar) {
|
||||
Storage::delete('public/avatars/'.$user->avatar);
|
||||
}
|
||||
$filename = Str::uuid().'.'.$request->file('avatar')->getClientOriginalExtension();
|
||||
$filename = self::generateFilename($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!');
|
||||
// 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
|
||||
// Settings page - redirects to channel settings tab
|
||||
public function settings()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return view('user.settings', compact('user'));
|
||||
return redirect()->route('channel')->with('_open_tab', 'settings');
|
||||
}
|
||||
|
||||
// Update settings (password)
|
||||
@@ -100,36 +127,121 @@ class UserController extends Controller
|
||||
'password' => Hash::make($request->new_password),
|
||||
]);
|
||||
|
||||
return redirect()->route('settings')->with('success', 'Password updated successfully!');
|
||||
AuditLog::record('user.password_changed');
|
||||
|
||||
return redirect()->route('channel')->with('toast_success', 'Password updated!')->with('_open_tab', 'settings');
|
||||
}
|
||||
|
||||
// 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($userId = null)
|
||||
public function channel($username = null)
|
||||
{
|
||||
if ($userId) {
|
||||
$user = User::findOrFail($userId);
|
||||
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
|
||||
}
|
||||
|
||||
// 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);
|
||||
$sort = request('sort', 'latest');
|
||||
$isOwner = Auth::check() && Auth::user()->id === $user->id;
|
||||
$preview = $isOwner && request()->boolean('preview'); // owner viewing as visitor
|
||||
$isOwner = $isOwner && !$preview;
|
||||
|
||||
// Also get user's playlists for their own channel
|
||||
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
|
||||
} else {
|
||||
$videos = Video::public()
|
||||
->where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
$playlists = null;
|
||||
$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();
|
||||
}
|
||||
|
||||
return view('user.channel', compact('user', 'videos', 'playlists'));
|
||||
$videos = $baseQuery->where('is_shorts', false)->get();
|
||||
$shorts = (clone $allQuery)->where('is_shorts', true)->latest()->get();
|
||||
$playlists = $isOwner
|
||||
? $user->playlists()->orderBy('created_at', 'desc')->get()
|
||||
: $user->playlists()->public()->where('is_default', false)->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
|
||||
@@ -151,13 +263,23 @@ class UserController extends Controller
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->get()
|
||||
->sortByDesc(function ($video) use ($videoIds) {
|
||||
->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()
|
||||
{
|
||||
@@ -214,4 +336,96 @@ class UserController extends Controller
|
||||
'like_count' => $video->like_count,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleSubscribe(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 {
|
||||
$me->subscriptions()->attach($user->id);
|
||||
$subscribed = true;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'subscribed' => $subscribed,
|
||||
'subscriber_count' => $user->fresh()->subscriber_count,
|
||||
]);
|
||||
}
|
||||
|
||||
public function fetchNotifications()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$rawNotifications = $user->notifications()->latest()->take(50)->get();
|
||||
|
||||
// Bulk-fetch current video state for all notification types
|
||||
$videoIds = $rawNotifications
|
||||
->pluck('data.video_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$videos = \App\Models\Video::whereIn('id', $videoIds)
|
||||
->whereIn('visibility', ['public', 'unlisted'])
|
||||
->get(['id', 'thumbnail', 'visibility'])
|
||||
->keyBy('id');
|
||||
|
||||
$notifications = $rawNotifications
|
||||
->filter(function ($n) use ($videos) {
|
||||
$videoId = $n->data['video_id'] ?? null;
|
||||
return $videoId && $videos->has($videoId);
|
||||
})
|
||||
->take(30)
|
||||
->map(function ($n) use ($videos) {
|
||||
$data = $n->data;
|
||||
$video = $videos->get($data['video_id']);
|
||||
$data['video_thumbnail'] = $video?->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']);
|
||||
Auth::user()->update(['avatar' => basename($request->path)]);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function updateBanner(Request $request)
|
||||
{
|
||||
$request->validate(['path' => 'required|string|max:300']);
|
||||
Auth::user()->update(['banner' => basename($request->path)]);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user