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']);
|
||||
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\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function follow(User $user): JsonResponse
|
||||
{
|
||||
abort_if($user->id === auth()->id(), 403);
|
||||
|
||||
$existing = Followee::where('user_id', auth()->id())
|
||||
->where('followee_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
return response()->json(['following' => false]);
|
||||
return response()->json(['status' => 'none']);
|
||||
}
|
||||
|
||||
$canView = Gate::allows('viewProfileData', $user);
|
||||
|
||||
Followee::create([
|
||||
'user_id' => auth()->id(),
|
||||
'followee_id' => $user->id,
|
||||
'verified' => $canView,
|
||||
]);
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'New follower',
|
||||
'body' => auth()->user()->name . ' is now following you.',
|
||||
'title' => $canView ? 'New follower' : 'Follow request',
|
||||
'body' => $canView
|
||||
? auth()->user()->name . ' is now following you.'
|
||||
: auth()->user()->name . ' wants to follow you.',
|
||||
'is_achievement' => false,
|
||||
'url' => '/u/'. auth()->user()->name,
|
||||
'url' => $canView ? '/u/' . auth()->user()->name : '/follow-requests',
|
||||
]);
|
||||
|
||||
return response()->json(['following' => true]);
|
||||
return response()->json(['status' => $canView ? 'following' : 'requested']);
|
||||
}
|
||||
|
||||
public function settings(){
|
||||
public function approveRequest(User $follower): JsonResponse
|
||||
{
|
||||
$followee = Followee::where('user_id', $follower->id)
|
||||
->where('followee_id', auth()->id())
|
||||
->pending()
|
||||
->firstOrFail();
|
||||
|
||||
$followee->update(['verified' => true]);
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $follower->id,
|
||||
'title' => 'Follow request accepted',
|
||||
'body' => auth()->user()->name . ' accepted your follow request.',
|
||||
'is_achievement' => false,
|
||||
'url' => '/u/' . auth()->user()->name,
|
||||
]);
|
||||
|
||||
return response()->json(['approved' => true]);
|
||||
}
|
||||
|
||||
public function denyRequest(User $follower): JsonResponse
|
||||
{
|
||||
Followee::where('user_id', $follower->id)
|
||||
->where('followee_id', auth()->id())
|
||||
->pending()
|
||||
->delete();
|
||||
|
||||
return response()->json(['denied' => true]);
|
||||
}
|
||||
|
||||
public function settings(?string $category = null){
|
||||
$allowedTabs = ['general', 'followers'];
|
||||
$user = auth()->user();
|
||||
$current = array_merge(SettingsRegistry::defaults(), $user->settings ?? []);
|
||||
$fields = array_map(fn($field) => array_merge($field, [
|
||||
@@ -50,6 +89,7 @@ class UserController extends Controller
|
||||
return Inertia::render('UserSettings', [
|
||||
'fields' => $fields,
|
||||
'categories' => SettingsRegistry::categories(),
|
||||
'defaultTab' => in_array($category, $allowedTabs, true) ? $category : 'general',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,26 @@ namespace App\Http\Controllers;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class UserFlightController extends Controller
|
||||
{
|
||||
|
||||
protected User $user;
|
||||
|
||||
function __construct(User $user){
|
||||
$this->user = $user;
|
||||
public function viewableFlights(User $user, ?Request $request = null)
|
||||
{
|
||||
if (Gate::denies('viewProfileData', $user)) {
|
||||
return response()->json([]);
|
||||
}
|
||||
return $this->flights($user, $request);
|
||||
}
|
||||
|
||||
public function flights(?Request $request = null)
|
||||
public function flights(User $user, ?Request $request = null)
|
||||
{
|
||||
return UserFlight::where('user_id', $this->user->id)
|
||||
$key = "user_flights_{$user->id}";
|
||||
|
||||
$json = Cache::remember($key, now()->addDays(30), function () use ($user) {
|
||||
return UserFlight::where('user_id', $user->id)
|
||||
->with([
|
||||
'departureAirport.region.country',
|
||||
'departureAirport.region.continent',
|
||||
@@ -31,8 +38,21 @@ class UserFlightController extends Controller
|
||||
'flightClass',
|
||||
'crewType'
|
||||
])
|
||||
->when($request?->boolean('departed_only'), fn($q) => $q->where('departure_date', '<=', now('UTC')))
|
||||
->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);
|
||||
}
|
||||
|
||||
function sortedRegions(): array
|
||||
{
|
||||
return $this
|
||||
->regions()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ class Followee extends Model
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'followee_id',
|
||||
'verified',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'verified' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
@@ -21,4 +26,14 @@ class Followee extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class, 'followee_id');
|
||||
}
|
||||
|
||||
public function scopeVerified($query)
|
||||
{
|
||||
return $query->where('verified', true);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('verified', false);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -48,6 +48,10 @@ class User extends Authenticatable
|
||||
$this->update(['settings' => array_merge($current, $values)]);
|
||||
}
|
||||
|
||||
function updateSetting($settingName, $value) : void{
|
||||
$this->updateSettings([$settingName => $value]);
|
||||
}
|
||||
|
||||
protected function resolvedSettings(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -80,11 +84,6 @@ class User extends Authenticatable
|
||||
return $this->where('name', 'ilike', $value)->firstOrFail();
|
||||
}
|
||||
|
||||
public function FlightController(): UserFlightController
|
||||
{
|
||||
return new UserFlightController($this);
|
||||
}
|
||||
|
||||
public function flights(): HasMany {
|
||||
return $this->hasMany(UserFlight::class);
|
||||
}
|
||||
@@ -114,7 +113,21 @@ class User extends Authenticatable
|
||||
|
||||
public function isFollowing(User $user): bool
|
||||
{
|
||||
return $this->following()->where('followee_id', $user->id)->exists();
|
||||
return $this->following()
|
||||
->where('followee_id', $user->id)
|
||||
->verified()
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function followStatus(User $user): string
|
||||
{
|
||||
$followee = $this->following()->where('followee_id', $user->id)->first();
|
||||
|
||||
if (!$followee) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return $followee->verified ? 'following' : 'requested';
|
||||
}
|
||||
|
||||
public function notifications(): HasMany
|
||||
|
||||
@@ -48,6 +48,9 @@ class UserFlight extends Model
|
||||
'duration_display',
|
||||
'distance',
|
||||
'livery_url',
|
||||
'scope',
|
||||
'range',
|
||||
'region_range'
|
||||
];
|
||||
|
||||
public function calculateGreatCircleDistance(): float{
|
||||
@@ -108,6 +111,26 @@ class UserFlight extends Model
|
||||
);
|
||||
}
|
||||
|
||||
protected function scope(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn() => $this->departureAirport->region->country_id == $this->arrivalAirport->region->country_id ? 'domestic' : 'international'
|
||||
);
|
||||
}
|
||||
protected function range(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn() => $this->departureAirport->region->continent_id == $this->arrivalAirport->region->continent_id ? 'intracontinental' : 'intercontinental'
|
||||
);
|
||||
}
|
||||
|
||||
protected function regionRange(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn() => $this->departureAirport->region_id == $this->arrivalAirport->region_id ? 'intraregional' : 'interregional'
|
||||
);
|
||||
}
|
||||
|
||||
protected function arrivalDayDifference(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class FlightObserver
|
||||
{
|
||||
protected function clearCache(UserFlight $flight): void
|
||||
{
|
||||
Cache::forget("user_flights_{$flight->user->id}");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Recalculate after a flight is created.
|
||||
*/
|
||||
public function created(UserFlight $flight): void
|
||||
{
|
||||
$flight->user->calculateAchievements();
|
||||
$this->clearCache($flight);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,6 +30,7 @@ class FlightObserver
|
||||
public function updated(UserFlight $flight): void
|
||||
{
|
||||
$flight->user->calculateAchievements();
|
||||
$this->clearCache($flight);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,5 +40,6 @@ class FlightObserver
|
||||
public function deleted(UserFlight $flight): void
|
||||
{
|
||||
$flight->user->calculateAchievements();
|
||||
$this->clearCache($flight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'show_map_legend',
|
||||
'type' => 'checkbox',
|
||||
'label' => 'Expand Map Legend By Default',
|
||||
'category' => 'FlightsGoneBy Settings',
|
||||
'default' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'hide_impossible_achievements',
|
||||
'type' => 'checkbox',
|
||||
'label' => 'Hide Impossible Achievements By Default',
|
||||
'category' => 'FlightsGoneBy Settings',
|
||||
'default' => true,
|
||||
],
|
||||
[
|
||||
'category' => 'AI Generated Content',
|
||||
'key' => 'ai_liveries',
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->respond(function (Response $response, Throwable $e, Request $request) {
|
||||
$exceptions->respond(function ($response, Throwable $e, Request $request) {
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
$errors = [
|
||||
|
||||
@@ -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",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue3-apexcharts": "^1.11.1",
|
||||
"vuetify": "^4.0.5"
|
||||
"vuetify": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inertiajs/vue3": "^2.0.0",
|
||||
@@ -5263,9 +5263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vuetify": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.1.tgz",
|
||||
"integrity": "sha512-m75ZkDIBwstDhID/zPtQmh9m601G/C4wnvCmlA6hgvhD7EOwR9I4DClpxPQiNTd1toKWHCr3kD2XJqU1fh0tYA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.2.tgz",
|
||||
"integrity": "sha512-2SUzXt2Q71/cVmSZhU6kOmAo1ev3NZaI/ynj55TTcu6jXAc9kezoqZKY+Xm+0STnAOcCoTseF0qPSs01LjUaaA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
||||
+1
-1
@@ -36,6 +36,6 @@
|
||||
"maplibre-gl": "^5.22.0",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue3-apexcharts": "^1.11.1",
|
||||
"vuetify": "^4.0.5"
|
||||
"vuetify": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import FormattedNumber from "@/Components/FormattedNumber.vue";
|
||||
import {DistanceUnit} from "@/Types/types";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value: number
|
||||
unit?: 'km' | 'mi' | 'nm'
|
||||
unit?: DistanceUnit
|
||||
showUnits?: 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">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import VueApexCharts from "vue3-apexcharts"
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -12,18 +12,58 @@ const props = defineProps<{
|
||||
barHeight?: number
|
||||
colors?: string[]
|
||||
options?: object
|
||||
limit?: number
|
||||
}>()
|
||||
|
||||
const BAR_HEIGHT = computed(() => props.barHeight ?? 32)
|
||||
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 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`
|
||||
})
|
||||
|
||||
// ── Tooltip state (exposed to parent via scoped slot) ─────────────────────────
|
||||
// ── Tooltip state ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tooltipVisible = ref(false)
|
||||
const tooltipX = ref(0)
|
||||
@@ -74,7 +114,7 @@ const chartOptions = computed(() => ({
|
||||
colors: props.colors ?? ['#4da6ff', '#ffc107'],
|
||||
dataLabels: { enabled: false },
|
||||
xaxis: {
|
||||
categories: props.categories,
|
||||
categories: visibleCategories.value,
|
||||
labels: { show: false },
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false },
|
||||
@@ -113,7 +153,7 @@ const chartOptions = computed(() => ({
|
||||
type="bar"
|
||||
:height="chartHeight"
|
||||
:options="options ?? chartOptions"
|
||||
:series="series"
|
||||
:series="visibleSeries"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -125,6 +165,22 @@ const chartOptions = computed(() => ({
|
||||
: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">
|
||||
<span class="total-count">{{ footerValue }}</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-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 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -32,6 +32,8 @@ const chartEvents = computed(() => ({
|
||||
|
||||
<template>
|
||||
<ScrollingHorizontalBarChart
|
||||
:limit="18
|
||||
"
|
||||
title="Top countries"
|
||||
:series="flightStats.countries.value.series"
|
||||
:categories="countries.map(c => c.name)"
|
||||
|
||||
@@ -41,6 +41,7 @@ const chartEvents = computed(() => ({
|
||||
:events="chartEvents"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseleave="onMouseLeave"
|
||||
:limit="12"
|
||||
>
|
||||
<template #tooltip="{ visible, x, y, index }">
|
||||
<ChartTooltip :visible="visible" :x="x" :y="y">
|
||||
|
||||
@@ -40,6 +40,7 @@ const chartEvents = computed(() => ({
|
||||
:categories="airlines.map(a => a.name)"
|
||||
:footer-value="airlines.length"
|
||||
footer-label="total airlines"
|
||||
:limit="12"
|
||||
:events="chartEvents"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseleave="onMouseLeave"
|
||||
|
||||
@@ -42,6 +42,7 @@ const chartEvents = computed(() => ({
|
||||
:events="chartEvents"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseleave="onMouseLeave"
|
||||
:limit="12"
|
||||
>
|
||||
<template #tooltip="{ visible, x, y, index }">
|
||||
<ChartTooltip :visible="visible" :x="x" :y="y">
|
||||
|
||||
@@ -31,6 +31,7 @@ const series = computed(() => props.flightStats.topRoutes.value.series)
|
||||
:footer-value="routes.length"
|
||||
footer-label="total routes"
|
||||
:max-visible="12"
|
||||
:limit="12"
|
||||
>
|
||||
<template #tooltip="{ visible, x, y, index }">
|
||||
<ChartTooltip :visible="visible" :x="x" :y="y">
|
||||
|
||||
@@ -47,7 +47,8 @@ const CLASS_ORDER: Record<string, number> = {
|
||||
'Premium Economy': 2,
|
||||
'Business': 3,
|
||||
'First': 4,
|
||||
'Private': 5
|
||||
'Private': 5,
|
||||
'Crew' : 6,
|
||||
}
|
||||
|
||||
const customKeySort = {
|
||||
@@ -57,6 +58,9 @@ const customKeySort = {
|
||||
airline: (a: Flight['airline'], b: Flight['airline']) => {
|
||||
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),
|
||||
departure_airport: (a: Flight['departure_airport'], b: Flight['departure_airport']) => {
|
||||
return (a?.display_code ?? '').localeCompare(b?.display_code ?? '')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type {Airline, Flight} from '@/Types/types'
|
||||
import { ref } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import type { Airline, Alliance, Flight, RegionRange, FlightRange, FlightScope } from '@/Types/types'
|
||||
import { ref, watch } from 'vue'
|
||||
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -11,31 +11,72 @@ const emit = defineEmits<{
|
||||
change: [filters: {
|
||||
years: number[]
|
||||
airlines: number[]
|
||||
alliances: number[]
|
||||
countries: string[]
|
||||
continents: string[]
|
||||
flightClasses: number[]
|
||||
crewTypes: number[]
|
||||
flightScopes: FlightScope[]
|
||||
flightRanges: FlightRange[]
|
||||
regionRanges: RegionRange[]
|
||||
flightReasons: number[]
|
||||
seatTypes: number[]
|
||||
manufacturers: string[]
|
||||
aircraftModels: number[]
|
||||
airportRegions: number[]
|
||||
}]
|
||||
}>()
|
||||
|
||||
// ── 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[]) {
|
||||
const years = new Set<number>()
|
||||
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 continents = new Map<string, { code: string; name: string }>()
|
||||
const classes = 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 => {
|
||||
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 })
|
||||
|
||||
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 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 (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 })
|
||||
|
||||
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 })
|
||||
|
||||
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 {
|
||||
years: [...years].sort((a, b) => b - a),
|
||||
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)),
|
||||
continents: [...continents.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)),
|
||||
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)
|
||||
|
||||
// ── Filter state ──────────────────────────────────────────────────────────────
|
||||
|
||||
const selectedYears = ref<number[]>([])
|
||||
const selectedAirlines = ref<number[]>([])
|
||||
const selectedAlliances = ref<number[]>([])
|
||||
const selectedCountries = ref<string[]>([])
|
||||
const selectedContinents = ref<string[]>([])
|
||||
const selectedFlightClasses = 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() {
|
||||
emit('change', {
|
||||
years: selectedYears.value,
|
||||
airlines: selectedAirlines.value,
|
||||
alliances: selectedAlliances.value,
|
||||
countries: selectedCountries.value,
|
||||
continents: selectedContinents.value,
|
||||
flightClasses: selectedFlightClasses.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 ───────────────────────────────────────────────────────────────────
|
||||
const page = usePage()
|
||||
|
||||
const countryFlagClass = (code: string) =>
|
||||
`fi fi-${code.toLowerCase()}`
|
||||
@@ -91,6 +205,7 @@ const countryFlagClass = (code: string) =>
|
||||
|
||||
<template>
|
||||
<div class="flight-filters">
|
||||
|
||||
<v-select
|
||||
v-model="selectedYears"
|
||||
:items="availableOptions.years"
|
||||
@@ -140,6 +255,35 @@ const countryFlagClass = (code: string) =>
|
||||
</template>
|
||||
</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-model="selectedCountries"
|
||||
:items="availableOptions.countries"
|
||||
@@ -169,6 +313,60 @@ const countryFlagClass = (code: string) =>
|
||||
</template>
|
||||
</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-model="selectedContinents"
|
||||
:items="availableOptions.continents"
|
||||
@@ -186,11 +384,29 @@ const countryFlagClass = (code: string) =>
|
||||
</template>
|
||||
</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-model="selectedFlightClasses"
|
||||
:items="availableOptions.classes"
|
||||
item-title="name" item-value="id"
|
||||
label="Flight class"
|
||||
label="Flight Class"
|
||||
multiple clearable hide-details
|
||||
density="compact" variant="outlined"
|
||||
@update:model-value="emitFilters"
|
||||
@@ -203,6 +419,78 @@ const countryFlagClass = (code: string) =>
|
||||
</template>
|
||||
</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-if="availableOptions.crewTypes.length > 0"
|
||||
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>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<p>No flight data available</p>
|
||||
</div>
|
||||
<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="map-legend__toggle-label">Legend</span>
|
||||
<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 maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import { Flight, Airport, SharedProps } from '@/Types/types'
|
||||
import PlaneLoader from '@/Components/FlightsGoneBy/PlaneLoader.vue'
|
||||
import type { Feature, FeatureCollection, LineString, Point } from 'geojson'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
|
||||
import {usePage} from "@inertiajs/vue3";
|
||||
|
||||
|
||||
type LngLat = [number, number]
|
||||
@@ -256,6 +255,9 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const mapContainer = ref<HTMLDivElement | null>(null)
|
||||
const mapReady = ref(false)
|
||||
const { updateSetting } = useUpdateSetting()
|
||||
const page = usePage<SharedProps>().props
|
||||
|
||||
|
||||
let map: maplibregl.Map | null = null
|
||||
let popup: maplibregl.Popup | null = null
|
||||
@@ -267,7 +269,12 @@ export default defineComponent({
|
||||
|
||||
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)
|
||||
|
||||
@@ -735,7 +742,7 @@ export default defineComponent({
|
||||
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">
|
||||
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 FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
|
||||
import FlightFilter from "@/Components/FlightsGoneBy/FlightFilter.vue";
|
||||
import FlightStatsBar from "@/Components/FlightsGoneBy/FlightStatsBar.vue";
|
||||
import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue";
|
||||
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
|
||||
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue"
|
||||
import FlightStatsBar from "@/Components/FlightsGoneBy/FlightStatsBar.vue"
|
||||
import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
stats: FlightStats
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
filtersChange: [filters: {
|
||||
years: number[]
|
||||
airlines: number[]
|
||||
countries: string[]
|
||||
continents: string[]
|
||||
flightClasses: number[]
|
||||
crewTypes: number[]
|
||||
}]
|
||||
}>()
|
||||
|
||||
|
||||
const mappedFlights = computed(() => [
|
||||
...props.stats.pastFlights.value,
|
||||
...props.stats.upcomingFlights.value,
|
||||
@@ -36,11 +19,7 @@ const mappedFlights = computed(() => [
|
||||
<template>
|
||||
<div>
|
||||
<FlightMap :flights="mappedFlights" />
|
||||
<FlightFilter :flights="mappedFlights" @change="$emit('filtersChange', $event)" />
|
||||
<FlightStatsBar :flights="stats.pastFlights.value" :upcoming-flights="stats.upcomingFlights.value" />
|
||||
<FlightCharts :stats="stats" :flight-stats="stats"/>
|
||||
<!-- <div v-else style="width:100%; display:flex; align-items: center;justify-content: center">
|
||||
<PlaneLoader />
|
||||
</div>-->
|
||||
<FlightCharts :stats="stats" :flight-stats="stats" />
|
||||
</div>
|
||||
</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">
|
||||
import { computed } from 'vue'
|
||||
import type {Flight, SharedProps} from '@/Types/types'
|
||||
import Distance from "@/Components/Distance.vue";
|
||||
import {usePage} from "@inertiajs/vue3";
|
||||
import type { Flight, SharedProps } from '@/Types/types'
|
||||
import StatItem from "@/Components/StatItem.vue"
|
||||
import { usePage } from "@inertiajs/vue3"
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -197,8 +96,63 @@ const upcomingDurationDisplay = computed(() => {
|
||||
weeks: Math.floor(totalHours / 24 / 7),
|
||||
}
|
||||
})
|
||||
|
||||
const distanceUnit = computed(() => page.auth?.user?.resolved_settings?.distance_unit)
|
||||
</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>
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
@@ -209,94 +163,11 @@ const upcomingDurationDisplay = computed(() => {
|
||||
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) {
|
||||
.stats-bar {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.stats-bar { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-bar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 22px;
|
||||
}
|
||||
.stats-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</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);
|
||||
gap: 1em;
|
||||
padding: 2em;
|
||||
margin: 2em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
|
||||
@@ -51,7 +51,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||
</button>
|
||||
<div v-if="dropdownOpen" class="dropdown-menu">
|
||||
<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" />
|
||||
<button class="dropdown-item dropdown-item--danger" @click="logout">Log Out</button>
|
||||
</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('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('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" />
|
||||
<button class="nav-link nav-link--danger" @click="logout">Log Out</button>
|
||||
</template>
|
||||
|
||||
@@ -3,11 +3,17 @@ import {ref, watch} from 'vue'
|
||||
import axios from "axios";
|
||||
import {Notification} from "@/Types/types";
|
||||
import {Link} from "@inertiajs/vue3";
|
||||
import {local} from "laravel-vite-plugin/fonts";
|
||||
|
||||
const props = defineProps<{
|
||||
unreadCount: number
|
||||
}>()
|
||||
|
||||
const localUnreadCount = ref(props.unreadCount)
|
||||
watch(() => props.unreadCount, (val) => {
|
||||
localUnreadCount.value = val
|
||||
})
|
||||
|
||||
|
||||
const open = ref(false)
|
||||
const notifications = ref<Notification[]>([])
|
||||
@@ -26,20 +32,25 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
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
|
||||
const { data } = await axios.get('/notifications')
|
||||
notifications.value = data
|
||||
loading.value = false
|
||||
await markAllRead(notifications.value)
|
||||
emit('update:unreadCount', 0) // <-- add this
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notif-wrapper">
|
||||
<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-badge>
|
||||
</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 type { Flight, User, SharedProps } from "@/Types/types";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import FollowButton from "@/Components/FlightsGoneBy/FollowButton.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
user: User
|
||||
flightCount?: number
|
||||
achievementCount?: number
|
||||
isFollowing?: boolean
|
||||
followStatus?: string
|
||||
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(() => {
|
||||
return {
|
||||
flights: props.flightCount ?? 0,
|
||||
@@ -28,21 +20,7 @@ const counts = computed(() => {
|
||||
} 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>
|
||||
|
||||
<template>
|
||||
@@ -56,14 +34,7 @@ const follow = async () => {
|
||||
{{ user.name }}
|
||||
</Link>
|
||||
</h1>
|
||||
<button
|
||||
v-if="isLoggedIn && !isOwnProfile"
|
||||
class="follow-btn"
|
||||
:disabled="processing"
|
||||
@click="follow"
|
||||
>
|
||||
{{ following ? 'Following' : '+ Follow' }}
|
||||
</button>
|
||||
<FollowButton v-if="followStatus !== undefined" :user="user" :followStatus="followStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,13 +44,6 @@ const follow = async () => {
|
||||
<span class="count-label">{{show.toUpperCase()}}</span>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -126,30 +90,6 @@ const follow = async () => {
|
||||
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 {
|
||||
text-align: right;
|
||||
line-height: 1;
|
||||
|
||||
@@ -2,23 +2,29 @@
|
||||
import {Flight, User} from "@/Types/types";
|
||||
import ProfileHeader from "@/Components/FlightsGoneBy/ProfileHeader.vue";
|
||||
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
|
||||
import PrivateProfileMessage from "@/Components/FlightsGoneBy/PrivateProfileMessage.vue";
|
||||
import {Head} from "@inertiajs/vue3";
|
||||
|
||||
defineProps<{
|
||||
user: User
|
||||
flightCount?: number
|
||||
achievementCount? : number
|
||||
isFollowing: boolean
|
||||
followStatus: string
|
||||
loading: boolean
|
||||
canView: boolean
|
||||
title?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<PlaneLoader />
|
||||
</div>
|
||||
<slot v-else />
|
||||
<slot v-else-if="canView" />
|
||||
<PrivateProfileMessage :name="user.name" v-else />
|
||||
</div>
|
||||
</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" />
|
||||
Dashboard
|
||||
</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" />
|
||||
Reconcile Missing Liveries
|
||||
<v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip>
|
||||
|
||||
@@ -17,7 +17,7 @@ defineProps<{
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<GrowthCard label="Total Users" :count="userCount" :weekly-growth="oneWeekUserGrowth" icon="mdi-account">
|
||||
Latest User: {{ latestUser }}
|
||||
<small>Latest User: {{ latestUser }}</small>
|
||||
</GrowthCard>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
|
||||
@@ -17,7 +17,7 @@ defineOptions({ layout: MainLayout })
|
||||
|
||||
const props = defineProps<{
|
||||
achievement: Achievement
|
||||
userAchievement: UserAchievement
|
||||
userAchievement: UserAchievement | null
|
||||
user: User
|
||||
loggedInUser: User | null
|
||||
isFollowing: boolean
|
||||
@@ -28,6 +28,7 @@ const props = defineProps<{
|
||||
continents: Continent[]
|
||||
aircraft_families: Record<string, string[]>
|
||||
achievementCount: number
|
||||
canView: boolean
|
||||
}>()
|
||||
|
||||
|
||||
@@ -53,22 +54,10 @@ const unlocked = computed(() => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<ProfileLayout :achievementCount="achievementCount" :user="user" :isFollowing="isFollowing" :loading="flightsLoading">
|
||||
<Head :title="`${achievement.name}`" />
|
||||
<ProfileLayout :title="`${achievement.name}`" :canView="canView" :achievementCount="achievementCount" :user="user" :isFollowing="isFollowing" :loading="flightsLoading">
|
||||
<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}`" />
|
||||
|
||||
|
||||
@@ -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">
|
||||
import { ref, computed } from "vue"
|
||||
import {ref, computed, watch} from "vue"
|
||||
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue"
|
||||
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.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 Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
|
||||
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
|
||||
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
|
||||
|
||||
const {updateSetting} = useUpdateSetting()
|
||||
|
||||
defineOptions({ layout: MainLayout })
|
||||
|
||||
const props = defineProps<{
|
||||
user: User
|
||||
canEdit: boolean
|
||||
isFollowing: boolean
|
||||
followStatus: string
|
||||
achievements: Record<string, Achievement[]>
|
||||
userAchievements: Record<number, UserAchievement>
|
||||
unlockedCount: number
|
||||
totalAchievements: number
|
||||
unlockedByCategory: Record<string, number>
|
||||
canView: boolean
|
||||
}>()
|
||||
|
||||
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(() => {
|
||||
if (!hideImpossible.value) return props.achievements
|
||||
@@ -56,12 +64,13 @@ const filteredUnlockedCount = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="`${user.name}'s Achievements`" />
|
||||
<ProfileLayout
|
||||
:user="user"
|
||||
:achievementCount="unlockedCount"
|
||||
:is-following="isFollowing"
|
||||
:followStatus="followStatus"
|
||||
:loading="false"
|
||||
:canView="canView"
|
||||
:title="`${user.name}'s Achievements`"
|
||||
>
|
||||
<ProfileViewSwitcher active-view="achievements" :user="user" />
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@ const props = defineProps<{
|
||||
flightCount: number
|
||||
isFollowing: boolean
|
||||
canEdit: boolean
|
||||
canView: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileLayout
|
||||
:canView="canView"
|
||||
:user="user"
|
||||
:is-following="isFollowing"
|
||||
:flight-count="flightCount"
|
||||
@@ -39,9 +41,7 @@ const props = defineProps<{
|
||||
<RoutePanel :flight="flight" />
|
||||
<Panel label="Flight Details">
|
||||
<BoardingPass :user="user" :showToolTips="false" style="width:100%;max-width:600px; margin:0 auto" :flight="flight" :canEdit="canEdit" />
|
||||
<DetailRows>
|
||||
|
||||
</DetailRows>
|
||||
<DetailRows/>
|
||||
</Panel>
|
||||
<AircraftPanel :flight="flight"/>
|
||||
<AirportPanel :airport="flight.departure_airport" label="Departure" />
|
||||
|
||||
@@ -1,62 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import MainLayout from "@/Layouts/MainLayout.vue";
|
||||
import {Head, router} from '@inertiajs/vue3'
|
||||
import {computed, onMounted, ref, watchEffect} from 'vue'
|
||||
import axios from 'axios'
|
||||
import {Flight, ProfileView, User} from "@/Types/types"
|
||||
import {useFlightStats} from "@/Composables/useFlightStats"
|
||||
import MainLayout from "@/Layouts/MainLayout.vue"
|
||||
import { Head, router } from '@inertiajs/vue3'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { Flight, ProfileView, RegionRange, User, FlightRange, FlightScope } from "@/Types/types"
|
||||
import { useFlightStats } from "@/Composables/useFlightStats"
|
||||
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue"
|
||||
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue"
|
||||
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue"
|
||||
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
|
||||
import FlightMapAndCharts from "@/Components/FlightsGoneBy/FlightMapAndCharts.vue";
|
||||
import {useFlights} from "@/Composables/useFlights";
|
||||
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue"
|
||||
import FlightMapAndCharts from "@/Components/FlightsGoneBy/FlightMapAndCharts.vue"
|
||||
import FlightFilter from "@/Components/FlightsGoneBy/FlightFilter.vue"
|
||||
import { useFlights } from "@/Composables/useFlights"
|
||||
|
||||
defineOptions({ layout: MainLayout })
|
||||
|
||||
const props = defineProps<{
|
||||
user: User
|
||||
canEdit: boolean
|
||||
canView: boolean
|
||||
selectedFlightId?: number | null
|
||||
initialView?: ProfileView
|
||||
isFollowing: boolean
|
||||
followStatus: string
|
||||
flight_api_url: string
|
||||
flightCount: number,
|
||||
flightCount: number
|
||||
}>()
|
||||
|
||||
// ── Flights state ─────────────────────────────────────────────────────────────
|
||||
|
||||
const { flights, flightsLoading } = useFlights(props.flight_api_url)
|
||||
const localSelectedFlightId = ref(props.selectedFlightId ?? null)
|
||||
|
||||
// ── Filter state ──────────────────────────────────────────────────────────────
|
||||
|
||||
const filtersOpen = ref(window.innerWidth >= 1024)
|
||||
|
||||
const selectedYears = ref<number[]>([])
|
||||
const selectedAirlines = ref<number[]>([])
|
||||
const selectedAlliances = ref<number[]>([])
|
||||
const selectedCountries = ref<string[]>([])
|
||||
const selectedContinents = ref<string[]>([])
|
||||
const selectedFlightClasses = 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: {
|
||||
years: number[]
|
||||
airlines: number[]
|
||||
alliances: number[]
|
||||
countries: string[]
|
||||
continents: string[]
|
||||
flightClasses: number[]
|
||||
crewTypes: number[]
|
||||
flightScopes: FlightScope[]
|
||||
flightRanges: FlightRange[]
|
||||
regionRanges: RegionRange[]
|
||||
flightReasons: number[]
|
||||
seatTypes: number[]
|
||||
manufacturers: string[]
|
||||
aircraftModels: number[]
|
||||
airportRegions: number[]
|
||||
}) {
|
||||
localSelectedFlightId.value = null
|
||||
selectedYears.value = filters.years
|
||||
selectedAirlines.value = filters.airlines
|
||||
selectedAlliances.value = filters.alliances
|
||||
selectedCountries.value = filters.countries
|
||||
selectedContinents.value = filters.continents
|
||||
selectedFlightClasses.value = filters.flightClasses
|
||||
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 {
|
||||
const date = new Date(f.departure_date)
|
||||
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 (selectedAlliances.value.length && !selectedAlliances.value.includes(f.airline?.alliance?.id ?? -1)) return false
|
||||
if (selectedCountries.value.length) {
|
||||
const depCode = f.departure_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 (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
|
||||
}
|
||||
|
||||
const filteredFlights = computed(() => {
|
||||
return flights.value.filter(matchesFilters)
|
||||
})
|
||||
|
||||
const filteredFlights = computed(() => flights.value.filter(matchesFilters))
|
||||
const stats = useFlightStats(filteredFlights)
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -91,6 +153,7 @@ watchEffect(() => {
|
||||
})
|
||||
|
||||
// ── View switching ────────────────────────────────────────────────────────────
|
||||
|
||||
const activeView = ref<ProfileView>(props.initialView ?? 'board')
|
||||
|
||||
const routeNames: Record<Exclude<ProfileView, 'achievements'>, string> = {
|
||||
@@ -99,6 +162,11 @@ const routeNames: Record<Exclude<ProfileView, 'achievements'>, string> = {
|
||||
passes: 'profile.boarding-passes',
|
||||
} as const
|
||||
|
||||
function toggleFilters(e: MouseEvent) {
|
||||
filtersOpen.value = !filtersOpen.value
|
||||
;(e.currentTarget as HTMLButtonElement).blur()
|
||||
}
|
||||
|
||||
function switchView(view: ProfileView) {
|
||||
if (view === 'achievements') {
|
||||
router.visit(route('profile.achievements', { user: props.user.name }))
|
||||
@@ -120,16 +188,97 @@ function switchView(view: ProfileView) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head :title="`${user.name}'s Flights`" />
|
||||
<ProfileLayout :is-following="isFollowing" :flightCount="flightCount" :user="user" :loading="flightsLoading">
|
||||
<ProfileLayout :title="`${user.name}'s Flights`" :canView="canView" :followStatus="followStatus" :flightCount="flightCount" :user="user" :loading="flightsLoading">
|
||||
|
||||
<div class="toolbar">
|
||||
<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" />
|
||||
<BoardingPasses v-if="activeView === 'passes'" :flight-stats="stats" :canEdit="canEdit" :user="user" />
|
||||
<FlightMapAndCharts
|
||||
v-if="activeView === 'map'"
|
||||
:stats="stats"
|
||||
:canEdit="canEdit"
|
||||
@filters-change="onFiltersChange"
|
||||
/>
|
||||
<FlightMapAndCharts v-if="activeView === 'map'" :stats="stats" :canEdit="canEdit" />
|
||||
|
||||
</ProfileLayout>
|
||||
</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>
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import MainLayout from "@/Layouts/MainLayout.vue"
|
||||
import GlassBox from "@/Components/FlightsGoneBy/GlassBox.vue"
|
||||
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 })
|
||||
|
||||
const props = defineProps<{
|
||||
fields: SettingField[],
|
||||
categories: Record<string, string>
|
||||
categories: Record<string, string>,
|
||||
defaultTab: 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 tab = ref(props.defaultTab)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
watch(tab, (value) => {
|
||||
const path = value === 'general' ? '/settings' : `/settings/${value}`
|
||||
window.history.replaceState(window.history.state, '', path)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Settings" />
|
||||
<GlassBox title="Your Settings">
|
||||
<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" />
|
||||
<v-tabs v-model="tab" class="mb-4">
|
||||
<v-tab value="general">General</v-tab>
|
||||
<v-tab value="followers">Followers</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<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-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>
|
||||
<v-window-item value="followers">
|
||||
<FollowerSettings />
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</GlassBox>
|
||||
</template>
|
||||
|
||||
Vendored
+15
@@ -27,9 +27,16 @@ export interface UserSettings {
|
||||
show_ai_tail_logos: boolean
|
||||
show_ai_livery_images: boolean
|
||||
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 interface SettingOption {
|
||||
value: string
|
||||
@@ -260,6 +267,10 @@ export interface CrewType {
|
||||
internal_name: string
|
||||
}
|
||||
|
||||
export type RegionRange = "intraregional" | "interregional"
|
||||
export type FlightScope = 'domestic' | 'international'
|
||||
export type FlightRange = 'intracontinental' | 'intercontinental'
|
||||
|
||||
export interface Flight {
|
||||
id: number
|
||||
flight_number: string | null
|
||||
@@ -284,9 +295,13 @@ export interface Flight {
|
||||
duration_display: string
|
||||
duration: number
|
||||
distance: number
|
||||
scope: FlightScope
|
||||
range: FlightRange
|
||||
region_range: RegionRange
|
||||
livery_url?: string
|
||||
}
|
||||
|
||||
|
||||
export interface MissingLivery {
|
||||
airline_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\FlightController;
|
||||
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\NotificationController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\SearchController;
|
||||
use App\Http\Controllers\SettingsController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Http\Controllers\UserFlightController;
|
||||
use App\Models\Airline;
|
||||
use App\Models\FlightClass;
|
||||
use App\Models\FlightReason;
|
||||
@@ -29,7 +31,7 @@ use Inertia\Inertia;
|
||||
*/
|
||||
Route::domain(config('app.domain'))->group(
|
||||
function() {
|
||||
Route::get('/', [FlightProfileController::class, 'index'])->name('home');
|
||||
Route::get('/', [UserProfileController::class, 'index'])->name('home');
|
||||
|
||||
|
||||
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::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('/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::get('/settings', [UserController::class, 'settings'])->name('profile.settings');
|
||||
Route::patch('/settings', [SettingsController::class, 'update'])->name('settings.update');
|
||||
Route::get('/settings/{category?}', [UserController::class, 'settings'])->name('user.settings');
|
||||
|
||||
|
||||
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.get');
|
||||
Route::patch('/notifications/{notification}/read', [NotificationController::class, 'markRead']);
|
||||
|
||||
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');
|
||||
@@ -75,16 +88,15 @@ Route::domain(config('app.domain'))->group(
|
||||
Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');
|
||||
Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports');
|
||||
|
||||
//@Todo: Move to API
|
||||
Route::get('/data/user/{username}/flights', [UserApiController::class, 'flights']);
|
||||
Route::get('/data/user/{user}/flights', [UserFlightController::class, 'viewableFlights']);
|
||||
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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user