Updated Map View
This commit is contained in:
@@ -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.']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
'is_achievement' => false,
|
||||||
'url' => '/u/'. auth()->user()->name,
|
'url' => $canView ? '/u/' . auth()->user()->name : '/follow-requests',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json(['following' => true]);
|
return response()->json(['status' => $canView ? 'following' : 'requested']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function settings(){
|
public function approveRequest(User $follower): JsonResponse
|
||||||
|
{
|
||||||
|
$followee = Followee::where('user_id', $follower->id)
|
||||||
|
->where('followee_id', auth()->id())
|
||||||
|
->pending()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$followee->update(['verified' => true]);
|
||||||
|
|
||||||
|
Notification::create([
|
||||||
|
'user_id' => $follower->id,
|
||||||
|
'title' => 'Follow request accepted',
|
||||||
|
'body' => auth()->user()->name . ' accepted your follow request.',
|
||||||
|
'is_achievement' => false,
|
||||||
|
'url' => '/u/' . auth()->user()->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['approved' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function denyRequest(User $follower): JsonResponse
|
||||||
|
{
|
||||||
|
Followee::where('user_id', $follower->id)
|
||||||
|
->where('followee_id', auth()->id())
|
||||||
|
->pending()
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(['denied' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function settings(?string $category = null){
|
||||||
|
$allowedTabs = ['general', 'followers'];
|
||||||
$user = auth()->user();
|
$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',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+4
-4
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
+105
-6
@@ -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">, </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">, </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">, </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">, </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">, </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">, </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">, </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">, </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">, </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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Vendored
+15
@@ -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
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user