diff --git a/app/Http/Controllers/AchievementController.php b/app/Http/Controllers/AchievementController.php deleted file mode 100644 index 2904358..0000000 --- a/app/Http/Controllers/AchievementController.php +++ /dev/null @@ -1,115 +0,0 @@ -get() - ->groupBy(fn(Achievement $a) => $a->category->name) - ->map(fn($group) => $group->sortBy('sort_order')->values()); - - $userAchievements = $user->achievements() - ->with('achievement') - ->select(['achievement_id', 'progress']) - ->orderBy('achievement_id') - ->get() - ->keyBy('achievement_id'); - - $unlockedByCategory = $achievements->map(fn($group) => - $group->filter(fn($a) => $userAchievements->get($a->id)?->unlocked)->count() - ); - - $unlockedCount = $userAchievements->filter(fn($ua) => $ua->unlocked)->count(); - - return Inertia::render('UserAchievements', [ - 'user' => $user, - 'canEdit' => auth()->id() === $user->id, - 'isFollowing' => auth()->check() && auth()->user()->isFollowing($user), - 'achievements' => $achievements, - 'userAchievements' => $userAchievements, - 'loggedInUser' => auth()->user(), - 'unlockedCount' => $unlockedCount, - 'unlockedByCategory' => $unlockedByCategory, - 'totalAchievements' => $achievements->flatten()->count(), - ]); - } - - function getRegionsByCountryCode(string $countryCode) - { - return Country::whereCode($countryCode) - ->first() - ->regions() - ->orderBy('name') - ->get() - ->toArray(); - } - - public function specific(User $user, Achievement $achievement) - { - $regions = match($achievement->internal_name){ - 'fun_challenges.australian_states' => $this->getRegionsByCountryCode('AU'), - 'fun_challenges.chinese_provinces' => $this->getRegionsByCountryCode('CN'), - 'fun_challenges.canadian_provinces' => $this->getRegionsByCountryCode('CA'), - 'fun_challenges.brazilian_states' => $this->getRegionsByCountryCode('BR'), - 'fun_challenges.us_states' => $this->getRegionsByCountryCode('US'), - default => [], - }; - - $allianceInternalName = match($achievement->internal_name){ - 'airlines_alliances.all_star_alliance' => 'star_alliance', - 'airlines_alliances.all_oneworld' => 'oneworld', - 'airlines_alliances.all_skyteam' => 'skyteam', - 'airlines_alliances.all_vanilla_alliance' => 'vanilla_alliance', - default => null, - }; - - $continents = match($achievement->internal_name){ - 'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(), - default => [], - }; - - $alliance = null; - $airlines = []; - - if ($allianceInternalName) { - $alliance = Alliance::where('internal_name', $allianceInternalName) - ->with('airlines') - ->firstOrFail(); - $airlines = $alliance->airlines()->with('country')->orderBy('name')->get(); - } - - $aircraftFamilies = match($achievement->internal_name){ - 'aircraft.all_boeing_7x7' => Aircraft::BOEING_FAMILIES, - 'aircraft.all_airbus_a3xx' => Aircraft::AIRBUS_FAMILIES, - default => [], - }; - - return Inertia::render('Profile/UserAchievement', [ - 'user' => $user, - 'achievement' => $achievement, - 'loggedInUser' => auth()->user(), - 'userAchievement' => $user->achievements()->where('achievement_id', $achievement->id)->first(), - 'isFollowing' => auth()->check() && auth()->user()->isFollowing($user), - 'flight_api_url' => FlightProfileController::getUserFlightApiURL($user), - 'regions' => $regions, - 'alliance' => $alliance, - 'airlines' => $airlines, - 'continents' => $continents, - 'aircraft_families' => $aircraftFamilies, - 'achievementCount' => $user->unlockedAchievements()->count(), - ]); - } - -} diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php index 349dadf..6cbffbe 100644 --- a/app/Http/Controllers/Api/UserApiController.php +++ b/app/Http/Controllers/Api/UserApiController.php @@ -50,14 +50,4 @@ class UserApiController extends ApiController ]); } - public function flights(string $username, Request $request): JsonResponse - { - $user = User::where('name', 'ilike', $username)->first(); - - if (!$user) { - return response()->json(['message' => 'User not found'], 404); - } - - return response()->json($user->FlightController()->flights($request)); - } } diff --git a/app/Http/Controllers/FlightProfileController.php b/app/Http/Controllers/FlightProfileController.php deleted file mode 100644 index ea07a43..0000000 --- a/app/Http/Controllers/FlightProfileController.php +++ /dev/null @@ -1,100 +0,0 @@ -check()) { - $user = auth()->user(); - $defaultPage = $user->resolved_settings['default_login_page']; - - $route = match ($defaultPage) { - 'feed_first' => $user->following()->count() > 0 ? 'feed' : 'profile.view', - 'feed' => 'feed', - 'profile' => 'profile.view', - 'dashboard' => 'dashboard', - }; - - $args = $route == 'profile.view' ? $user->name : null; - - return redirect()->route($route, $args); - } - return redirect()->route('login'); - } - - public static function getUserFlightApiURL(User $user){ - return '/data/user/'.$user->name.'/flights'; - } - - public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array { - return [ - 'user' => $user, - 'canEdit' => (auth()->check() && auth()->id() === $user->id) || (auth()->check() && auth()->user()->hasRole('admin')), - 'initialView' => $view, - 'selectedFlightId' => $selectedFlightId, - 'flight_api_url' => self::getUserFlightApiURL($user), - 'isFollowing' => auth()->check() && auth()->user()->isFollowing($user), - 'flightCount' => $user->departedFlights()->count(), - ]; - } - - public function departureBoard(User $user, ?UserFlight $flight = null){ - $profileData = $this->profileData($user, 'board', $flight?->id); - return Inertia::render('UserProfile', $profileData); - } - - public function map(User $user){ - $profileData = $this->profileData($user, 'map'); - return Inertia::render('UserProfile', $profileData); - } - - public function boardingPasses(User $user){ - $profileData = $this->profileData($user, 'passes'); - return Inertia::render('UserProfile', $profileData); - } - - public function view(User $user) - { - $loggedInUser = auth()->user(); - - $isPrivate = $user->resolved_settings['private_profile']; - - if ($isPrivate && $user->id !== $loggedInUser?->id) { - if (!$loggedInUser || !$loggedInUser->isFollowing($user)) { - abort(404); - } - } - - $defaultView = $loggedInUser ? $loggedInUser->resolved_settings['default_profile_view'] : 'map'; - - return match($defaultView) { - 'boarding-passes' => $this->boardingPasses($user), - 'map' => $this->map($user), - 'departure-board' => $this->departureBoard($user), - 'achievements' => redirect()->route('profile.achievements', $user->name), - }; - - } - - public function flight(User $user, UserFlight $userFlight) - { - if($userFlight->user_id !== $user->id){ - abort(404); - } - - return Inertia::render('UserFlight', [ - 'flightCount' => $user->departedFlights()->count(), - 'flight' => $userFlight->snapshot($userFlight->id), - 'canEdit' => auth()->check() && auth()->id() === $user->id, - 'user' => $user, - 'isFollowing' => auth()->check() && auth()->user()->isFollowing($user), - ]); - } -} diff --git a/app/Http/Controllers/FollowerController.php b/app/Http/Controllers/FollowerController.php new file mode 100644 index 0000000..0e59ccf --- /dev/null +++ b/app/Http/Controllers/FollowerController.php @@ -0,0 +1,66 @@ +where('followee_id', auth()->id()) + ->orderBy('verified') // unverified first + ->get() + ->map(fn (Followee $f) => [ + 'user' => $f->user, + 'verified' => $f->verified, + ]); + + return response()->json($followers); + } + + public function approve(User $follower): JsonResponse + { + $followee = Followee::where('user_id', $follower->id) + ->where('followee_id', auth()->id()) + ->pending() + ->firstOrFail(); + + $followee->update(['verified' => true]); + + Notification::create([ + 'user_id' => $follower->id, + 'title' => 'Follow request accepted', + 'body' => auth()->user()->name . ' accepted your follow request.', + 'is_achievement' => false, + 'url' => '/u/' . auth()->user()->name, + ]); + + return response()->json(['status' => 'approved']); + } + + public function deny(User $follower): JsonResponse + { + Followee::where('user_id', $follower->id) + ->where('followee_id', auth()->id()) + ->pending() + ->delete(); + + return response()->json(['status' => 'denied']); + } + + public function remove(User $follower): JsonResponse + { + Followee::where('user_id', $follower->id) + ->where('followee_id', auth()->id()) + ->verified() + ->delete(); + + return response()->json(['status' => 'removed']); + } +} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index c5d8540..97e2dff 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -26,4 +26,15 @@ class SettingsController extends Controller $request->user()->updateSettings($validated['settings']); return response()->json(['message' => 'Settings saved.']); } + + public function updateSingle(Request $request, string $key) + { + $validated = $request->validate([ + 'value' => ['required'], + ]); + + $request->user()->updateSetting($key, $validated['value']); + + return response()->json(['message' => 'Setting saved.']); + } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 80bd64b..351568c 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -9,38 +9,77 @@ use App\Settings\SettingsRegistry; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; use Inertia\Inertia; class UserController extends Controller { public function follow(User $user): JsonResponse { + abort_if($user->id === auth()->id(), 403); + $existing = Followee::where('user_id', auth()->id()) ->where('followee_id', $user->id) ->first(); if ($existing) { $existing->delete(); - return response()->json(['following' => false]); + return response()->json(['status' => 'none']); } + $canView = Gate::allows('viewProfileData', $user); + Followee::create([ 'user_id' => auth()->id(), 'followee_id' => $user->id, + 'verified' => $canView, ]); Notification::create([ - 'user_id' => $user->id, - 'title' => 'New follower', - 'body' => auth()->user()->name . ' is now following you.', + 'user_id' => $user->id, + 'title' => $canView ? 'New follower' : 'Follow request', + 'body' => $canView + ? auth()->user()->name . ' is now following you.' + : auth()->user()->name . ' wants to follow you.', 'is_achievement' => false, - 'url' => '/u/'. auth()->user()->name, + 'url' => $canView ? '/u/' . auth()->user()->name : '/follow-requests', ]); - return response()->json(['following' => true]); + return response()->json(['status' => $canView ? 'following' : 'requested']); } - public function settings(){ + public function approveRequest(User $follower): JsonResponse + { + $followee = Followee::where('user_id', $follower->id) + ->where('followee_id', auth()->id()) + ->pending() + ->firstOrFail(); + + $followee->update(['verified' => true]); + + Notification::create([ + 'user_id' => $follower->id, + 'title' => 'Follow request accepted', + 'body' => auth()->user()->name . ' accepted your follow request.', + 'is_achievement' => false, + 'url' => '/u/' . auth()->user()->name, + ]); + + return response()->json(['approved' => true]); + } + + public function denyRequest(User $follower): JsonResponse + { + Followee::where('user_id', $follower->id) + ->where('followee_id', auth()->id()) + ->pending() + ->delete(); + + return response()->json(['denied' => true]); + } + + public function settings(?string $category = null){ + $allowedTabs = ['general', 'followers']; $user = auth()->user(); $current = array_merge(SettingsRegistry::defaults(), $user->settings ?? []); $fields = array_map(fn($field) => array_merge($field, [ @@ -50,6 +89,7 @@ class UserController extends Controller return Inertia::render('UserSettings', [ 'fields' => $fields, 'categories' => SettingsRegistry::categories(), + 'defaultTab' => in_array($category, $allowedTabs, true) ? $category : 'general', ]); } } diff --git a/app/Http/Controllers/UserFlightController.php b/app/Http/Controllers/UserFlightController.php index 70f96dc..b4ecb02 100644 --- a/app/Http/Controllers/UserFlightController.php +++ b/app/Http/Controllers/UserFlightController.php @@ -5,34 +5,54 @@ namespace App\Http\Controllers; use App\Models\User; use App\Models\UserFlight; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Gate; class UserFlightController extends Controller { - protected User $user; - - function __construct(User $user){ - $this->user = $user; + public function viewableFlights(User $user, ?Request $request = null) + { + if (Gate::denies('viewProfileData', $user)) { + return response()->json([]); + } + return $this->flights($user, $request); } - public function flights(?Request $request = null) + public function flights(User $user, ?Request $request = null) { - return UserFlight::where('user_id', $this->user->id) - ->with([ - 'departureAirport.region.country', - 'departureAirport.region.continent', - 'arrivalAirport.region.country', - 'arrivalAirport.region.continent', - 'airline.country', - 'airline.alliance', - 'aircraft', - 'seatType', - 'flightReason', - 'flightClass', - 'crewType' - ]) - ->when($request?->boolean('departed_only'), fn($q) => $q->where('departure_date', '<=', now('UTC'))) - ->orderBy('departure_date', 'desc') - ->get(); + $key = "user_flights_{$user->id}"; + + $json = Cache::remember($key, now()->addDays(30), function () use ($user) { + return UserFlight::where('user_id', $user->id) + ->with([ + 'departureAirport.region.country', + 'departureAirport.region.continent', + 'arrivalAirport.region.country', + 'arrivalAirport.region.continent', + 'airline.country', + 'airline.alliance', + 'aircraft', + 'seatType', + 'flightReason', + 'flightClass', + 'crewType' + ]) + ->orderBy('departure_date', 'desc') + ->get() + ->values() + ->toJson(); + }); + + if ($request?->boolean('departed_only')) { + $filtered = collect(json_decode($json)) + ->filter(fn($f) => $f->departure_date <= now('UTC')->toDateString()) + ->values() + ->toJson(); + + return response($filtered, 200)->header('Content-Type', 'application/json'); + } + + return response($json, 200)->header('Content-Type', 'application/json'); } } diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php new file mode 100644 index 0000000..f5d8489 --- /dev/null +++ b/app/Http/Controllers/UserProfileController.php @@ -0,0 +1,194 @@ +check()) { + $user = auth()->user(); + $defaultPage = $user->resolved_settings['default_login_page']; + + $route = match ($defaultPage) { + 'feed_first' => $user->following()->count() > 0 ? 'feed' : 'profile.view', + 'feed' => 'feed', + 'profile' => 'profile.view', + 'dashboard' => 'dashboard', + }; + + $args = $route == 'profile.view' ? $user->name : null; + + return redirect()->route($route, $args); + } + return redirect()->route('login'); + } + + public static function getUserFlightApiURL(User $user){ + return '/data/user/'.$user->name.'/flights'; + } + + public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array { + return [ + 'user' => $user, + 'canView' => Gate::allows('viewProfileData', $user), + 'canEdit' => auth()->check() && (auth()->id() === $user->id || auth()->user()->hasRole('admin')), + 'initialView' => $view, + 'selectedFlightId' => $selectedFlightId, + 'flight_api_url' => self::getUserFlightApiURL($user), + 'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none', + 'flightCount' => $user->departedFlights()->count(), + ]; + } + + public function departureBoard(User $user, ?UserFlight $flight = null){ + $profileData = $this->profileData($user, 'board', $flight?->id); + return Inertia::render('UserProfile', $profileData); + } + + public function map(User $user){ + $profileData = $this->profileData($user, 'map'); + return Inertia::render('UserProfile', $profileData); + } + + public function boardingPasses(User $user){ + $profileData = $this->profileData($user, 'passes'); + return Inertia::render('UserProfile', $profileData); + } + + public function view(User $user, ?string $page = null) + { + $loggedInUser = auth()->user(); + $defaultView = $page ?: ($loggedInUser ? $loggedInUser->resolved_settings['default_profile_view'] : 'map'); + + return match($defaultView) { + 'boarding-passes' => $this->boardingPasses($user), + 'map' => $this->map($user), + 'departure-board' => $this->departureBoard($user), + 'achievements' => redirect()->route('profile.achievements', $user->name), + }; + + } + + public function flight(User $user, UserFlight $userFlight) + { + if($userFlight->user_id !== $user->id){ + abort(404); + } + + return Inertia::render('UserFlight', [ + 'flightCount' => $user->departedFlights()->count(), + 'flight' => $userFlight->snapshot($userFlight->id), + 'canEdit' => auth()->check() && auth()->id() === $user->id, + 'canView' => Gate::allows('viewProfileData', $user), + 'user' => $user, + 'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none', + ]); + } + + + public function achievements(User $user) + { + $canView = Gate::allows('viewProfileData', $user); + + $achievements = Achievement::with(['category', 'difficulty']) + ->get() + ->groupBy(fn(Achievement $a) => $a->category->name) + ->map(fn($group) => $group->sortBy('sort_order')->values()); + + $userAchievements = $user->achievements() + ->with('achievement') + ->select(['achievement_id', 'progress']) + ->orderBy('achievement_id') + ->get() + ->keyBy('achievement_id'); + + $unlockedByCategory = $achievements->map(fn($group) => + $group->filter(fn($a) => $userAchievements->get($a->id)?->unlocked)->count() + ); + + $unlockedCount = $userAchievements->filter(fn($ua) => $ua->unlocked)->count(); + return Inertia::render('UserAchievements', [ + 'canView' => $canView, + 'user' => $user, + 'canEdit' => auth()->id() === $user->id, + 'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none', + 'achievements' => $canView ? $achievements : [], + 'userAchievements' => $canView ? $userAchievements : [], + 'loggedInUser' => auth()->user(), + 'unlockedCount' => $unlockedCount, + 'unlockedByCategory' => $unlockedByCategory, + 'totalAchievements' => $achievements->flatten()->count(), + ]); + } + + public function achievement(User $user, Achievement $achievement) + { + $regions = match($achievement->internal_name){ + 'fun_challenges.australian_states' => Country::whereCode('AU')->first()->sortedRegions(), + 'fun_challenges.chinese_provinces' => Country::whereCode('CN')->first()->sortedRegions(), + 'fun_challenges.canadian_provinces' => Country::whereCode('CA')->first()->sortedRegions(), + 'fun_challenges.brazilian_states' => Country::whereCode('BR')->first()->sortedRegions(), + 'fun_challenges.us_states' => Country::whereCode('US')->first()->sortedRegions(), + default => [], + }; + + $allianceInternalName = match($achievement->internal_name){ + 'airlines_alliances.all_star_alliance' => 'star_alliance', + 'airlines_alliances.all_oneworld' => 'oneworld', + 'airlines_alliances.all_skyteam' => 'skyteam', + 'airlines_alliances.all_vanilla_alliance' => 'vanilla_alliance', + default => null, + }; + + $continents = match($achievement->internal_name){ + 'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(), + default => [], + }; + + $alliance = null; + $airlines = []; + + if ($allianceInternalName) { + $alliance = Alliance::where('internal_name', $allianceInternalName) + ->with('airlines') + ->firstOrFail(); + $airlines = $alliance->airlines()->with('country')->orderBy('name')->get(); + } + + $aircraftFamilies = match($achievement->internal_name){ + 'aircraft.all_boeing_7x7' => Aircraft::BOEING_FAMILIES, + 'aircraft.all_airbus_a3xx' => Aircraft::AIRBUS_FAMILIES, + default => [], + }; + + $canView = Gate::allows('viewProfileData', $user); + + return Inertia::render('Profile/UserAchievement', [ + 'user' => $user, + 'achievement' => $achievement, + 'loggedInUser' => auth()->user(), + 'userAchievement' => $canView ? $user->achievements()->where('achievement_id', $achievement->id)->first() : null, + 'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none', + 'flight_api_url' => UserProfileController::getUserFlightApiURL($user), + 'regions' => $regions, + 'alliance' => $alliance, + 'airlines' => $airlines, + 'continents' => $continents, + 'aircraft_families' => $aircraftFamilies, + 'achievementCount' => $user->unlockedAchievements()->count(), + 'canView' => $canView, + ]); + } +} diff --git a/app/Models/Country.php b/app/Models/Country.php index 229331e..4c1ac1a 100644 --- a/app/Models/Country.php +++ b/app/Models/Country.php @@ -17,4 +17,13 @@ class Country extends Model { return $this->hasMany(Region::class); } + + function sortedRegions(): array + { + return $this + ->regions() + ->orderBy('name') + ->get() + ->toArray(); + } } diff --git a/app/Models/Followee.php b/app/Models/Followee.php index 6527d25..130fd87 100644 --- a/app/Models/Followee.php +++ b/app/Models/Followee.php @@ -10,6 +10,11 @@ class Followee extends Model protected $fillable = [ 'user_id', 'followee_id', + 'verified', + ]; + + protected $casts = [ + 'verified' => 'boolean', ]; public function user(): BelongsTo @@ -21,4 +26,14 @@ class Followee extends Model { return $this->belongsTo(User::class, 'followee_id'); } + + public function scopeVerified($query) + { + return $query->where('verified', true); + } + + public function scopePending($query) + { + return $query->where('verified', false); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 788638a..a2b2958 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -48,6 +48,10 @@ class User extends Authenticatable $this->update(['settings' => array_merge($current, $values)]); } + function updateSetting($settingName, $value) : void{ + $this->updateSettings([$settingName => $value]); + } + protected function resolvedSettings(): Attribute { return Attribute::make( @@ -80,11 +84,6 @@ class User extends Authenticatable return $this->where('name', 'ilike', $value)->firstOrFail(); } - public function FlightController(): UserFlightController - { - return new UserFlightController($this); - } - public function flights(): HasMany { return $this->hasMany(UserFlight::class); } @@ -114,7 +113,21 @@ class User extends Authenticatable public function isFollowing(User $user): bool { - return $this->following()->where('followee_id', $user->id)->exists(); + return $this->following() + ->where('followee_id', $user->id) + ->verified() + ->exists(); + } + + public function followStatus(User $user): string + { + $followee = $this->following()->where('followee_id', $user->id)->first(); + + if (!$followee) { + return 'none'; + } + + return $followee->verified ? 'following' : 'requested'; } public function notifications(): HasMany diff --git a/app/Models/UserFlight.php b/app/Models/UserFlight.php index b4cdb4d..ed8e2d5 100644 --- a/app/Models/UserFlight.php +++ b/app/Models/UserFlight.php @@ -48,6 +48,9 @@ class UserFlight extends Model 'duration_display', 'distance', 'livery_url', + 'scope', + 'range', + 'region_range' ]; public function calculateGreatCircleDistance(): float{ @@ -108,6 +111,26 @@ class UserFlight extends Model ); } + protected function scope(): Attribute + { + return Attribute::make( + get: fn() => $this->departureAirport->region->country_id == $this->arrivalAirport->region->country_id ? 'domestic' : 'international' + ); + } + protected function range(): Attribute + { + return Attribute::make( + get: fn() => $this->departureAirport->region->continent_id == $this->arrivalAirport->region->continent_id ? 'intracontinental' : 'intercontinental' + ); + } + + protected function regionRange(): Attribute + { + return Attribute::make( + get: fn() => $this->departureAirport->region_id == $this->arrivalAirport->region_id ? 'intraregional' : 'interregional' + ); + } + protected function arrivalDayDifference(): Attribute { return Attribute::make( diff --git a/app/Observers/FlightObserver.php b/app/Observers/FlightObserver.php index 0ac6b5e..fa373e0 100644 --- a/app/Observers/FlightObserver.php +++ b/app/Observers/FlightObserver.php @@ -3,15 +3,23 @@ namespace App\Observers; use App\Models\UserFlight; +use Illuminate\Support\Facades\Cache; class FlightObserver { + protected function clearCache(UserFlight $flight): void + { + Cache::forget("user_flights_{$flight->user->id}"); + } + + /** * Recalculate after a flight is created. */ public function created(UserFlight $flight): void { $flight->user->calculateAchievements(); + $this->clearCache($flight); } /** @@ -22,6 +30,7 @@ class FlightObserver public function updated(UserFlight $flight): void { $flight->user->calculateAchievements(); + $this->clearCache($flight); } /** @@ -31,5 +40,6 @@ class FlightObserver public function deleted(UserFlight $flight): void { $flight->user->calculateAchievements(); + $this->clearCache($flight); } } diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000..2220ebe --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,82 @@ +id === $profileUser->id || $viewer->hasRole('admin'))) { + return true; + } + + + $isPrivate = $profileUser->resolved_settings['private_profile'] == 'private'; + + if (!$isPrivate) { + return true; + } + + return $viewer && $viewer->isFollowing($profileUser); + } + + /** + * Determine whether the user can view any models. + */ + public function viewAny(User $user): bool + { + return false; + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, User $model): bool + { + return false; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return false; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, User $model): bool + { + return false; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, User $model): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, User $model): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, User $model): bool + { + return false; + } +} diff --git a/app/Settings/SettingsRegistry.php b/app/Settings/SettingsRegistry.php index 8e186fe..24b3ea3 100644 --- a/app/Settings/SettingsRegistry.php +++ b/app/Settings/SettingsRegistry.php @@ -67,6 +67,20 @@ class SettingsRegistry ['value' => 'achievements', 'label' => 'Achievements'], ], ], + [ + 'key' => 'show_map_legend', + 'type' => 'checkbox', + 'label' => 'Expand Map Legend By Default', + 'category' => 'FlightsGoneBy Settings', + 'default' => true, + ], + [ + 'key' => 'hide_impossible_achievements', + 'type' => 'checkbox', + 'label' => 'Hide Impossible Achievements By Default', + 'category' => 'FlightsGoneBy Settings', + 'default' => true, + ], [ 'category' => 'AI Generated Content', 'key' => 'ai_liveries', diff --git a/bootstrap/app.php b/bootstrap/app.php index 33a18db..c91c737 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -33,7 +33,7 @@ return Application::configure(basePath: dirname(__DIR__)) // }) ->withExceptions(function (Exceptions $exceptions): void { - $exceptions->respond(function (Response $response, Throwable $e, Request $request) { + $exceptions->respond(function ($response, Throwable $e, Request $request) { $status = $response->getStatusCode(); $errors = [ diff --git a/database/migrations/2026_06_20_083315_update_followees_table.php b/database/migrations/2026_06_20_083315_update_followees_table.php new file mode 100644 index 0000000..6b5c24a --- /dev/null +++ b/database/migrations/2026_06_20_083315_update_followees_table.php @@ -0,0 +1,26 @@ +boolean('verified')->default(true)->after('followee_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/package-lock.json b/package-lock.json index 013aa90..6d5dc9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "maplibre-gl": "^5.22.0", "vue-echarts": "^8.0.1", "vue3-apexcharts": "^1.11.1", - "vuetify": "^4.0.5" + "vuetify": "^4.1.2" }, "devDependencies": { "@inertiajs/vue3": "^2.0.0", @@ -5263,9 +5263,9 @@ } }, "node_modules/vuetify": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.1.tgz", - "integrity": "sha512-m75ZkDIBwstDhID/zPtQmh9m601G/C4wnvCmlA6hgvhD7EOwR9I4DClpxPQiNTd1toKWHCr3kD2XJqU1fh0tYA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.2.tgz", + "integrity": "sha512-2SUzXt2Q71/cVmSZhU6kOmAo1ev3NZaI/ynj55TTcu6jXAc9kezoqZKY+Xm+0STnAOcCoTseF0qPSs01LjUaaA==", "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 9a6de63..5127293 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,6 @@ "maplibre-gl": "^5.22.0", "vue-echarts": "^8.0.1", "vue3-apexcharts": "^1.11.1", - "vuetify": "^4.0.5" + "vuetify": "^4.1.2" } } diff --git a/resources/js/Components/Distance.vue b/resources/js/Components/Distance.vue index 8801fb4..70b380a 100644 --- a/resources/js/Components/Distance.vue +++ b/resources/js/Components/Distance.vue @@ -1,10 +1,11 @@ + + + + diff --git a/resources/js/Components/FlightsGoneBy/Charts/ChartTypes/ScrollingHorizontalBarChart.vue b/resources/js/Components/FlightsGoneBy/Charts/ChartTypes/ScrollingHorizontalBarChart.vue index 62a2a28..f7d7721 100644 --- a/resources/js/Components/FlightsGoneBy/Charts/ChartTypes/ScrollingHorizontalBarChart.vue +++ b/resources/js/Components/FlightsGoneBy/Charts/ChartTypes/ScrollingHorizontalBarChart.vue @@ -1,5 +1,5 @@ + + diff --git a/resources/js/Components/FlightsGoneBy/FollowButton.vue b/resources/js/Components/FlightsGoneBy/FollowButton.vue new file mode 100644 index 0000000..24c9f13 --- /dev/null +++ b/resources/js/Components/FlightsGoneBy/FollowButton.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/resources/js/Components/FlightsGoneBy/GlassBox.vue b/resources/js/Components/FlightsGoneBy/GlassBox.vue index 42834b5..b760671 100644 --- a/resources/js/Components/FlightsGoneBy/GlassBox.vue +++ b/resources/js/Components/FlightsGoneBy/GlassBox.vue @@ -18,6 +18,7 @@ defineProps<{ width: clamp(280px, 100%, 700px); gap: 1em; padding: 2em; + margin: 2em; } h2 { diff --git a/resources/js/Components/FlightsGoneBy/MainHeader.vue b/resources/js/Components/FlightsGoneBy/MainHeader.vue index 5086657..78c6364 100644 --- a/resources/js/Components/FlightsGoneBy/MainHeader.vue +++ b/resources/js/Components/FlightsGoneBy/MainHeader.vue @@ -51,7 +51,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))