Updated Map View

This commit is contained in:
2026-06-20 22:21:17 +10:00
parent 6fad966b7e
commit 05ca994253
52 changed files with 2038 additions and 803 deletions
@@ -1,115 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Achievement;
use App\Models\Aircraft;
use App\Models\Alliance;
use App\Models\Continent;
use App\Models\Country;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
class AchievementController extends Controller
{
public function index(User $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', [
'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(),
]);
}
}
@@ -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));
}
} }
@@ -1,100 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\UserFlight;
use App\Http\Resources\UserFlightResource;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class FlightProfileController extends Controller
{
public function index(){
if (auth()->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),
]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Models\Followee;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class FollowerController extends Controller
{
public function index(): JsonResponse
{
$followers = Followee::with('user')
->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']);
}
}
@@ -26,4 +26,15 @@ class SettingsController extends Controller
$request->user()->updateSettings($validated['settings']); $request->user()->updateSettings($validated['settings']);
return response()->json(['message' => 'Settings saved.']); 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.']);
}
} }
+45 -5
View File
@@ -9,38 +9,77 @@ use App\Settings\SettingsRegistry;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia; use Inertia\Inertia;
class UserController extends Controller class UserController extends Controller
{ {
public function follow(User $user): JsonResponse public function follow(User $user): JsonResponse
{ {
abort_if($user->id === auth()->id(), 403);
$existing = Followee::where('user_id', auth()->id()) $existing = Followee::where('user_id', auth()->id())
->where('followee_id', $user->id) ->where('followee_id', $user->id)
->first(); ->first();
if ($existing) { if ($existing) {
$existing->delete(); $existing->delete();
return response()->json(['following' => false]); return response()->json(['status' => 'none']);
} }
$canView = Gate::allows('viewProfileData', $user);
Followee::create([ Followee::create([
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'followee_id' => $user->id, 'followee_id' => $user->id,
'verified' => $canView,
]); ]);
Notification::create([ Notification::create([
'user_id' => $user->id, 'user_id' => $user->id,
'title' => 'New follower', 'title' => $canView ? 'New follower' : 'Follow request',
'body' => auth()->user()->name . ' is now following you.', 'body' => $canView
? auth()->user()->name . ' is now following you.'
: auth()->user()->name . ' wants to follow you.',
'is_achievement' => false,
'url' => $canView ? '/u/' . auth()->user()->name : '/follow-requests',
]);
return response()->json(['status' => $canView ? 'following' : 'requested']);
}
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, 'is_achievement' => false,
'url' => '/u/' . auth()->user()->name, 'url' => '/u/' . auth()->user()->name,
]); ]);
return response()->json(['following' => true]); return response()->json(['approved' => true]);
} }
public function settings(){ 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(); $user = auth()->user();
$current = array_merge(SettingsRegistry::defaults(), $user->settings ?? []); $current = array_merge(SettingsRegistry::defaults(), $user->settings ?? []);
$fields = array_map(fn($field) => array_merge($field, [ $fields = array_map(fn($field) => array_merge($field, [
@@ -50,6 +89,7 @@ class UserController extends Controller
return Inertia::render('UserSettings', [ return Inertia::render('UserSettings', [
'fields' => $fields, 'fields' => $fields,
'categories' => SettingsRegistry::categories(), 'categories' => SettingsRegistry::categories(),
'defaultTab' => in_array($category, $allowedTabs, true) ? $category : 'general',
]); ]);
} }
} }
+28 -8
View File
@@ -5,19 +5,26 @@ namespace App\Http\Controllers;
use App\Models\User; use App\Models\User;
use App\Models\UserFlight; use App\Models\UserFlight;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
class UserFlightController extends Controller class UserFlightController extends Controller
{ {
protected User $user; public function viewableFlights(User $user, ?Request $request = null)
{
function __construct(User $user){ if (Gate::denies('viewProfileData', $user)) {
$this->user = $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) $key = "user_flights_{$user->id}";
$json = Cache::remember($key, now()->addDays(30), function () use ($user) {
return UserFlight::where('user_id', $user->id)
->with([ ->with([
'departureAirport.region.country', 'departureAirport.region.country',
'departureAirport.region.continent', 'departureAirport.region.continent',
@@ -31,8 +38,21 @@ class UserFlightController extends Controller
'flightClass', 'flightClass',
'crewType' 'crewType'
]) ])
->when($request?->boolean('departed_only'), fn($q) => $q->where('departure_date', '<=', now('UTC')))
->orderBy('departure_date', 'desc') ->orderBy('departure_date', 'desc')
->get(); ->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');
} }
} }
@@ -0,0 +1,194 @@
<?php
namespace App\Http\Controllers;
use App\Models\Achievement;
use App\Models\Aircraft;
use App\Models\Alliance;
use App\Models\Continent;
use App\Models\Country;
use App\Models\User;
use App\Models\UserFlight;
use App\Http\Resources\UserFlightResource;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
class UserProfileController extends Controller
{
public function index(){
if (auth()->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,
]);
}
}
+9
View File
@@ -17,4 +17,13 @@ class Country extends Model
{ {
return $this->hasMany(Region::class); return $this->hasMany(Region::class);
} }
function sortedRegions(): array
{
return $this
->regions()
->orderBy('name')
->get()
->toArray();
}
} }
+15
View File
@@ -10,6 +10,11 @@ class Followee extends Model
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'followee_id', 'followee_id',
'verified',
];
protected $casts = [
'verified' => 'boolean',
]; ];
public function user(): BelongsTo public function user(): BelongsTo
@@ -21,4 +26,14 @@ class Followee extends Model
{ {
return $this->belongsTo(User::class, 'followee_id'); 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);
}
} }
+19 -6
View File
@@ -48,6 +48,10 @@ class User extends Authenticatable
$this->update(['settings' => array_merge($current, $values)]); $this->update(['settings' => array_merge($current, $values)]);
} }
function updateSetting($settingName, $value) : void{
$this->updateSettings([$settingName => $value]);
}
protected function resolvedSettings(): Attribute protected function resolvedSettings(): Attribute
{ {
return Attribute::make( return Attribute::make(
@@ -80,11 +84,6 @@ class User extends Authenticatable
return $this->where('name', 'ilike', $value)->firstOrFail(); return $this->where('name', 'ilike', $value)->firstOrFail();
} }
public function FlightController(): UserFlightController
{
return new UserFlightController($this);
}
public function flights(): HasMany { public function flights(): HasMany {
return $this->hasMany(UserFlight::class); return $this->hasMany(UserFlight::class);
} }
@@ -114,7 +113,21 @@ class User extends Authenticatable
public function isFollowing(User $user): bool 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 public function notifications(): HasMany
+23
View File
@@ -48,6 +48,9 @@ class UserFlight extends Model
'duration_display', 'duration_display',
'distance', 'distance',
'livery_url', 'livery_url',
'scope',
'range',
'region_range'
]; ];
public function calculateGreatCircleDistance(): float{ 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 protected function arrivalDayDifference(): Attribute
{ {
return Attribute::make( return Attribute::make(
+10
View File
@@ -3,15 +3,23 @@
namespace App\Observers; namespace App\Observers;
use App\Models\UserFlight; use App\Models\UserFlight;
use Illuminate\Support\Facades\Cache;
class FlightObserver class FlightObserver
{ {
protected function clearCache(UserFlight $flight): void
{
Cache::forget("user_flights_{$flight->user->id}");
}
/** /**
* Recalculate after a flight is created. * Recalculate after a flight is created.
*/ */
public function created(UserFlight $flight): void public function created(UserFlight $flight): void
{ {
$flight->user->calculateAchievements(); $flight->user->calculateAchievements();
$this->clearCache($flight);
} }
/** /**
@@ -22,6 +30,7 @@ class FlightObserver
public function updated(UserFlight $flight): void public function updated(UserFlight $flight): void
{ {
$flight->user->calculateAchievements(); $flight->user->calculateAchievements();
$this->clearCache($flight);
} }
/** /**
@@ -31,5 +40,6 @@ class FlightObserver
public function deleted(UserFlight $flight): void public function deleted(UserFlight $flight): void
{ {
$flight->user->calculateAchievements(); $flight->user->calculateAchievements();
$this->clearCache($flight);
} }
} }
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class UserPolicy
{
public function viewProfileData(?User $viewer, User $profileUser): bool
{
if ($viewer && ($viewer->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;
}
}
+14
View File
@@ -67,6 +67,20 @@ class SettingsRegistry
['value' => 'achievements', 'label' => 'Achievements'], ['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', 'category' => 'AI Generated Content',
'key' => 'ai_liveries', 'key' => 'ai_liveries',
+1 -1
View File
@@ -33,7 +33,7 @@ return Application::configure(basePath: dirname(__DIR__))
// //
}) })
->withExceptions(function (Exceptions $exceptions): void { ->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(); $status = $response->getStatusCode();
$errors = [ $errors = [
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('followees', function (Blueprint $table) {
$table->boolean('verified')->default(true)->after('followee_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
+4 -4
View File
@@ -13,7 +13,7 @@
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"vue-echarts": "^8.0.1", "vue-echarts": "^8.0.1",
"vue3-apexcharts": "^1.11.1", "vue3-apexcharts": "^1.11.1",
"vuetify": "^4.0.5" "vuetify": "^4.1.2"
}, },
"devDependencies": { "devDependencies": {
"@inertiajs/vue3": "^2.0.0", "@inertiajs/vue3": "^2.0.0",
@@ -5263,9 +5263,9 @@
} }
}, },
"node_modules/vuetify": { "node_modules/vuetify": {
"version": "4.1.1", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.1.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.2.tgz",
"integrity": "sha512-m75ZkDIBwstDhID/zPtQmh9m601G/C4wnvCmlA6hgvhD7EOwR9I4DClpxPQiNTd1toKWHCr3kD2XJqU1fh0tYA==", "integrity": "sha512-2SUzXt2Q71/cVmSZhU6kOmAo1ev3NZaI/ynj55TTcu6jXAc9kezoqZKY+Xm+0STnAOcCoTseF0qPSs01LjUaaA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
+1 -1
View File
@@ -36,6 +36,6 @@
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"vue-echarts": "^8.0.1", "vue-echarts": "^8.0.1",
"vue3-apexcharts": "^1.11.1", "vue3-apexcharts": "^1.11.1",
"vuetify": "^4.0.5" "vuetify": "^4.1.2"
} }
} }
+2 -1
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import FormattedNumber from "@/Components/FormattedNumber.vue"; import FormattedNumber from "@/Components/FormattedNumber.vue";
import {DistanceUnit} from "@/Types/types";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
value: number value: number
unit?: 'km' | 'mi' | 'nm' unit?: DistanceUnit
showUnits?: boolean showUnits?: boolean
includeSpace?: boolean includeSpace?: boolean
}>(), { }>(), {
@@ -0,0 +1,122 @@
<script lang="ts" setup>
import { ref } from 'vue'
import axios from 'axios'
import type { FollowerEntry } from "@/Types/types"
const props = defineProps<{
entry: FollowerEntry
}>()
const emit = defineEmits<{
approved: []
denied: []
removed: []
}>()
const processing = ref(false)
const confirmingRemove = ref(false)
async function approve() {
processing.value = true
try {
await axios.post(`/followers/${encodeURIComponent(props.entry.user.name)}/approve`)
emit('approved')
} finally {
processing.value = false
}
}
async function deny() {
processing.value = true
try {
await axios.post(`/followers/${encodeURIComponent(props.entry.user.name)}/deny`)
emit('denied')
} finally {
processing.value = false
}
}
async function remove() {
processing.value = true
try {
await axios.delete(`/followers/${encodeURIComponent(props.entry.user.name)}`)
emit('removed')
} finally {
processing.value = false
confirmingRemove.value = false
}
}
</script>
<template>
<v-card variant="outlined" class="follower-card pa-3 d-flex align-center">
<v-icon icon="mdi-account-circle" size="40" class="mr-3" />
<div class="flex-grow-1">
<div class="text-body-2 font-weight-medium">{{ entry.user.name }}</div>
<div v-if="!entry.verified" class="text-caption text-medium-emphasis">
Wants to follow you
</div>
</div>
<template v-if="!entry.verified">
<v-btn
size="small"
color="primary"
variant="flat"
:loading="processing"
class="mr-2"
@click="approve"
>
Approve
</v-btn>
<v-btn
size="small"
variant="outlined"
:disabled="processing"
@click="deny"
>
Deny
</v-btn>
</template>
<template v-else>
<v-btn
v-if="!confirmingRemove"
size="small"
variant="text"
:disabled="processing"
@click="confirmingRemove = true"
>
Remove
</v-btn>
<template v-else>
<span class="text-caption mr-2">Remove follower?</span>
<v-btn
size="small"
color="error"
variant="flat"
:loading="processing"
class="mr-1"
@click="remove"
>
Confirm
</v-btn>
<v-btn
size="small"
variant="text"
:disabled="processing"
@click="confirmingRemove = false"
>
Cancel
</v-btn>
</template>
</template>
</v-card>
</template>
<style scoped>
.follower-card {
background: rgba(255, 255, 255, 0.02);
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import VueApexCharts from "vue3-apexcharts" import VueApexCharts from "vue3-apexcharts"
const props = defineProps<{ const props = defineProps<{
@@ -12,18 +12,58 @@ const props = defineProps<{
barHeight?: number barHeight?: number
colors?: string[] colors?: string[]
options?: object options?: object
limit?: number
}>() }>()
const BAR_HEIGHT = computed(() => props.barHeight ?? 32) const BAR_HEIGHT = computed(() => props.barHeight ?? 32)
const MAX_VISIBLE = computed(() => props.maxVisible ?? 12) const MAX_VISIBLE = computed(() => props.maxVisible ?? 12)
const chartHeight = computed(() => props.categories.length * BAR_HEIGHT.value + 40) // ── Limit / Show More ─────────────────────────────────────────────────────────
const showAll = ref(false)
const isExpanding = ref(false)
async function expand() {
isExpanding.value = true
await nextTick() // paint "Loading..." before blocking on chart render
showAll.value = true
await nextTick() // wait for ApexCharts to finish
isExpanding.value = false
}
function collapse() {
showAll.value = false
}
const visibleCategories = computed(() =>
props.limit && !showAll.value
? props.categories.slice(0, props.limit)
: props.categories
)
const visibleSeries = computed(() =>
props.limit && !showAll.value
? props.series.map(s => ({ ...s, data: s.data.slice(0, props.limit) }))
: props.series
)
const hasMore = computed(() =>
!!props.limit && props.categories.length > props.limit
)
const hiddenCount = computed(() =>
props.limit ? props.categories.length - props.limit : 0
)
// ── Chart dimensions ──────────────────────────────────────────────────────────
const chartHeight = computed(() => visibleCategories.value.length * BAR_HEIGHT.value + 40)
const scrollHeight = computed(() => { const scrollHeight = computed(() => {
const visible = Math.min(props.categories.length, MAX_VISIBLE.value) const visible = Math.min(visibleCategories.value.length, MAX_VISIBLE.value)
return `${visible * BAR_HEIGHT.value + 40}px` return `${visible * BAR_HEIGHT.value + 40}px`
}) })
// ── Tooltip state (exposed to parent via scoped slot) ───────────────────────── // ── Tooltip state ─────────────────────────────────────────────────────────────
const tooltipVisible = ref(false) const tooltipVisible = ref(false)
const tooltipX = ref(0) const tooltipX = ref(0)
@@ -74,7 +114,7 @@ const chartOptions = computed(() => ({
colors: props.colors ?? ['#4da6ff', '#ffc107'], colors: props.colors ?? ['#4da6ff', '#ffc107'],
dataLabels: { enabled: false }, dataLabels: { enabled: false },
xaxis: { xaxis: {
categories: props.categories, categories: visibleCategories.value,
labels: { show: false }, labels: { show: false },
axisBorder: { show: false }, axisBorder: { show: false },
axisTicks: { show: false }, axisTicks: { show: false },
@@ -113,7 +153,7 @@ const chartOptions = computed(() => ({
type="bar" type="bar"
:height="chartHeight" :height="chartHeight"
:options="options ?? chartOptions" :options="options ?? chartOptions"
:series="series" :series="visibleSeries"
/> />
</div> </div>
@@ -125,6 +165,22 @@ const chartOptions = computed(() => ({
:index="hoveredIndex" :index="hoveredIndex"
/> />
<div v-if="hasMore || (limit && showAll)" class="show-more-wrap">
<button
v-if="!showAll"
class="show-more"
:disabled="isExpanding"
:class="{ loading: isExpanding }"
@click="expand"
>
<span v-if="isExpanding" class="spinner" />
<span>{{ isExpanding ? 'Loading…' : `Show ${hiddenCount} more` }}</span>
</button>
<button v-else class="show-more" @click="collapse">
Show less
</button>
</div>
<div v-if="footerValue !== undefined" class="chart-footer"> <div v-if="footerValue !== undefined" class="chart-footer">
<span class="total-count">{{ footerValue }}</span> <span class="total-count">{{ footerValue }}</span>
<span class="total-label">{{ footerLabel }}</span> <span class="total-label">{{ footerLabel }}</span>
@@ -167,6 +223,49 @@ const chartOptions = computed(() => ({
.chart-scroll::-webkit-scrollbar-track { background: transparent; } .chart-scroll::-webkit-scrollbar-track { background: transparent; }
.chart-scroll::-webkit-scrollbar-thumb { background: #334455; border-radius: 2px; } .chart-scroll::-webkit-scrollbar-thumb { background: #334455; border-radius: 2px; }
.show-more-wrap {
display: flex;
padding-left: 2px;
}
.show-more {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid #334455;
border-radius: 4px;
color: #778899;
cursor: pointer;
font-size: 12px;
padding: 4px 10px;
transition: color 0.15s, border-color 0.15s;
}
.show-more:hover:not(:disabled) {
border-color: #4da6ff;
color: #4da6ff;
}
.show-more:disabled {
cursor: default;
opacity: 0.6;
}
.spinner {
width: 10px;
height: 10px;
border: 1.5px solid #556677;
border-top-color: #4da6ff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.chart-footer { .chart-footer {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -32,6 +32,8 @@ const chartEvents = computed(() => ({
<template> <template>
<ScrollingHorizontalBarChart <ScrollingHorizontalBarChart
:limit="18
"
title="Top countries" title="Top countries"
:series="flightStats.countries.value.series" :series="flightStats.countries.value.series"
:categories="countries.map(c => c.name)" :categories="countries.map(c => c.name)"
@@ -41,6 +41,7 @@ const chartEvents = computed(() => ({
:events="chartEvents" :events="chartEvents"
@mousemove="onMouseMove" @mousemove="onMouseMove"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
:limit="12"
> >
<template #tooltip="{ visible, x, y, index }"> <template #tooltip="{ visible, x, y, index }">
<ChartTooltip :visible="visible" :x="x" :y="y"> <ChartTooltip :visible="visible" :x="x" :y="y">
@@ -40,6 +40,7 @@ const chartEvents = computed(() => ({
:categories="airlines.map(a => a.name)" :categories="airlines.map(a => a.name)"
:footer-value="airlines.length" :footer-value="airlines.length"
footer-label="total airlines" footer-label="total airlines"
:limit="12"
:events="chartEvents" :events="chartEvents"
@mousemove="onMouseMove" @mousemove="onMouseMove"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
@@ -42,6 +42,7 @@ const chartEvents = computed(() => ({
:events="chartEvents" :events="chartEvents"
@mousemove="onMouseMove" @mousemove="onMouseMove"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
:limit="12"
> >
<template #tooltip="{ visible, x, y, index }"> <template #tooltip="{ visible, x, y, index }">
<ChartTooltip :visible="visible" :x="x" :y="y"> <ChartTooltip :visible="visible" :x="x" :y="y">
@@ -31,6 +31,7 @@ const series = computed(() => props.flightStats.topRoutes.value.series)
:footer-value="routes.length" :footer-value="routes.length"
footer-label="total routes" footer-label="total routes"
:max-visible="12" :max-visible="12"
:limit="12"
> >
<template #tooltip="{ visible, x, y, index }"> <template #tooltip="{ visible, x, y, index }">
<ChartTooltip :visible="visible" :x="x" :y="y"> <ChartTooltip :visible="visible" :x="x" :y="y">
@@ -47,7 +47,8 @@ const CLASS_ORDER: Record<string, number> = {
'Premium Economy': 2, 'Premium Economy': 2,
'Business': 3, 'Business': 3,
'First': 4, 'First': 4,
'Private': 5 'Private': 5,
'Crew' : 6,
} }
const customKeySort = { const customKeySort = {
@@ -57,6 +58,9 @@ const customKeySort = {
airline: (a: Flight['airline'], b: Flight['airline']) => { airline: (a: Flight['airline'], b: Flight['airline']) => {
return (a?.iata_code ?? '').localeCompare(b?.iata_code ?? '') return (a?.iata_code ?? '').localeCompare(b?.iata_code ?? '')
}, },
aircraft: (a: Flight['aircraft'], b: Flight['aircraft']) => {
return (a?.designator ?? '').localeCompare(b?.designator ?? '')
},
duration: (a: any, b: any) => (a ?? 0) - (b ?? 0), duration: (a: any, b: any) => (a ?? 0) - (b ?? 0),
departure_airport: (a: Flight['departure_airport'], b: Flight['departure_airport']) => { departure_airport: (a: Flight['departure_airport'], b: Flight['departure_airport']) => {
return (a?.display_code ?? '').localeCompare(b?.display_code ?? '') return (a?.display_code ?? '').localeCompare(b?.display_code ?? '')
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type {Airline, Flight} from '@/Types/types' import type { Airline, Alliance, Flight, RegionRange, FlightRange, FlightScope } from '@/Types/types'
import { ref } from 'vue' import { ref, watch } from 'vue'
import { usePage } from '@inertiajs/vue3' import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -11,31 +11,72 @@ const emit = defineEmits<{
change: [filters: { change: [filters: {
years: number[] years: number[]
airlines: number[] airlines: number[]
alliances: number[]
countries: string[] countries: string[]
continents: string[] continents: string[]
flightClasses: number[] flightClasses: number[]
crewTypes: number[] crewTypes: number[]
flightScopes: FlightScope[]
flightRanges: FlightRange[]
regionRanges: RegionRange[]
flightReasons: number[]
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
airportRegions: number[]
}] }]
}>() }>()
// ── Available options ───────────────────────────────────────────────────────── // ── Available options ─────────────────────────────────────────────────────────
type AirlineData = { id: number; name: string, airline: Airline } type AirlineData = { id: number; name: string; airline: Airline }
const FLIGHT_SCOPE_LABELS: Record<FlightScope, string> = {
domestic: 'Domestic',
international: 'International',
}
const FLIGHT_RANGE_LABELS: Record<FlightRange, string> = {
intracontinental: 'Intracontinental',
intercontinental: 'Intercontinental',
}
const REGION_RANGE_LABELS: Record<RegionRange, string> = {
intraregional: 'Intraregional',
interregional: 'Interregional',
}
function buildOptions(flights: Flight[]) { function buildOptions(flights: Flight[]) {
const years = new Set<number>() const years = new Set<number>()
const airlines = new Map<number, AirlineData>() const airlines = new Map<number, AirlineData>()
const alliances = new Map<number, { id: number; name: string; alliance: Alliance }>()
const countries = new Map<string, { code: string; name: string }>() const countries = new Map<string, { code: string; name: string }>()
const continents = new Map<string, { code: string; name: string }>() const continents = new Map<string, { code: string; name: string }>()
const classes = new Map<number, { id: number; name: string }>() const classes = new Map<number, { id: number; name: string }>()
const crewTypes = new Map<number, { id: number; name: string }>() const crewTypes = new Map<number, { id: number; name: string }>()
const flightReasons = new Map<number, { id: number; name: string }>()
const seatTypes = new Map<number, { id: number; name: string }>()
const flightScopes = new Set<FlightScope>()
const flightRanges = new Set<FlightRange>()
const regionRanges = new Set<RegionRange>()
const manufacturers = new Map<string, { name: string }>()
const aircraftModels = new Map<number, { id: number; name: string }>()
const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>()
flights.forEach(f => { flights.forEach(f => {
years.add(new Date(f.departure_date).getFullYear()) years.add(new Date(f.departure_date).getFullYear())
if (f.airline) if (f.airline) {
airlines.set(f.airline.id, { id: f.airline.id, name: f.airline.name, airline: f.airline }) airlines.set(f.airline.id, { id: f.airline.id, name: f.airline.name, airline: f.airline })
if (f.airline.alliance?.id != null && f.airline.alliance?.name)
alliances.set(f.airline.alliance.id, {
id: f.airline.alliance.id,
name: f.airline.alliance.name,
alliance: f.airline.alliance,
})
}
const dep = f.departure_airport.region const dep = f.departure_airport.region
const arr = f.arrival_airport.region const arr = f.arrival_airport.region
@@ -44,46 +85,119 @@ function buildOptions(flights: Flight[]) {
if (dep?.continent) continents.set(dep.continent.code, { code: dep.continent.code, name: dep.continent.name }) if (dep?.continent) continents.set(dep.continent.code, { code: dep.continent.code, name: dep.continent.name })
if (arr?.continent) continents.set(arr.continent.code, { code: arr.continent.code, name: arr.continent.name }) if (arr?.continent) continents.set(arr.continent.code, { code: arr.continent.code, name: arr.continent.name })
if (f.flight_class?.id && f.flight_class?.name) for (const airport of [f.departure_airport, f.arrival_airport]) {
const region = airport.region
if (region?.id != null && region?.name) {
if (!airportRegions.has(region.id)) {
airportRegions.set(region.id, {
id: region.id,
name: region.country?.code ? `${region.country.code} - ${region.name}` : region.name,
countryCodes: new Set(),
})
}
if (region.country?.code)
airportRegions.get(region.id)!.countryCodes.add(region.country.code)
}
}
if (f.flight_class?.id != null && f.flight_class?.name)
classes.set(f.flight_class.id, { id: f.flight_class.id, name: f.flight_class.name }) classes.set(f.flight_class.id, { id: f.flight_class.id, name: f.flight_class.name })
if (f.crew_type?.id && f.crew_type?.name) if (f.crew_type?.id != null && f.crew_type?.name)
crewTypes.set(f.crew_type.id, { id: f.crew_type.id, name: f.crew_type.name }) crewTypes.set(f.crew_type.id, { id: f.crew_type.id, name: f.crew_type.name })
if (f.flight_reason?.id != null && f.flight_reason?.name)
flightReasons.set(f.flight_reason.id, { id: f.flight_reason.id, name: f.flight_reason.name })
if (f.seat_type?.id != null && f.seat_type?.name)
seatTypes.set(f.seat_type.id, { id: f.seat_type.id, name: f.seat_type.name })
if (f.aircraft?.manufacturer_code)
manufacturers.set(f.aircraft.manufacturer_code, { name: f.aircraft.manufacturer_code })
if (f.aircraft?.id != null && f.aircraft?.display_name_short)
aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short })
flightScopes.add(f.scope)
flightRanges.add(f.range)
regionRanges.add(f.region_range)
}) })
const scopeOrder: FlightScope[] = ['domestic', 'international']
const rangeOrder: FlightRange[] = ['intracontinental', 'intercontinental']
const regionRangeOrder: RegionRange[] = ['intraregional', 'interregional']
return { return {
years: [...years].sort((a, b) => b - a), years: [...years].sort((a, b) => b - a),
airlines: [...airlines.values()].sort((a, b) => a.name.localeCompare(b.name)), airlines: [...airlines.values()].sort((a, b) => a.name.localeCompare(b.name)),
alliances: [...alliances.values()].sort((a, b) => a.name.localeCompare(b.name)),
countries: [...countries.values()].sort((a, b) => a.name.localeCompare(b.name)), countries: [...countries.values()].sort((a, b) => a.name.localeCompare(b.name)),
continents: [...continents.values()].sort((a, b) => a.name.localeCompare(b.name)), continents: [...continents.values()].sort((a, b) => a.name.localeCompare(b.name)),
classes: [...classes.values()].sort((a, b) => a.name.localeCompare(b.name)), classes: [...classes.values()].sort((a, b) => a.name.localeCompare(b.name)),
crewTypes: [...crewTypes.values()].sort((a, b) => a.name.localeCompare(b.name)), crewTypes: [...crewTypes.values()].sort((a, b) => a.name.localeCompare(b.name)),
flightReasons: [...flightReasons.values()].sort((a, b) => a.name.localeCompare(b.name)),
seatTypes: [...seatTypes.values()].sort((a, b) => a.id - b.id),
flightScopes: scopeOrder.filter(s => flightScopes.has(s)).map(s => ({ value: s, label: FLIGHT_SCOPE_LABELS[s] })),
flightRanges: rangeOrder.filter(r => flightRanges.has(r)).map(r => ({ value: r, label: FLIGHT_RANGE_LABELS[r] })),
regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })),
manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)),
aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)),
airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)),
} }
} }
const availableOptions = buildOptions(props.flights) const availableOptions = buildOptions(props.flights)
// ── Filter state ────────────────────────────────────────────────────────────── // ── Filter state ──────────────────────────────────────────────────────────────
const selectedYears = ref<number[]>([]) const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([]) const selectedAirlines = ref<number[]>([])
const selectedAlliances = ref<number[]>([])
const selectedCountries = ref<string[]>([]) const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([]) const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([]) const selectedFlightClasses = ref<number[]>([])
const selectedCrewTypes = ref<number[]>([]) const selectedCrewTypes = ref<number[]>([])
const selectedFlightScopes = ref<FlightScope[]>([])
const selectedFlightRanges = ref<FlightRange[]>([])
const selectedRegionRanges = ref<RegionRange[]>([])
const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
// When selected countries change, drop any selected regions that no longer
// belong to any of the selected countries.
watch(selectedCountries, (countries) => {
if (countries.length === 0) return
selectedAirportRegions.value = selectedAirportRegions.value.filter(regionId => {
const region = availableOptions.airportRegions.find(r => r.id === regionId)
if (!region) return false
return [...(region as any).countryCodes].some((code: string) => countries.includes(code))
})
})
function emitFilters() { function emitFilters() {
emit('change', { emit('change', {
years: selectedYears.value, years: selectedYears.value,
airlines: selectedAirlines.value, airlines: selectedAirlines.value,
alliances: selectedAlliances.value,
countries: selectedCountries.value, countries: selectedCountries.value,
continents: selectedContinents.value, continents: selectedContinents.value,
flightClasses: selectedFlightClasses.value, flightClasses: selectedFlightClasses.value,
crewTypes: selectedCrewTypes.value, crewTypes: selectedCrewTypes.value,
flightScopes: selectedFlightScopes.value,
flightRanges: selectedFlightRanges.value,
regionRanges: selectedRegionRanges.value,
flightReasons: selectedFlightReasons.value,
seatTypes: selectedSeatTypes.value,
manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value,
airportRegions: selectedAirportRegions.value,
}) })
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const page = usePage()
const countryFlagClass = (code: string) => const countryFlagClass = (code: string) =>
`fi fi-${code.toLowerCase()}` `fi fi-${code.toLowerCase()}`
@@ -91,6 +205,7 @@ const countryFlagClass = (code: string) =>
<template> <template>
<div class="flight-filters"> <div class="flight-filters">
<v-select <v-select
v-model="selectedYears" v-model="selectedYears"
:items="availableOptions.years" :items="availableOptions.years"
@@ -140,6 +255,35 @@ const countryFlagClass = (code: string) =>
</template> </template>
</v-select> </v-select>
<v-select
v-if="availableOptions.alliances.length > 0"
v-model="selectedAlliances"
:items="availableOptions.alliances"
item-title="name" item-value="id"
label="Alliance"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend="{ isSelected }">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
</template>
<template #title>
<AllianceLogo :size="22" :alliance="(item as any).alliance" style="margin-right: 8px;" />
{{ (item as any).name }}
</template>
</v-list-item>
</template>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAlliances.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAlliances.length - 2 }}</span>
</template>
</v-select>
<v-select <v-select
v-model="selectedCountries" v-model="selectedCountries"
:items="availableOptions.countries" :items="availableOptions.countries"
@@ -169,6 +313,60 @@ const countryFlagClass = (code: string) =>
</template> </template>
</v-select> </v-select>
<v-select
v-if="availableOptions.flightScopes.length > 1"
v-model="selectedFlightScopes"
:items="availableOptions.flightScopes"
item-title="label" item-value="value"
label="Flight Scope"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightScopes.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightScopes.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.airportRegions.length > 0"
v-model="selectedAirportRegions"
:items="availableOptions.airportRegions"
item-title="name" item-value="id"
label="Region"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirportRegions.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirportRegions.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.regionRanges.length > 1"
v-model="selectedRegionRanges"
:items="availableOptions.regionRanges"
item-title="label" item-value="value"
label="Region Range"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedRegionRanges.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedRegionRanges.length - 2 }}</span>
</template>
</v-select>
<v-select <v-select
v-model="selectedContinents" v-model="selectedContinents"
:items="availableOptions.continents" :items="availableOptions.continents"
@@ -186,11 +384,29 @@ const countryFlagClass = (code: string) =>
</template> </template>
</v-select> </v-select>
<v-select
v-if="availableOptions.flightRanges.length > 1"
v-model="selectedFlightRanges"
:items="availableOptions.flightRanges"
item-title="label" item-value="value"
label="Flight Range"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightRanges.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightRanges.length - 2 }}</span>
</template>
</v-select>
<v-select <v-select
v-model="selectedFlightClasses" v-model="selectedFlightClasses"
:items="availableOptions.classes" :items="availableOptions.classes"
item-title="name" item-value="id" item-title="name" item-value="id"
label="Flight class" label="Flight Class"
multiple clearable hide-details multiple clearable hide-details
density="compact" variant="outlined" density="compact" variant="outlined"
@update:model-value="emitFilters" @update:model-value="emitFilters"
@@ -203,6 +419,78 @@ const countryFlagClass = (code: string) =>
</template> </template>
</v-select> </v-select>
<v-select
v-if="availableOptions.flightReasons.length > 0"
v-model="selectedFlightReasons"
:items="availableOptions.flightReasons"
item-title="name" item-value="id"
label="Flight Reason"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightReasons.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightReasons.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.seatTypes.length > 0"
v-model="selectedSeatTypes"
:items="availableOptions.seatTypes"
item-title="name" item-value="id"
label="Seat Type"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedSeatTypes.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedSeatTypes.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.manufacturers.length > 0"
v-model="selectedManufacturers"
:items="availableOptions.manufacturers"
item-title="name" item-value="name"
label="Aircraft Manufacturer"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedManufacturers.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedManufacturers.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.aircraftModels.length > 0"
v-model="selectedAircraftModels"
:items="availableOptions.aircraftModels"
item-title="name" item-value="id"
label="Aircraft Model"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAircraftModels.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAircraftModels.length - 2 }}</span>
</template>
</v-select>
<v-select <v-select
v-if="availableOptions.crewTypes.length > 0" v-if="availableOptions.crewTypes.length > 0"
v-model="selectedCrewTypes" v-model="selectedCrewTypes"
@@ -220,6 +508,7 @@ const countryFlagClass = (code: string) =>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCrewTypes.length - 2 }}</span> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCrewTypes.length - 2 }}</span>
</template> </template>
</v-select> </v-select>
</div> </div>
</template> </template>
@@ -13,7 +13,7 @@
<p>No flight data available</p> <p>No flight data available</p>
</div> </div>
<div v-if="showLegend" class="map-legend" :class="{ 'map-legend--open': legendOpen }"> <div v-if="showLegend" class="map-legend" :class="{ 'map-legend--open': legendOpen }">
<button class="map-legend__toggle" @click="legendOpen = !legendOpen"> <button class="map-legend__toggle" @click="toggleLegend">
<span class="mdi mdi-format-list-bulleted" /> <span class="mdi mdi-format-list-bulleted" />
<span class="map-legend__toggle-label">Legend</span> <span class="map-legend__toggle-label">Legend</span>
<span class="mdi" :class="legendOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'" /> <span class="mdi" :class="legendOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'" />
@@ -40,12 +40,11 @@
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick, PropType } from 'vue' import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick, PropType } from 'vue'
import maplibregl from 'maplibre-gl' import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'
import { usePage } from '@inertiajs/vue3'
import { Flight, Airport, SharedProps } from '@/Types/types' import { Flight, Airport, SharedProps } from '@/Types/types'
import PlaneLoader from '@/Components/FlightsGoneBy/PlaneLoader.vue' import PlaneLoader from '@/Components/FlightsGoneBy/PlaneLoader.vue'
import type { Feature, FeatureCollection, LineString, Point } from 'geojson' import type { Feature, FeatureCollection, LineString, Point } from 'geojson'
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
// ── Types ───────────────────────────────────────────────────────────────────── import {usePage} from "@inertiajs/vue3";
type LngLat = [number, number] type LngLat = [number, number]
@@ -256,6 +255,9 @@ export default defineComponent({
setup(props) { setup(props) {
const mapContainer = ref<HTMLDivElement | null>(null) const mapContainer = ref<HTMLDivElement | null>(null)
const mapReady = ref(false) const mapReady = ref(false)
const { updateSetting } = useUpdateSetting()
const page = usePage<SharedProps>().props
let map: maplibregl.Map | null = null let map: maplibregl.Map | null = null
let popup: maplibregl.Popup | null = null let popup: maplibregl.Popup | null = null
@@ -267,7 +269,12 @@ export default defineComponent({
let selectedAirportId: number | null = null let selectedAirportId: number | null = null
const legendOpen = ref(true) const legendOpen = ref(page.auth?.user?.resolved_settings?.show_map_legend ?? true)
function toggleLegend() {
legendOpen.value = !legendOpen.value
updateSetting('show_map_legend', legendOpen.value).catch(() => {})
}
const isGlobe = ref(false) const isGlobe = ref(false)
@@ -735,7 +742,7 @@ export default defineComponent({
if (map) { map.remove(); map = null } if (map) { map.remove(); map = null }
}) })
return { mapContainer, mapReady, exportMapBasic, legendOpen, legendItems, isGlobe, toggleProjection } return { mapContainer, mapReady, exportMapBasic, legendOpen, legendItems, isGlobe, toggleProjection, toggleLegend }
}, },
}) })
@@ -1,32 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
import { SharedProps } from '@/Types/types'
import { ref, onMounted, nextTick } from 'vue'
import { FlightStats } from '@/Composables/useFlightStats' import { FlightStats } from '@/Composables/useFlightStats'
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue"; import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue"
import FlightFilter from "@/Components/FlightsGoneBy/FlightFilter.vue"; import FlightStatsBar from "@/Components/FlightsGoneBy/FlightStatsBar.vue"
import FlightStatsBar from "@/Components/FlightsGoneBy/FlightStatsBar.vue"; import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue"
import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue";
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
const props = defineProps<{ const props = defineProps<{
stats: FlightStats stats: FlightStats
canEdit: boolean canEdit: boolean
}>() }>()
const emit = defineEmits<{
filtersChange: [filters: {
years: number[]
airlines: number[]
countries: string[]
continents: string[]
flightClasses: number[]
crewTypes: number[]
}]
}>()
const mappedFlights = computed(() => [ const mappedFlights = computed(() => [
...props.stats.pastFlights.value, ...props.stats.pastFlights.value,
...props.stats.upcomingFlights.value, ...props.stats.upcomingFlights.value,
@@ -36,11 +19,7 @@ const mappedFlights = computed(() => [
<template> <template>
<div> <div>
<FlightMap :flights="mappedFlights" /> <FlightMap :flights="mappedFlights" />
<FlightFilter :flights="mappedFlights" @change="$emit('filtersChange', $event)" />
<FlightStatsBar :flights="stats.pastFlights.value" :upcoming-flights="stats.upcomingFlights.value" /> <FlightStatsBar :flights="stats.pastFlights.value" :upcoming-flights="stats.upcomingFlights.value" />
<FlightCharts :stats="stats" :flight-stats="stats" /> <FlightCharts :stats="stats" :flight-stats="stats" />
<!-- <div v-else style="width:100%; display:flex; align-items: center;justify-content: center">
<PlaneLoader />
</div>-->
</div> </div>
</template> </template>
@@ -1,109 +1,8 @@
<template>
<div class="stats-bar glass">
<div class="stat">
<template v-if="flights.length">
<div class="stat-primary">
<span class="stat-num">{{ flights.length.toLocaleString() }}</span>
<span class="unit">flights</span>
</div>
</template>
<template v-if="upcomingFlights.length">
<div :class="flights.length ? 'stat-upcoming' : 'stat-primary'">
<span :class="flights.length ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingFlights.length.toLocaleString() }}</span>
<span :class="flights.length ? 'stat-upcoming-lbl' : 'unit'">{{ flights.length ? 'upcoming' : 'flights' }}</span>
<span v-if="!flights.length" class="upcoming-badge">upcoming</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="uniqueRoutes">
<div class="stat-primary">
<span class="stat-num">{{ uniqueRoutes.toLocaleString() }}</span>
<span class="unit">routes</span>
</div>
</template>
<template v-if="uniqueUpcomingRoutes">
<div :class="uniqueRoutes ? 'stat-upcoming' : 'stat-primary'">
<span :class="uniqueRoutes ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingRoutes.toLocaleString() }}</span>
<span :class="uniqueRoutes ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueRoutes ? 'upcoming' : 'routes' }}</span>
<span v-if="!uniqueRoutes" class="upcoming-badge">upcoming</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="totalDistanceKm">
<div class="stat-primary">
<span class="stat-num"><Distance :unit="page.auth?.user?.resolved_settings?.distance_unit" includeSpace :value="totalDistanceKm" /></span>
</div>
</template>
<template v-if="upcomingDistanceKm">
<div :class="totalDistanceKm ? 'stat-upcoming' : 'stat-primary'">
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'"><Distance :unit="page.auth?.user?.resolved_settings?.distance_unit" includeSpace :value="upcomingDistanceKm"/>
<span :class="upcomingDistanceKm ? 'stat-upcoming-lbl' : 'unit'"> upcoming</span>
</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="uniqueCountries">
<div class="stat-primary">
<span class="stat-num">{{ uniqueCountries }}</span>
<span class="unit">countries</span>
</div>
</template>
<template v-if="uniqueUpcomingCountries">
<div :class="uniqueCountries ? 'stat-upcoming' : 'stat-primary'">
<span :class="uniqueCountries ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingCountries }}</span>
<span :class="uniqueCountries ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueCountries ? 'upcoming' : 'countries' }}</span>
<span v-if="!uniqueCountries" class="upcoming-badge">upcoming</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="uniqueAirports">
<div class="stat-primary">
<span class="stat-num">{{ uniqueAirports }}</span>
<span class="unit">airports</span>
</div>
</template>
<template v-if="uniqueUpcomingAirports">
<div :class="uniqueAirports ? 'stat-upcoming' : 'stat-primary'">
<span :class="uniqueAirports ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingAirports }}</span>
<span :class="uniqueAirports ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueAirports ? 'upcoming' : 'airports' }}</span>
<span v-if="!uniqueAirports" class="upcoming-badge">upcoming</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="durationDisplay.hours">
<div class="stat-primary">
<span class="stat-num">{{ durationDisplay.hours.toLocaleString() }}</span>
<span class="unit">hours in the air</span>
</div>
<div class="stat-sub">{{ durationDisplay.days }} days · {{ durationDisplay.weeks }} weeks</div>
</template>
<template v-if="upcomingDurationDisplay.hours">
<div :class="durationDisplay.hours ? 'stat-upcoming' : 'stat-primary'">
<span :class="durationDisplay.hours ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingDurationDisplay.hours.toLocaleString() }}</span>
<span :class="durationDisplay.hours ? 'stat-upcoming-lbl' : 'unit'">{{ durationDisplay.hours ? 'hrs upcoming' : 'hours in the air' }}</span>
<span v-if="!durationDisplay.hours" class="upcoming-badge">upcoming</span>
</div>
<div v-if="!durationDisplay.hours" class="stat-sub">{{ upcomingDurationDisplay.days }} days · {{ upcomingDurationDisplay.weeks }} weeks</div>
</template>
</div>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Flight, SharedProps } from '@/Types/types' import type { Flight, SharedProps } from '@/Types/types'
import Distance from "@/Components/Distance.vue"; import StatItem from "@/Components/StatItem.vue"
import {usePage} from "@inertiajs/vue3"; import { usePage } from "@inertiajs/vue3"
const props = defineProps<{ const props = defineProps<{
flights: Flight[] flights: Flight[]
@@ -197,8 +96,63 @@ const upcomingDurationDisplay = computed(() => {
weeks: Math.floor(totalHours / 24 / 7), weeks: Math.floor(totalHours / 24 / 7),
} }
}) })
const distanceUnit = computed(() => page.auth?.user?.resolved_settings?.distance_unit)
</script> </script>
<template>
<div class="stats-bar glass">
<StatItem
:primary="flights.length || null"
unit="flights"
:upcoming="upcomingFlights.length || null"
:upcoming-label="flights.length ? 'upcoming' : 'flights'"
:show-upcoming-badge="!flights.length"
/>
<StatItem
:primary="uniqueRoutes || null"
unit="routes"
:upcoming="uniqueUpcomingRoutes || null"
:upcoming-label="uniqueRoutes ? 'upcoming' : 'routes'"
:show-upcoming-badge="!uniqueRoutes"
/>
<StatItem
:primary="totalDistanceKm || null"
:upcoming="upcomingDistanceKm || null"
:upcoming-label="totalDistanceKm ? 'upcoming' : undefined"
is-distance
:distance-unit="distanceUnit"
/>
<StatItem
:primary="uniqueCountries || null"
unit="countries"
:upcoming="uniqueUpcomingCountries || null"
:upcoming-label="uniqueCountries ? 'upcoming' : 'countries'"
:show-upcoming-badge="!uniqueCountries"
/>
<StatItem
:primary="uniqueAirports || null"
unit="airports"
:upcoming="uniqueUpcomingAirports || null"
:upcoming-label="uniqueAirports ? 'upcoming' : 'airports'"
:show-upcoming-badge="!uniqueAirports"
/>
<StatItem
:primary="durationDisplay.hours || null"
unit="hours in the air"
:upcoming="upcomingDurationDisplay.hours || null"
:upcoming-label="durationDisplay.hours ? 'hrs upcoming' : 'hours in the air'"
:show-upcoming-badge="!durationDisplay.hours"
:sub="!durationDisplay.hours ? `${upcomingDurationDisplay.days} days · ${upcomingDurationDisplay.weeks} weeks` : undefined"
/>
</div>
</template>
<style scoped> <style scoped>
.stats-bar { .stats-bar {
display: grid; display: grid;
@@ -209,94 +163,11 @@ const upcomingDurationDisplay = computed(() => {
margin-top: 12px; margin-top: 12px;
} }
.stat {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-primary {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
}
.stat-num {
font-size: 26px;
font-weight: 500;
color: #e0e6f0;
letter-spacing: -0.5px;
line-height: 1;
}
.unit {
font-size: 13px;
font-weight: 400;
color: #3a5566;
}
.stat-sub {
font-size: 11px;
color: #334455;
margin-top: 1px;
}
.stat-upcoming {
display: flex;
align-items: baseline;
gap: 5px;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.stat-upcoming-num {
font-size: 13px;
font-weight: 500;
color: #4a8fa8;
}
.stat-upcoming-lbl {
font-size: 12px;
color: #335566;
}
.upcoming-badge {
font-size: 10px;
font-weight: 500;
color: #4a8fa8;
background: rgba(74, 143, 168, 0.12);
border: 1px solid rgba(74, 143, 168, 0.2);
border-radius: 4px;
padding: 1px 6px;
letter-spacing: 0.06em;
text-transform: uppercase;
align-self: center;
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.stats-bar { .stats-bar { grid-template-columns: repeat(3, minmax(0, 1fr)); }
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat {
padding: 14px 16px;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.stats-bar { .stats-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat {
padding: 12px 14px;
}
.stat-num {
font-size: 22px;
}
} }
</style> </style>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import {computed, ref} from "vue";
import {usePage} from "@inertiajs/vue3";
import type {SharedProps, User} from "@/Types/types";
type FollowStatus = 'following' | 'requested' | 'none'
const props = defineProps<{
user: User
followStatus: FollowStatus
}>()
const snackbar = ref(false)
const snackbarMessage = ref('')
const status = ref<FollowStatus>(props.followStatus)
const processing = ref(false)
const auth = usePage<SharedProps>().props.auth
const isOwnProfile = computed(() => auth.user?.id == props.user.id)
const isLoggedIn = computed(() => !!auth.user)
const buttonLabel = computed(() => {
switch (status.value) {
case 'following': return 'Following'
case 'requested': return 'Request sent'
default: return '+ Follow'
}
})
const follow = async () => {
processing.value = true
const response = await fetch(route('profile.follow', { user: props.user.name }), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
},
})
const data = await response.json()
status.value = data.status
snackbarMessage.value = data.status === 'following'
? `You are now following ${props.user.name}`
: data.status === 'requested'
? `Follow request sent to ${props.user.name}`
: `You unfollowed ${props.user.name}`
snackbar.value = true
processing.value = false
}
</script>
<template>
<button
v-if="isLoggedIn && !isOwnProfile"
class="follow-btn"
:disabled="processing"
@click="follow"
>
{{ buttonLabel }}
</button>
<v-snackbar v-model="snackbar" :timeout="5000" color="#ffc107" location="bottom center">
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="snackbar = false">Close</v-btn>
</template>
</v-snackbar>
</template>
<style scoped>
.follow-btn {
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.12em;
color: #ffc107;
background: none;
border: 1px solid rgba(255, 193, 7, 0.35);
border-radius: 4px;
padding: 0.3em 0.85em;
cursor: pointer;
transition: background 0.15s ease;
white-space: nowrap;
align-self: center;
}
.follow-btn:hover:not(:disabled) {
background: rgba(255, 193, 7, 0.1);
}
.follow-btn:disabled {
opacity: 0.5;
cursor: default;
}
</style>
@@ -18,6 +18,7 @@ defineProps<{
width: clamp(280px, 100%, 700px); width: clamp(280px, 100%, 700px);
gap: 1em; gap: 1em;
padding: 2em; padding: 2em;
margin: 2em;
} }
h2 { h2 {
@@ -51,7 +51,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</button> </button>
<div v-if="dropdownOpen" class="dropdown-menu"> <div v-if="dropdownOpen" class="dropdown-menu">
<Link :href="route('import.fr24')" class="dropdown-item">Import from FR24</Link> <Link :href="route('import.fr24')" class="dropdown-item">Import from FR24</Link>
<Link :href="route('profile.settings')" class="dropdown-item">Settings</Link> <Link :href="route('user.settings')" class="dropdown-item">Settings</Link>
<div class="dropdown-divider" /> <div class="dropdown-divider" />
<button class="dropdown-item dropdown-item--danger" @click="logout">Log Out</button> <button class="dropdown-item dropdown-item--danger" @click="logout">Log Out</button>
</div> </div>
@@ -77,7 +77,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<Link :href="route('profile.view', { user: page.props.auth.user.name })" class="nav-link" @click="menuOpen = false">Profile</Link> <Link :href="route('profile.view', { user: page.props.auth.user.name })" class="nav-link" @click="menuOpen = false">Profile</Link>
<Link :href="route('feed')" class="nav-link nav-link" @click="menuOpen = false">Feed</Link> <Link :href="route('feed')" class="nav-link nav-link" @click="menuOpen = false">Feed</Link>
<Link :href="route('import.fr24')" class="nav-link" @click="menuOpen = false">Import from FR24</Link> <Link :href="route('import.fr24')" class="nav-link" @click="menuOpen = false">Import from FR24</Link>
<Link :href="route('profile.settings')" class="nav-link" @click="menuOpen = false">Settings</Link> <Link :href="route('user.settings')" class="nav-link" @click="menuOpen = false">Settings</Link>
<div class="dropdown-divider" /> <div class="dropdown-divider" />
<button class="nav-link nav-link--danger" @click="logout">Log Out</button> <button class="nav-link nav-link--danger" @click="logout">Log Out</button>
</template> </template>
@@ -3,11 +3,17 @@ import {ref, watch} from 'vue'
import axios from "axios"; import axios from "axios";
import {Notification} from "@/Types/types"; import {Notification} from "@/Types/types";
import {Link} from "@inertiajs/vue3"; import {Link} from "@inertiajs/vue3";
import {local} from "laravel-vite-plugin/fonts";
const props = defineProps<{ const props = defineProps<{
unreadCount: number unreadCount: number
}>() }>()
const localUnreadCount = ref(props.unreadCount)
watch(() => props.unreadCount, (val) => {
localUnreadCount.value = val
})
const open = ref(false) const open = ref(false)
const notifications = ref<Notification[]>([]) const notifications = ref<Notification[]>([])
@@ -26,20 +32,25 @@ const emit = defineEmits<{
}>() }>()
watch(open, async (isOpen) => { watch(open, async (isOpen) => {
if (!isOpen || notifications.value.length) return if (!isOpen) return
localUnreadCount.value = 0
emit('update:unreadCount', 0)
if (notifications.value.length) return
loading.value = true loading.value = true
const { data } = await axios.get('/notifications') const { data } = await axios.get('/notifications')
notifications.value = data notifications.value = data
loading.value = false loading.value = false
await markAllRead(notifications.value) await markAllRead(notifications.value)
emit('update:unreadCount', 0) // <-- add this
}) })
</script> </script>
<template> <template>
<div class="notif-wrapper"> <div class="notif-wrapper">
<v-btn icon variant="text" @click="open = !open" aria-label="Notifications"> <v-btn icon variant="text" @click="open = !open" aria-label="Notifications">
<v-badge :content="unreadCount" :model-value="unreadCount > 0" color="primary"> <v-badge :content="localUnreadCount" :model-value="localUnreadCount > 0" color="primary">
<v-icon>mdi-bell-outline</v-icon> <v-icon>mdi-bell-outline</v-icon>
</v-badge> </v-badge>
</v-btn> </v-btn>
@@ -0,0 +1,43 @@
<script setup lang="ts">
defineProps<{
name?: string
}>()
</script>
<template>
<div class="private-panel glass glass-border">
<span class="private-icon mdi mdi-lock-outline"></span>
<span class="private-title">PRIVATE PROFILE</span>
<span class="private-sub">{{ name ? `${name}'s profile is private. You can request to follow them for access.` : 'This profile is private' }}</span>
</div>
</template>
<style scoped>
.private-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 4rem 2rem;
}
.private-icon {
font-size: 1.6rem;
color: #556;
margin-bottom: 0.2rem;
}
.private-title {
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
letter-spacing: 0.2em;
color: #778899;
}
.private-sub {
font-family: 'Barlow', sans-serif;
font-size: 0.82rem;
color: #445;
}
</style>
@@ -3,24 +3,16 @@ import { usePage } from "@inertiajs/vue3";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import type { Flight, User, SharedProps } from "@/Types/types"; import type { Flight, User, SharedProps } from "@/Types/types";
import { Link } from "@inertiajs/vue3"; import { Link } from "@inertiajs/vue3";
import FollowButton from "@/Components/FlightsGoneBy/FollowButton.vue";
const props = defineProps<{ const props = defineProps<{
user: User user: User
flightCount?: number flightCount?: number
achievementCount?: number achievementCount?: number
isFollowing?: boolean followStatus?: string
show: "flights" | "achievements" show: "flights" | "achievements"
}>() }>()
const auth = usePage<SharedProps>().props.auth
const isOwnProfile = computed(() => auth.user?.id == props.user.id)
const isLoggedIn = computed(() => !!auth.user)
const following = ref(props.isFollowing ?? false)
const processing = ref(false)
const snackbar = ref(false)
const snackbarMessage = ref('')
const counts = computed(() => { const counts = computed(() => {
return { return {
flights: props.flightCount ?? 0, flights: props.flightCount ?? 0,
@@ -28,21 +20,7 @@ const counts = computed(() => {
} as Record<"flights" | "achievements", number> } as Record<"flights" | "achievements", number>
}) })
const follow = async () => {
processing.value = true
const response = await fetch(route('profile.follow', { user: props.user.name }), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
},
})
const data = await response.json()
following.value = data.following
snackbarMessage.value = data.following ? `You are now following ${props.user.name}` : `You unfollowed ${props.user.name}`
snackbar.value = true
processing.value = false
}
</script> </script>
<template> <template>
@@ -56,14 +34,7 @@ const follow = async () => {
{{ user.name }} {{ user.name }}
</Link> </Link>
</h1> </h1>
<button <FollowButton v-if="followStatus !== undefined" :user="user" :followStatus="followStatus" />
v-if="isLoggedIn && !isOwnProfile"
class="follow-btn"
:disabled="processing"
@click="follow"
>
{{ following ? 'Following' : '+ Follow' }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -73,13 +44,6 @@ const follow = async () => {
<span class="count-label">{{show.toUpperCase()}}</span> <span class="count-label">{{show.toUpperCase()}}</span>
</div> </div>
</div> </div>
<v-snackbar v-model="snackbar" :timeout="5000" color="#ffc107" location="bottom center">
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="snackbar = false">Close</v-btn>
</template>
</v-snackbar>
</template> </template>
<style scoped> <style scoped>
@@ -126,30 +90,6 @@ const follow = async () => {
padding: 1em padding: 1em
} }
.follow-btn {
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.12em;
color: #ffc107;
background: none;
border: 1px solid rgba(255, 193, 7, 0.35);
border-radius: 4px;
padding: 0.3em 0.85em;
cursor: pointer;
transition: background 0.15s ease;
white-space: nowrap;
align-self: center;
}
.follow-btn:hover:not(:disabled) {
background: rgba(255, 193, 7, 0.1);
}
.follow-btn:disabled {
opacity: 0.5;
cursor: default;
}
.board-count { .board-count {
text-align: right; text-align: right;
line-height: 1; line-height: 1;
@@ -2,23 +2,29 @@
import {Flight, User} from "@/Types/types"; import {Flight, User} from "@/Types/types";
import ProfileHeader from "@/Components/FlightsGoneBy/ProfileHeader.vue"; import ProfileHeader from "@/Components/FlightsGoneBy/ProfileHeader.vue";
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue"; import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
import PrivateProfileMessage from "@/Components/FlightsGoneBy/PrivateProfileMessage.vue";
import {Head} from "@inertiajs/vue3";
defineProps<{ defineProps<{
user: User user: User
flightCount?: number flightCount?: number
achievementCount? : number achievementCount? : number
isFollowing: boolean followStatus: string
loading: boolean loading: boolean
canView: boolean
title?: string
}>() }>()
</script> </script>
<template> <template>
<div class="board-wrapper"> <div class="board-wrapper">
<ProfileHeader :show="achievementCount && achievementCount > 0 ? 'achievements' : 'flights'" :is-following="isFollowing" :user="user" :flightCount="flightCount" :achievementCount="achievementCount" /> <Head :title="title" />
<ProfileHeader :show="achievementCount && achievementCount > 0 ? 'achievements' : 'flights'" :followStatus="followStatus" :user="user" :flightCount="flightCount" :achievementCount="achievementCount" />
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
<PlaneLoader /> <PlaneLoader />
</div> </div>
<slot v-else /> <slot v-else-if="canView" />
<PrivateProfileMessage :name="user.name" v-else />
</div> </div>
</template> </template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import Distance from "@/Components/Distance.vue";
import {DistanceUnit} from "@/Types/types";
defineProps<{
primary?: number | null
unit?: string
upcoming?: number | null
upcomingLabel?: string
showUpcomingBadge?: boolean
sub?: string
isDistance?: boolean
distanceUnit?: DistanceUnit
}>()
</script>
<template>
<div class="stat">
<template v-if="primary">
<div class="stat-primary">
<span class="stat-num">
<Distance v-if="isDistance" :unit="distanceUnit" includeSpace :value="primary" />
<template v-else>{{ primary.toLocaleString() }}</template>
</span>
<span v-if="unit && !isDistance" class="unit">{{ unit }}</span>
</div>
</template>
<template v-if="upcoming">
<div :class="primary ? 'stat-upcoming' : 'stat-primary'">
<span :class="primary ? 'stat-upcoming-num' : 'stat-num'">
<Distance v-if="isDistance" :unit="distanceUnit" includeSpace :value="upcoming" />
<template v-else>{{ upcoming.toLocaleString() }}</template>
{{ ' ' }}<span
v-if="upcomingLabel"
:class="primary ? 'stat-upcoming-lbl' : 'unit'"
>{{ upcomingLabel }}</span>
</span>
<span v-if="!primary && showUpcomingBadge" class="upcoming-badge">upcoming</span>
</div>
<div v-if="sub && !primary" class="stat-sub">{{ sub }}</div>
</template>
</div>
</template>
<style scoped>
.stat {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-primary {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
}
.stat-num {
font-size: 26px;
font-weight: 500;
color: #e0e6f0;
letter-spacing: -0.5px;
line-height: 1;
}
.unit {
font-size: 13px;
font-weight: 400;
color: #3a5566;
}
.stat-sub {
font-size: 11px;
color: #334455;
margin-top: 1px;
}
.stat-upcoming {
display: flex;
align-items: baseline;
gap: 5px;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.stat-upcoming-num {
font-size: 13px;
font-weight: 500;
color: #4a8fa8;
}
.stat-upcoming-lbl {
font-size: 12px;
color: #335566;
}
.upcoming-badge {
font-size: 10px;
font-weight: 500;
color: #4a8fa8;
background: rgba(74, 143, 168, 0.12);
border: 1px solid rgba(74, 143, 168, 0.2);
border-radius: 4px;
padding: 1px 6px;
letter-spacing: 0.06em;
text-transform: uppercase;
align-self: center;
}
@media (max-width: 1024px) {
.stat { padding: 14px 16px; }
}
@media (max-width: 640px) {
.stat { padding: 12px 14px; }
.stat-num { font-size: 22px; }
}
</style>
@@ -0,0 +1,80 @@
import { ref, type Ref } from 'vue'
import axios, { AxiosError } from 'axios'
import { usePage } from '@inertiajs/vue3'
import type { SharedProps } from '@/Types/types'
export type SettingValue = string | number | boolean | null | Record<string, unknown> | unknown[]
export interface UpdateSettingOptions {
endpoint?: (name: string) => string
method?: 'patch' | 'put' | 'post'
}
export interface UpdateSettingResponse<T = unknown> {
[key: string]: T
}
export interface UseUpdateSettingReturn {
updateSetting: <T = SettingValue>(name: string, value: T) => Promise<UpdateSettingResponse>
isUpdating: Ref<boolean>
error: Ref<string | null>
}
interface LaravelValidationErrorResponse {
message?: string
errors?: Record<string, string[]>
}
export function useUpdateSetting(options: UpdateSettingOptions = {}): UseUpdateSettingReturn {
const {
endpoint = (name: string) => `/settings/${name}`,
method = 'patch',
} = options
const isUpdating = ref(false)
const error = ref<string | null>(null)
async function updateSetting<T = SettingValue>(
name: string,
value: T
): Promise<UpdateSettingResponse> {
isUpdating.value = true
error.value = null
const page = usePage<SharedProps>()
if (!page.props.auth?.user) {
error.value = 'You must be logged in to update settings.'
return Promise.reject(new Error(error.value))
}
try {
const response = await axios.request<UpdateSettingResponse>({
method,
url: endpoint(name),
data: { value },
})
return response.data
} catch (err) {
const axiosError = err as AxiosError<LaravelValidationErrorResponse>
if (axiosError.response?.status === 401) {
error.value = 'You must be logged in to update settings.'
} else if (axiosError.response?.status === 422) {
error.value = axiosError.response.data?.message ?? 'Invalid setting value.'
} else {
error.value = 'Something went wrong updating your setting.'
}
throw err
} finally {
isUpdating.value = false
}
}
return {
updateSetting,
isUpdating,
error,
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ defineProps<{
<v-icon icon="mdi-chart-line" size="18" /> <v-icon icon="mdi-chart-line" size="18" />
Dashboard Dashboard
</Link> </Link>
<Link :href="route('admin.reconcile-missing-liveries')" class="sidebar-link"> <Link v-if="missingLiveryCount" :href="route('admin.reconcile-missing-liveries')" class="sidebar-link">
<v-icon icon="mdi-airplane-takeoff" size="18" /> <v-icon icon="mdi-airplane-takeoff" size="18" />
Reconcile Missing Liveries Reconcile Missing Liveries
<v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip> <v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip>
+1 -1
View File
@@ -17,7 +17,7 @@ defineProps<{
<v-row> <v-row>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
<GrowthCard label="Total Users" :count="userCount" :weekly-growth="oneWeekUserGrowth" icon="mdi-account"> <GrowthCard label="Total Users" :count="userCount" :weekly-growth="oneWeekUserGrowth" icon="mdi-account">
Latest User: {{ latestUser }} <small>Latest User: {{ latestUser }}</small>
</GrowthCard> </GrowthCard>
</v-col> </v-col>
<v-col cols="12" sm="6"> <v-col cols="12" sm="6">
+3 -14
View File
@@ -17,7 +17,7 @@ defineOptions({ layout: MainLayout })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
userAchievement: UserAchievement userAchievement: UserAchievement | null
user: User user: User
loggedInUser: User | null loggedInUser: User | null
isFollowing: boolean isFollowing: boolean
@@ -28,6 +28,7 @@ const props = defineProps<{
continents: Continent[] continents: Continent[]
aircraft_families: Record<string, string[]> aircraft_families: Record<string, string[]>
achievementCount: number achievementCount: number
canView: boolean
}>() }>()
@@ -53,22 +54,10 @@ const unlocked = computed(() => {
return true return true
}) })
const difficultyVariant = computed(() => {
switch (props.achievement.difficulty?.internal_name) {
case 'easy': return 'easy'
case 'moderate': return 'moderate'
case 'hard': return 'hard'
case 'expensive': return 'expensive'
case 'near_impossible': return 'near-impossible'
case 'impossible': return 'impossible'
default: return 'economy'
}
})
</script> </script>
<template> <template>
<ProfileLayout :achievementCount="achievementCount" :user="user" :isFollowing="isFollowing" :loading="flightsLoading"> <ProfileLayout :title="`${achievement.name}`" :canView="canView" :achievementCount="achievementCount" :user="user" :isFollowing="isFollowing" :loading="flightsLoading">
<Head :title="`${achievement.name}`" />
<div class="innerLayout"> <div class="innerLayout">
<ButtonLink variant="flat" icon="mdi-arrow-left" :label="`Back to ${user.name}'s Achievements`" :href="`${route('profile.achievements', { user: user.name })}#${achievement.internal_name}`" /> <ButtonLink variant="flat" icon="mdi-arrow-left" :label="`Back to ${user.name}'s Achievements`" :href="`${route('profile.achievements', { user: user.name })}#${achievement.internal_name}`" />
@@ -0,0 +1,52 @@
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import type { FollowerEntry } from "@/Types/types"
import FollowerCard from "@/Components/FlightsGoneBy/Admin/FollowerCard.vue";
const followers = ref<FollowerEntry[]>([])
const loading = ref(true)
onMounted(async () => {
const { data } = await axios.get<FollowerEntry[]>('/followers')
followers.value = data
loading.value = false
})
const pendingCount = computed(() => followers.value.filter(f => !f.verified).length)
function handleApproved(userId: number) {
const entry = followers.value.find(f => f.user.id === userId)
if (entry) entry.verified = true
}
function handleRemoved(userId: number) {
followers.value = followers.value.filter(f => f.user.id !== userId)
}
</script>
<template>
<div v-if="loading" class="d-flex justify-center py-8">
<v-progress-circular indeterminate color="primary" />
</div>
<div v-else>
<p v-if="pendingCount" class="text-body-2 text-medium-emphasis mb-3">
{{ pendingCount }} pending request{{ pendingCount > 1 ? 's' : '' }}
</p>
<p v-if="!followers.length" class="text-body-2 text-medium-emphasis">
No followers yet.
</p>
<FollowerCard
v-for="entry in followers"
:key="entry.user.id"
:entry="entry"
class="mb-2"
@approved="handleApproved(entry.user.id)"
@denied="handleRemoved(entry.user.id)"
@removed="handleRemoved(entry.user.id)"
/>
</div>
</template>
@@ -0,0 +1,131 @@
<script lang="ts" setup>
import { reactive, ref, computed } from 'vue'
import axios from 'axios'
import type { SettingField } from "@/Types/types"
const props = defineProps<{
fields: SettingField[],
categories: Record<string, string>
}>()
const values = reactive(
Object.fromEntries(props.fields.map(f => [f.key, f.value]))
)
const saving = ref(false)
const saved = ref(false)
const error = ref(false)
const groupedFields = computed(() =>
props.fields.reduce((groups: Record<string, SettingField[]>, field) => {
const cat = field.category ?? 'General'
;(groups[cat] ??= []).push(field)
return groups
}, {} as Record<string, SettingField[]>)
)
async function save() {
saving.value = true
error.value = false
try {
await axios.patch('/settings', { settings: values })
saved.value = true
setTimeout(() => saved.value = false, 3000)
} catch {
error.value = true
} finally {
saving.value = false
}
}
</script>
<template>
<v-form @submit.prevent="save">
<template v-for="(groupFields, category) in groupedFields" :key="category">
<p class="text-overline text-medium-emphasis mb-1 mt-4">{{ category }}</p>
<small v-if="categories[category]" class="text-body-2 text-medium-emphasis mb-3">
{{ categories[category] }}
</small>
<v-divider class="mb-4" />
<template v-for="field in groupFields" :key="field.key">
<v-select
v-if="field.type === 'select'"
v-model="values[field.key]"
:label="field.label"
:items="field.options"
item-title="label"
item-value="value"
variant="outlined"
density="comfortable"
class="mb-2"
/>
<v-text-field
v-else-if="field.type === 'text'"
v-model="values[field.key]"
:label="field.label"
variant="outlined"
density="comfortable"
class="mb-2"
/>
<v-checkbox
v-else-if="field.type === 'checkbox'"
v-model="values[field.key]"
:label="field.label"
color="primary"
density="comfortable"
hide-details
class="mb-2"
/>
<v-select
v-else-if="field.type === 'multiselect'"
v-model="values[field.key]"
:label="field.label"
:items="field.options"
item-title="label"
item-value="value"
variant="outlined"
density="comfortable"
chips
multiple
closable-chips
clearable
class="mb-2"
/>
</template>
</template>
<v-divider class="my-4" />
<div class="d-flex align-center gap-3">
<v-btn
type="submit"
color="primary"
variant="elevated"
:loading="saving"
min-width="140"
>
Save settings
</v-btn>
<v-fade-transition>
<div v-if="saved" class="d-flex align-center gap-1 text-success">
<v-icon size="18">mdi-check-circle</v-icon>
<span class="text-body-2">Saved</span>
</div>
</v-fade-transition>
<v-fade-transition>
<div v-if="error" class="d-flex align-center gap-1 text-error">
<v-icon size="18">mdi-alert-circle</v-icon>
<span class="text-body-2">Something went wrong</span>
</div>
</v-fade-transition>
</div>
</v-form>
</template>
+14 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue" import {ref, computed, watch} from "vue"
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue" import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue"
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue" import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue"
import AchievementCard from "@/Components/FlightsGoneBy/AchievementCard.vue" import AchievementCard from "@/Components/FlightsGoneBy/AchievementCard.vue"
@@ -8,23 +8,31 @@ import {Head, usePage} from "@inertiajs/vue3";
import MainLayout from "@/Layouts/MainLayout.vue"; import MainLayout from "@/Layouts/MainLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue"; import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue"; import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
const {updateSetting} = useUpdateSetting()
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
const props = defineProps<{ const props = defineProps<{
user: User user: User
canEdit: boolean canEdit: boolean
isFollowing: boolean followStatus: string
achievements: Record<string, Achievement[]> achievements: Record<string, Achievement[]>
userAchievements: Record<number, UserAchievement> userAchievements: Record<number, UserAchievement>
unlockedCount: number unlockedCount: number
totalAchievements: number totalAchievements: number
unlockedByCategory: Record<string, number> unlockedByCategory: Record<string, number>
canView: boolean
}>() }>()
const page = usePage<SharedProps>().props const page = usePage<SharedProps>().props
const hideImpossible = ref(false) const hideImpossible = ref(page.auth?.user?.resolved_settings?.hide_impossible_achievements ?? false)
watch(hideImpossible, (value) => {
updateSetting('hide_impossible_achievements', value).catch(() => {})
})
const filteredAchievements = computed(() => { const filteredAchievements = computed(() => {
if (!hideImpossible.value) return props.achievements if (!hideImpossible.value) return props.achievements
@@ -56,12 +64,13 @@ const filteredUnlockedCount = computed(() =>
</script> </script>
<template> <template>
<Head :title="`${user.name}'s Achievements`" />
<ProfileLayout <ProfileLayout
:user="user" :user="user"
:achievementCount="unlockedCount" :achievementCount="unlockedCount"
:is-following="isFollowing" :followStatus="followStatus"
:loading="false" :loading="false"
:canView="canView"
:title="`${user.name}'s Achievements`"
> >
<ProfileViewSwitcher active-view="achievements" :user="user" /> <ProfileViewSwitcher active-view="achievements" :user="user" />
+3 -3
View File
@@ -20,11 +20,13 @@ const props = defineProps<{
flightCount: number flightCount: number
isFollowing: boolean isFollowing: boolean
canEdit: boolean canEdit: boolean
canView: boolean
}>() }>()
</script> </script>
<template> <template>
<ProfileLayout <ProfileLayout
:canView="canView"
:user="user" :user="user"
:is-following="isFollowing" :is-following="isFollowing"
:flight-count="flightCount" :flight-count="flightCount"
@@ -39,9 +41,7 @@ const props = defineProps<{
<RoutePanel :flight="flight" /> <RoutePanel :flight="flight" />
<Panel label="Flight Details"> <Panel label="Flight Details">
<BoardingPass :user="user" :showToolTips="false" style="width:100%;max-width:600px; margin:0 auto" :flight="flight" :canEdit="canEdit" /> <BoardingPass :user="user" :showToolTips="false" style="width:100%;max-width:600px; margin:0 auto" :flight="flight" :canEdit="canEdit" />
<DetailRows> <DetailRows/>
</DetailRows>
</Panel> </Panel>
<AircraftPanel :flight="flight"/> <AircraftPanel :flight="flight"/>
<AirportPanel :airport="flight.departure_airport" label="Departure" /> <AirportPanel :airport="flight.departure_airport" label="Departure" />
+171 -22
View File
@@ -1,62 +1,115 @@
<script setup lang="ts"> <script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue"; import MainLayout from "@/Layouts/MainLayout.vue"
import { Head, router } from '@inertiajs/vue3' import { Head, router } from '@inertiajs/vue3'
import {computed, onMounted, ref, watchEffect} from 'vue' import { computed, ref, watchEffect } from 'vue'
import axios from 'axios' import { Flight, ProfileView, RegionRange, User, FlightRange, FlightScope } from "@/Types/types"
import {Flight, ProfileView, User} from "@/Types/types"
import { useFlightStats } from "@/Composables/useFlightStats" import { useFlightStats } from "@/Composables/useFlightStats"
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue" import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue"
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue" import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue"
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue" import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue"
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue"; import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue"
import FlightMapAndCharts from "@/Components/FlightsGoneBy/FlightMapAndCharts.vue"; import FlightMapAndCharts from "@/Components/FlightsGoneBy/FlightMapAndCharts.vue"
import {useFlights} from "@/Composables/useFlights"; import FlightFilter from "@/Components/FlightsGoneBy/FlightFilter.vue"
import { useFlights } from "@/Composables/useFlights"
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
const props = defineProps<{ const props = defineProps<{
user: User user: User
canEdit: boolean canEdit: boolean
canView: boolean
selectedFlightId?: number | null selectedFlightId?: number | null
initialView?: ProfileView initialView?: ProfileView
isFollowing: boolean followStatus: string
flight_api_url: string flight_api_url: string
flightCount: number, flightCount: number
}>() }>()
// ── Flights state ───────────────────────────────────────────────────────────── // ── Flights state ─────────────────────────────────────────────────────────────
const { flights, flightsLoading } = useFlights(props.flight_api_url) const { flights, flightsLoading } = useFlights(props.flight_api_url)
const localSelectedFlightId = ref(props.selectedFlightId ?? null) const localSelectedFlightId = ref(props.selectedFlightId ?? null)
// ── Filter state ────────────────────────────────────────────────────────────── // ── Filter state ──────────────────────────────────────────────────────────────
const filtersOpen = ref(window.innerWidth >= 1024)
const selectedYears = ref<number[]>([]) const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([]) const selectedAirlines = ref<number[]>([])
const selectedAlliances = ref<number[]>([])
const selectedCountries = ref<string[]>([]) const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([]) const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([]) const selectedFlightClasses = ref<number[]>([])
const selectedCrewTypes = ref<number[]>([]) const selectedCrewTypes = ref<number[]>([])
const selectedFlightScopes = ref<FlightScope[]>([])
const selectedFlightRanges = ref<FlightRange[]>([])
const selectedRegionRanges = ref<RegionRange[]>([])
const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const activeFilterCount = computed(() =>
selectedYears.value.length +
selectedAirlines.value.length +
selectedAlliances.value.length +
selectedCountries.value.length +
selectedContinents.value.length +
selectedFlightClasses.value.length +
selectedCrewTypes.value.length +
selectedFlightScopes.value.length +
selectedFlightRanges.value.length +
selectedRegionRanges.value.length +
selectedFlightReasons.value.length +
selectedSeatTypes.value.length +
selectedManufacturers.value.length +
selectedAircraftModels.value.length +
selectedAirportRegions.value.length
)
function onFiltersChange(filters: { function onFiltersChange(filters: {
years: number[] years: number[]
airlines: number[] airlines: number[]
alliances: number[]
countries: string[] countries: string[]
continents: string[] continents: string[]
flightClasses: number[] flightClasses: number[]
crewTypes: number[] crewTypes: number[]
flightScopes: FlightScope[]
flightRanges: FlightRange[]
regionRanges: RegionRange[]
flightReasons: number[]
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
airportRegions: number[]
}) { }) {
localSelectedFlightId.value = null localSelectedFlightId.value = null
selectedYears.value = filters.years selectedYears.value = filters.years
selectedAirlines.value = filters.airlines selectedAirlines.value = filters.airlines
selectedAlliances.value = filters.alliances
selectedCountries.value = filters.countries selectedCountries.value = filters.countries
selectedContinents.value = filters.continents selectedContinents.value = filters.continents
selectedFlightClasses.value = filters.flightClasses selectedFlightClasses.value = filters.flightClasses
selectedCrewTypes.value = filters.crewTypes selectedCrewTypes.value = filters.crewTypes
selectedFlightScopes.value = filters.flightScopes
selectedFlightRanges.value = filters.flightRanges
selectedRegionRanges.value = filters.regionRanges
selectedFlightReasons.value = filters.flightReasons
selectedSeatTypes.value = filters.seatTypes
selectedManufacturers.value = filters.manufacturers
selectedAircraftModels.value = filters.aircraftModels
selectedAirportRegions.value = filters.airportRegions
} }
// ── Filtering ─────────────────────────────────────────────────────────────────
function matchesFilters(f: Flight): boolean { function matchesFilters(f: Flight): boolean {
const date = new Date(f.departure_date) const date = new Date(f.departure_date)
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
if (selectedAirlines.value.length && !selectedAirlines.value.includes(f.airline?.id ?? -1)) return false if (selectedAirlines.value.length && !selectedAirlines.value.includes(f.airline?.id ?? -1)) return false
if (selectedAlliances.value.length && !selectedAlliances.value.includes(f.airline?.alliance?.id ?? -1)) return false
if (selectedCountries.value.length) { if (selectedCountries.value.length) {
const depCode = f.departure_airport.region?.country?.code const depCode = f.departure_airport.region?.country?.code
const arrCode = f.arrival_airport.region?.country?.code const arrCode = f.arrival_airport.region?.country?.code
@@ -69,14 +122,23 @@ function matchesFilters(f: Flight): boolean {
} }
if (selectedFlightClasses.value.length && !selectedFlightClasses.value.includes(f.flight_class?.id ?? -1)) return false if (selectedFlightClasses.value.length && !selectedFlightClasses.value.includes(f.flight_class?.id ?? -1)) return false
if (selectedCrewTypes.value.length && !selectedCrewTypes.value.includes(f.crew_type?.id ?? -1)) return false if (selectedCrewTypes.value.length && !selectedCrewTypes.value.includes(f.crew_type?.id ?? -1)) return false
if (selectedFlightScopes.value.length && !selectedFlightScopes.value.includes(f.scope)) return false
if (selectedFlightRanges.value.length && !selectedFlightRanges.value.includes(f.range)) return false
if (selectedRegionRanges.value.length && !selectedRegionRanges.value.includes(f.region_range)) return false
if (selectedFlightReasons.value.length && !selectedFlightReasons.value.includes(f.flight_reason?.id ?? -1)) return false
if (selectedSeatTypes.value.length && !selectedSeatTypes.value.includes(f.seat_type?.id ?? -1)) return false
if (selectedManufacturers.value.length && !selectedManufacturers.value.includes(f.aircraft?.manufacturer_code ?? '')) return false
if (selectedAircraftModels.value.length && !selectedAircraftModels.value.includes(f.aircraft?.id ?? -1)) return false
if (selectedAirportRegions.value.length) {
const depRegion = f.departure_airport.region?.id
const arrRegion = f.arrival_airport.region?.id
if (!selectedAirportRegions.value.includes(depRegion ?? -1) &&
!selectedAirportRegions.value.includes(arrRegion ?? -1)) return false
}
return true return true
} }
const filteredFlights = computed(() => { const filteredFlights = computed(() => flights.value.filter(matchesFilters))
return flights.value.filter(matchesFilters)
})
const stats = useFlightStats(filteredFlights) const stats = useFlightStats(filteredFlights)
watchEffect(() => { watchEffect(() => {
@@ -91,6 +153,7 @@ watchEffect(() => {
}) })
// ── View switching ──────────────────────────────────────────────────────────── // ── View switching ────────────────────────────────────────────────────────────
const activeView = ref<ProfileView>(props.initialView ?? 'board') const activeView = ref<ProfileView>(props.initialView ?? 'board')
const routeNames: Record<Exclude<ProfileView, 'achievements'>, string> = { const routeNames: Record<Exclude<ProfileView, 'achievements'>, string> = {
@@ -99,6 +162,11 @@ const routeNames: Record<Exclude<ProfileView, 'achievements'>, string> = {
passes: 'profile.boarding-passes', passes: 'profile.boarding-passes',
} as const } as const
function toggleFilters(e: MouseEvent) {
filtersOpen.value = !filtersOpen.value
;(e.currentTarget as HTMLButtonElement).blur()
}
function switchView(view: ProfileView) { function switchView(view: ProfileView) {
if (view === 'achievements') { if (view === 'achievements') {
router.visit(route('profile.achievements', { user: props.user.name })) router.visit(route('profile.achievements', { user: props.user.name }))
@@ -120,16 +188,97 @@ function switchView(view: ProfileView) {
</script> </script>
<template> <template>
<Head :title="`${user.name}'s Flights`" /> <ProfileLayout :title="`${user.name}'s Flights`" :canView="canView" :followStatus="followStatus" :flightCount="flightCount" :user="user" :loading="flightsLoading">
<ProfileLayout :is-following="isFollowing" :flightCount="flightCount" :user="user" :loading="flightsLoading">
<div class="toolbar">
<ProfileViewSwitcher :user="user" :active-view="activeView" @update:active-view="switchView" /> <ProfileViewSwitcher :user="user" :active-view="activeView" @update:active-view="switchView" />
<button
class="filter-toggle"
:class="{ active: filtersOpen || activeFilterCount > 0 }"
@click="toggleFilters"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
Filters
<span v-if="activeFilterCount > 0" class="filter-badge">{{ activeFilterCount }}</span>
</button>
</div>
<div v-show="filtersOpen" class="filter-panel">
<FlightFilter :flights="flights" @change="onFiltersChange" />
</div>
<DepartureBoard v-if="activeView === 'board'" :flight-id="localSelectedFlightId" :flight-stats="stats" :canEdit="canEdit" :user="user" /> <DepartureBoard v-if="activeView === 'board'" :flight-id="localSelectedFlightId" :flight-stats="stats" :canEdit="canEdit" :user="user" />
<BoardingPasses v-if="activeView === 'passes'" :flight-stats="stats" :canEdit="canEdit" :user="user" /> <BoardingPasses v-if="activeView === 'passes'" :flight-stats="stats" :canEdit="canEdit" :user="user" />
<FlightMapAndCharts <FlightMapAndCharts v-if="activeView === 'map'" :stats="stats" :canEdit="canEdit" />
v-if="activeView === 'map'"
:stats="stats"
:canEdit="canEdit"
@filters-change="onFiltersChange"
/>
</ProfileLayout> </ProfileLayout>
</template> </template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid #334455;
border-radius: 6px;
color: #778899;
cursor: pointer;
font-size: 13px;
padding: 6px 12px;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.filter-toggle:focus {
outline: none;
}
@media (hover: hover) {
.filter-toggle:hover {
border-color: #4da6ff;
color: #4da6ff;
}
}
.filter-toggle.active {
border-color: #4da6ff;
color: #4da6ff;
}
.filter-badge {
background: #4da6ff;
color: #0a1628;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 2px 6px;
}
.filter-panel {
margin-bottom: 12px;
}
@media (max-width: 640px) {
.toolbar {
flex-direction: column;
align-items: flex-start;
}
.filter-toggle {
width: 100%;
justify-content: center;
}
}
</style>
+24 -120
View File
@@ -1,140 +1,44 @@
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref, computed } from 'vue'
import axios from 'axios'
import MainLayout from "@/Layouts/MainLayout.vue" import MainLayout from "@/Layouts/MainLayout.vue"
import GlassBox from "@/Components/FlightsGoneBy/GlassBox.vue" import GlassBox from "@/Components/FlightsGoneBy/GlassBox.vue"
import { Head } from "@inertiajs/vue3" import { Head } from "@inertiajs/vue3"
import {SettingField} from "@/Types/types"; import { ref, watch } from "vue"
import type { SettingField } from "@/Types/types"
import GeneralSettings from "@/Pages/Settings/GeneralSettings.vue"
import FollowerSettings from "@/Pages/Settings/FollowerSettings.vue"
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
const props = defineProps<{ const props = defineProps<{
fields: SettingField[], fields: SettingField[],
categories: Record<string, string> categories: Record<string, string>,
defaultTab: string
}>() }>()
const values = reactive( const tab = ref(props.defaultTab)
Object.fromEntries(props.fields.map(f => [f.key, f.value]))
)
const saving = ref(false)
const saved = ref(false)
const error = ref(false)
const groupedFields = computed(() => watch(tab, (value) => {
props.fields.reduce((groups: Record<string, SettingField[]>, field) => { const path = value === 'general' ? '/settings' : `/settings/${value}`
const cat = field.category ?? 'General' window.history.replaceState(window.history.state, '', path)
;(groups[cat] ??= []).push(field) })
return groups
}, {} as Record<string, SettingField[]>)
)
async function save() {
saving.value = true
error.value = false
try {
await axios.patch('/settings', { settings: values })
saved.value = true
setTimeout(() => saved.value = false, 3000)
} catch {
error.value = true
} finally {
saving.value = false
}
}
</script> </script>
<template> <template>
<Head title="Settings" /> <Head title="Settings" />
<GlassBox title="Your Settings"> <GlassBox title="Your Settings">
<v-form @submit.prevent="save"> <v-tabs v-model="tab" class="mb-4">
<template v-for="(groupFields, category) in groupedFields" :key="category"> <v-tab value="general">General</v-tab>
<p class="text-overline text-medium-emphasis mb-1 mt-4">{{ category }}</p> <v-tab value="followers">Followers</v-tab>
<small v-if="categories[category]" class="text-body-2 text-medium-emphasis mb-3"> </v-tabs>
{{ categories[category] }}
</small>
<v-divider class="mb-4" />
<template v-for="field in groupFields" :key="field.key"> <v-window v-model="tab">
<v-window-item value="general">
<GeneralSettings :fields="fields" :categories="categories" />
</v-window-item>
<v-select <v-window-item value="followers">
v-if="field.type === 'select'" <FollowerSettings />
v-model="values[field.key]" </v-window-item>
:label="field.label" </v-window>
:items="field.options"
item-title="label"
item-value="value"
variant="outlined"
density="comfortable"
class="mb-2"
/>
<v-text-field
v-else-if="field.type === 'text'"
v-model="values[field.key]"
:label="field.label"
variant="outlined"
density="comfortable"
class="mb-2"
/>
<v-checkbox
v-else-if="field.type === 'checkbox'"
v-model="values[field.key]"
:label="field.label"
color="primary"
density="comfortable"
hide-details
class="mb-2"
/>
<v-select
v-else-if="field.type === 'multiselect'"
v-model="values[field.key]"
:label="field.label"
:items="field.options"
item-title="label"
item-value="value"
variant="outlined"
density="comfortable"
chips
multiple
closable-chips
clearable
class="mb-2"
/>
</template>
</template>
<v-divider class="my-4" />
<div class="d-flex align-center gap-3">
<v-btn
type="submit"
color="primary"
variant="elevated"
:loading="saving"
min-width="140"
>
Save settings
</v-btn>
<v-fade-transition>
<div v-if="saved" class="d-flex align-center gap-1 text-success">
<v-icon size="18">mdi-check-circle</v-icon>
<span class="text-body-2">Saved</span>
</div>
</v-fade-transition>
<v-fade-transition>
<div v-if="error" class="d-flex align-center gap-1 text-error">
<v-icon size="18">mdi-alert-circle</v-icon>
<span class="text-body-2">Something went wrong</span>
</div>
</v-fade-transition>
</div>
</v-form>
</GlassBox> </GlassBox>
</template> </template>
+15
View File
@@ -27,9 +27,16 @@ export interface UserSettings {
show_ai_tail_logos: boolean show_ai_tail_logos: boolean
show_ai_livery_images: boolean show_ai_livery_images: boolean
departure_board_columns: string[] departure_board_columns: string[]
show_map_legend: boolean
hide_impossible_achievements: boolean
} }
export interface FollowerEntry {
user: User
verified: boolean
}
export type SettingType = 'select' | 'text' | 'checkbox' | 'multiselect' export type SettingType = 'select' | 'text' | 'checkbox' | 'multiselect'
export interface SettingOption { export interface SettingOption {
value: string value: string
@@ -260,6 +267,10 @@ export interface CrewType {
internal_name: string internal_name: string
} }
export type RegionRange = "intraregional" | "interregional"
export type FlightScope = 'domestic' | 'international'
export type FlightRange = 'intracontinental' | 'intercontinental'
export interface Flight { export interface Flight {
id: number id: number
flight_number: string | null flight_number: string | null
@@ -284,9 +295,13 @@ export interface Flight {
duration_display: string duration_display: string
duration: number duration: number
distance: number distance: number
scope: FlightScope
range: FlightRange
region_range: RegionRange
livery_url?: string livery_url?: string
} }
export interface MissingLivery { export interface MissingLivery {
airline_name: string; airline_name: string;
aircraft_display_name: string; aircraft_display_name: string;
+25 -13
View File
@@ -9,13 +9,15 @@ use App\Http\Controllers\Api\UserApiController;
use App\Http\Controllers\FeedController; use App\Http\Controllers\FeedController;
use App\Http\Controllers\FlightController; use App\Http\Controllers\FlightController;
use App\Http\Controllers\FlightImportController; use App\Http\Controllers\FlightImportController;
use App\Http\Controllers\FlightProfileController; use App\Http\Controllers\FollowerController;
use App\Http\Controllers\UserProfileController;
use App\Http\Controllers\LogoController; use App\Http\Controllers\LogoController;
use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\SearchController; use App\Http\Controllers\SearchController;
use App\Http\Controllers\SettingsController; use App\Http\Controllers\SettingsController;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
use App\Http\Controllers\UserFlightController;
use App\Models\Airline; use App\Models\Airline;
use App\Models\FlightClass; use App\Models\FlightClass;
use App\Models\FlightReason; use App\Models\FlightReason;
@@ -29,7 +31,7 @@ use Inertia\Inertia;
*/ */
Route::domain(config('app.domain'))->group( Route::domain(config('app.domain'))->group(
function() { function() {
Route::get('/', [FlightProfileController::class, 'index'])->name('home'); Route::get('/', [UserProfileController::class, 'index'])->name('home');
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
@@ -49,6 +51,10 @@ Route::domain(config('app.domain'))->group(
Route::put('/flights/{flight}', [FlightController::class, 'update'])->name('flights.update'); Route::put('/flights/{flight}', [FlightController::class, 'update'])->name('flights.update');
Route::delete('/flights/{flight}/{referrer?}', [FlightController::class, 'delete'])->name('flights.delete'); Route::delete('/flights/{flight}/{referrer?}', [FlightController::class, 'delete'])->name('flights.delete');
Route::patch('/settings/{key}', [SettingsController::class, 'updateSingle'])
->where('key', '[a-z_]+')
->name('settings.update-single');
Route::get('/import/fr24', [FlightImportController::class, 'showFr24Import'])->name('import.fr24'); Route::get('/import/fr24', [FlightImportController::class, 'showFr24Import'])->name('import.fr24');
Route::get('/reconcile', [FlightImportController::class, 'reconcile'])->name('reconcile');; Route::get('/reconcile', [FlightImportController::class, 'reconcile'])->name('reconcile');;
@@ -58,14 +64,21 @@ Route::domain(config('app.domain'))->group(
Route::post('/u/{user}/follow', [UserController::class, 'follow'])->name('profile.follow'); Route::post('/u/{user}/follow', [UserController::class, 'follow'])->name('profile.follow');
Route::get('/settings', [UserController::class, 'settings'])->name('profile.settings'); Route::get('/settings/{category?}', [UserController::class, 'settings'])->name('user.settings');
Route::patch('/settings', [SettingsController::class, 'update'])->name('settings.update');
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.get'); Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.get');
Route::patch('/notifications/{notification}/read', [NotificationController::class, 'markRead']); Route::patch('/notifications/{notification}/read', [NotificationController::class, 'markRead']);
Route::get('/feed', [FeedController::class, 'view'])->name('feed'); Route::get('/feed', [FeedController::class, 'view'])->name('feed');
Route::patch('/settings', [SettingsController::class, 'update'])->name('settings.update');
Route::get('/followers', [FollowerController::class, 'index'])->name('followers.index');
Route::post('/followers/{follower}/approve', [FollowerController::class, 'approve'])->name('followers.approve');
Route::post('/followers/{follower}/deny', [FollowerController::class, 'deny'])->name('followers.deny');
Route::delete('/followers/{follower}', [FollowerController::class, 'remove'])->name('followers.remove');
}); });
Route::post('/import/save', [FlightImportController::class, 'save'])->name('import.save'); Route::post('/import/save', [FlightImportController::class, 'save'])->name('import.save');
@@ -75,16 +88,15 @@ Route::domain(config('app.domain'))->group(
Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft'); Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');
Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports'); Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports');
//@Todo: Move to API Route::get('/data/user/{user}/flights', [UserFlightController::class, 'viewableFlights']);
Route::get('/data/user/{username}/flights', [UserApiController::class, 'flights']); Route::get('/u/{user}', [UserProfileController::class, 'view'])->name('profile.view');
Route::get('/u/{user}/map', [UserProfileController::class, 'map'])->name('profile.map');
Route::get('/u/{user}/departure-board/{flight?}', [UserProfileController::class, 'departureBoard'])->name('profile.departure-board');
Route::get('/u/{user}/boarding-passes', [UserProfileController::class, 'boardingPasses'])->name('profile.boarding-passes');
Route::get('/u/{user}/achievements', [UserProfileController::class, 'achievements'])->name('profile.achievements');
Route::get('/u/{user}/achievement/{achievement}', [UserProfileController::class, 'achievement'])->name('profile.achievement');
Route::get('/u/{user}/flight/{userFlight}', [UserProfileController::class, 'flight'])->name('profile.flight');
Route::get('/u/{user}', [FlightProfileController::class, 'view'])->name('profile.view');
Route::get('/u/{user}/map', [FlightProfileController::class, 'map'])->name('profile.map');
Route::get('/u/{user}/departure-board/{flight?}', [FlightProfileController::class, 'departureBoard'])->name('profile.departure-board');
Route::get('/u/{user}/boarding-passes', [FlightProfileController::class, 'boardingPasses'])->name('profile.boarding-passes');
Route::get('/u/{user}/achievements', [AchievementController::class, 'index'])->name('profile.achievements');
Route::get('/u/{user}/achievement/{achievement}', [AchievementController::class, 'specific'])->name('profile.achievement');
Route::get('/u/{user}/flight/{userFlight}', [FlightProfileController::class, 'flight'])->name('profile.flight');
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';