Added Notifications
This commit is contained in:
@@ -3,6 +3,9 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Achievement;
|
use App\Models\Achievement;
|
||||||
|
use App\Models\Aircraft;
|
||||||
|
use App\Models\Alliance;
|
||||||
|
use App\Models\Continent;
|
||||||
use App\Models\Country;
|
use App\Models\Country;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -15,7 +18,7 @@ class AchievementController extends Controller
|
|||||||
$achievements = Achievement::with(['category', 'difficulty'])
|
$achievements = Achievement::with(['category', 'difficulty'])
|
||||||
->get()
|
->get()
|
||||||
->groupBy(fn(Achievement $a) => $a->category->name)
|
->groupBy(fn(Achievement $a) => $a->category->name)
|
||||||
->map(fn($group) => $group->sortBy('id')->values());
|
->map(fn($group) => $group->sortBy('sort_order')->values());
|
||||||
|
|
||||||
$userAchievements = $user->achievements()
|
$userAchievements = $user->achievements()
|
||||||
->with('achievement')
|
->with('achievement')
|
||||||
@@ -29,26 +32,72 @@ class AchievementController extends Controller
|
|||||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||||
'achievements' => $achievements,
|
'achievements' => $achievements,
|
||||||
'userAchievements' => $userAchievements,
|
'userAchievements' => $userAchievements,
|
||||||
|
'loggedInUser' => auth()->user(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRegionsByCountryCode(string $countryCode)
|
||||||
|
{
|
||||||
|
return Country::whereCode($countryCode)
|
||||||
|
->first()
|
||||||
|
->regions()
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
public function specific(User $user, Achievement $achievement)
|
public function specific(User $user, Achievement $achievement)
|
||||||
{
|
{
|
||||||
$regions = match($achievement->internal_name){
|
$regions = match($achievement->internal_name){
|
||||||
'fun_challenges.australian_states' => Country::where('code', 'AU')->first()->regions->toArray(),
|
'fun_challenges.australian_states' => $this->getRegionsByCountryCode('AU'),
|
||||||
'fun_challenges.chinese_provinces' => Country::where('code', 'CN')->first()->regions->toArray(),
|
'fun_challenges.chinese_provinces' => $this->getRegionsByCountryCode('CN'),
|
||||||
'fun_challenges.canadian_provinces' => Country::where('code', 'CA')->first()->regions->toArray(),
|
'fun_challenges.canadian_provinces' => $this->getRegionsByCountryCode('CA'),
|
||||||
'fun_challenges.us_states' => Country::where('code', 'US')->first()->regions->toArray(),
|
'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 => [],
|
default => [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return Inertia::render('Profile/UserAchievement', [
|
return Inertia::render('Profile/UserAchievement', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'achievement' => $achievement,
|
'achievement' => $achievement,
|
||||||
|
'loggedInUser' => auth()->user(),
|
||||||
'userAchievement' => $user->achievements()->where('achievement_id', $achievement->id)->first(),
|
'userAchievement' => $user->achievements()->where('achievement_id', $achievement->id)->first(),
|
||||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||||
'flight_api_url' => FlightProfileController::getUserFlightApiURL($user),
|
'flight_api_url' => FlightProfileController::getUserFlightApiURL($user),
|
||||||
'regions' => $regions,
|
'regions' => $regions,
|
||||||
|
'alliance' => $alliance,
|
||||||
|
'airlines' => $airlines,
|
||||||
|
'continents' => $continents,
|
||||||
|
'aircraft_families' => $aircraftFamilies,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\User;
|
|||||||
use App\Models\UserFlight;
|
use App\Models\UserFlight;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class UserApiController extends ApiController
|
class UserApiController extends ApiController
|
||||||
{
|
{
|
||||||
@@ -47,7 +48,7 @@ class UserApiController extends ApiController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function flights(string $username): JsonResponse
|
public function flights(string $username, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = User::where('name', 'ilike', $username)->first();
|
$user = User::where('name', 'ilike', $username)->first();
|
||||||
|
|
||||||
@@ -55,6 +56,6 @@ class UserApiController extends ApiController
|
|||||||
return response()->json(['message' => 'User not found'], 404);
|
return response()->json(['message' => 'User not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($user->FlightController()->flights());
|
return response()->json($user->FlightController()->flights($request));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class FlightProfileController extends Controller
|
|||||||
'selectedFlightId' => $selectedFlightId,
|
'selectedFlightId' => $selectedFlightId,
|
||||||
'flight_api_url' => self::getUserFlightApiURL($user),
|
'flight_api_url' => self::getUserFlightApiURL($user),
|
||||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||||
|
'flightCount' => $user->departedFlights()->count(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class UserFlightController extends Controller
|
|||||||
$this->user = $user;
|
$this->user = $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function flights(){
|
public function flights(?Request $request = null)
|
||||||
|
{
|
||||||
return UserFlight::where('user_id', $this->user->id)
|
return UserFlight::where('user_id', $this->user->id)
|
||||||
->with([
|
->with([
|
||||||
'departureAirport.region.country',
|
'departureAirport.region.country',
|
||||||
@@ -30,6 +31,7 @@ class UserFlightController extends Controller
|
|||||||
'flightClass',
|
'flightClass',
|
||||||
'crewType'
|
'crewType'
|
||||||
])
|
])
|
||||||
|
->when($request?->boolean('departed_only'), fn($q) => $q->where('departure_date', '<=', now('UTC')))
|
||||||
->orderBy('departure_date', 'desc')
|
->orderBy('departure_date', 'desc')
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class Achievement extends Model
|
|||||||
'achievement_difficulty_id',
|
'achievement_difficulty_id',
|
||||||
'threshold',
|
'threshold',
|
||||||
'has_page',
|
'has_page',
|
||||||
|
'sort_order',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
+23
-3
@@ -26,9 +26,29 @@ class Aircraft extends Model
|
|||||||
'display_name_short'
|
'display_name_short'
|
||||||
];
|
];
|
||||||
|
|
||||||
const array IATA_ALIAS_MAP = [
|
public const array BOEING_FAMILIES = [
|
||||||
'7S8' => '73H',
|
'707' => ['B701', 'B703', 'B720'],
|
||||||
'7S9' => '73J'
|
'717' => ['B712', 'B717'],
|
||||||
|
'727' => ['B721', 'B722', 'B727'],
|
||||||
|
'737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
|
||||||
|
'747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
|
||||||
|
'757' => ['B752', 'B753', 'B757'],
|
||||||
|
'767' => ['B762', 'B763', 'B764', 'B767'],
|
||||||
|
'777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
|
||||||
|
'787' => ['B788', 'B789', 'B78X'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public const array AIRBUS_FAMILIES = [
|
||||||
|
'A300' => ['A30B', 'A300', 'A306'],
|
||||||
|
'A310' => ['A310', 'A312', 'A313'],
|
||||||
|
'A318' => ['A318'],
|
||||||
|
'A319' => ['A319', 'A31X'],
|
||||||
|
'A320' => ['A320', 'A20N'],
|
||||||
|
'A321' => ['A321', 'A21N'],
|
||||||
|
'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
|
||||||
|
'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
|
||||||
|
'A350' => ['A350', 'A358', 'A359', 'A35K'],
|
||||||
|
'A380' => ['A380', 'A388'],
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function displayName() : Attribute{
|
protected function displayName() : Attribute{
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(UserFlight::class);
|
return $this->hasMany(UserFlight::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function departedFlights() : HasMany {
|
||||||
|
return $this->flights()->where('departure_date', '<=', now('UTC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upcomingFlights() : HasMany {
|
||||||
|
return $this->flights()->where('departure_date', '>=', now('UTC'));
|
||||||
|
}
|
||||||
|
|
||||||
public function ImportedFlights(): HasMany
|
public function ImportedFlights(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ImportedFlight::class);
|
return $this->hasMany(ImportedFlight::class);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class AchievementService
|
|||||||
'arrivalAirport.region',
|
'arrivalAirport.region',
|
||||||
'departureAirport.region.continent',
|
'departureAirport.region.continent',
|
||||||
'arrivalAirport.region.continent',
|
'arrivalAirport.region.continent',
|
||||||
])->get();
|
])->where('departure_date', '<=', now('UTC'))->get();
|
||||||
|
|
||||||
foreach ($this->checkers as $checkerClass) {
|
foreach ($this->checkers as $checkerClass) {
|
||||||
$checker = new $checkerClass($this);
|
$checker = new $checkerClass($this);
|
||||||
|
|||||||
@@ -2,37 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Services\Achievements\Checkers;
|
namespace App\Services\Achievements\Checkers;
|
||||||
|
|
||||||
|
use App\Models\Aircraft;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserFlight;
|
use App\Models\UserFlight;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class AircraftChecker extends BaseChecker
|
class AircraftChecker extends BaseChecker
|
||||||
{
|
{
|
||||||
private const array BOEING_FAMILIES = [
|
|
||||||
'707' => ['B701', 'B703', 'B720'],
|
|
||||||
'717' => ['B712', 'B717'],
|
|
||||||
'727' => ['B721', 'B722', 'B727'],
|
|
||||||
'737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
|
|
||||||
'747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
|
|
||||||
'757' => ['B752', 'B753', 'B757'],
|
|
||||||
'767' => ['B762', 'B763', 'B764', 'B767'],
|
|
||||||
'777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
|
|
||||||
'787' => ['B788', 'B789', 'B78X'],
|
|
||||||
];
|
|
||||||
|
|
||||||
private const array AIRBUS_FAMILIES = [
|
|
||||||
'A300' => ['A30B', 'A300', 'A306'],
|
|
||||||
'A310' => ['A310', 'A312', 'A313'],
|
|
||||||
'A318' => ['A318'],
|
|
||||||
'A319' => ['A319', 'A31X'],
|
|
||||||
'A320' => ['A320', 'A20N'],
|
|
||||||
'A321' => ['A321', 'A21N'],
|
|
||||||
'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
|
|
||||||
'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
|
|
||||||
'A350' => ['A350', 'A358', 'A359', 'A35K'],
|
|
||||||
'A380' => ['A380', 'A388'],
|
|
||||||
];
|
|
||||||
|
|
||||||
private const array DOUBLE_DECKER_DESIGNATORS = [
|
private const array DOUBLE_DECKER_DESIGNATORS = [
|
||||||
// A380
|
// A380
|
||||||
'A380', 'A388',
|
'A380', 'A388',
|
||||||
@@ -121,7 +97,7 @@ class AircraftChecker extends BaseChecker
|
|||||||
|
|
||||||
// --- Boeing 7x7 families ---
|
// --- Boeing 7x7 families ---
|
||||||
|
|
||||||
$flownBoeingFamilies = collect(self::BOEING_FAMILIES)
|
$flownBoeingFamilies = collect(Aircraft::BOEING_FAMILIES)
|
||||||
->filter(fn($designators) =>
|
->filter(fn($designators) =>
|
||||||
$flightsWithAircraft->contains(
|
$flightsWithAircraft->contains(
|
||||||
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||||
@@ -133,7 +109,7 @@ class AircraftChecker extends BaseChecker
|
|||||||
|
|
||||||
// --- Airbus A3xx families ---
|
// --- Airbus A3xx families ---
|
||||||
|
|
||||||
$flownAirbusFamilie = collect(self::AIRBUS_FAMILIES)
|
$flownAirbusFamilie = collect(Aircraft::AIRBUS_FAMILIES)
|
||||||
->filter(fn($designators) =>
|
->filter(fn($designators) =>
|
||||||
$flightsWithAircraft->contains(
|
$flightsWithAircraft->contains(
|
||||||
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ class CountriesAndContinentsChecker extends BaseChecker
|
|||||||
$dep = $flight->departureAirport->region->continent->internal_name;
|
$dep = $flight->departureAirport->region->continent->internal_name;
|
||||||
$arr = $flight->arrivalAirport->region->continent->internal_name;
|
$arr = $flight->arrivalAirport->region->continent->internal_name;
|
||||||
|
|
||||||
|
if (!in_array($dep, self::INHABITED_CONTINENTS) || !in_array($arr, self::INHABITED_CONTINENTS)) continue;
|
||||||
|
if ($dep === $arr) {
|
||||||
|
$depCountry = $flight->departureAirport->region->country_id;
|
||||||
|
$arrCountry = $flight->arrivalAirport->region->country_id;
|
||||||
|
if ($depCountry === $arrCountry) continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Directed route key e.g. "europe→asia"
|
// Directed route key e.g. "europe→asia"
|
||||||
$directedRoutes->push("{$dep}→{$arr}");
|
$directedRoutes->push("{$dep}→{$arr}");
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ class GeneralFlyingChecker extends BaseChecker
|
|||||||
|
|
||||||
// --- Progressive achievements ---
|
// --- Progressive achievements ---
|
||||||
|
|
||||||
|
$totalDistance = $flights->sum('distance');
|
||||||
|
|
||||||
|
$this->awardProgress((int) $totalDistance, 'general_flying.circumference_of_the_earth');
|
||||||
|
$this->awardProgress((int) $totalDistance, 'general_flying.to_the_moon');
|
||||||
|
|
||||||
$this->awardProgress($count,'general_flying.10_flights');
|
$this->awardProgress($count,'general_flying.10_flights');
|
||||||
$this->awardProgress($count,'general_flying.50_flights');
|
$this->awardProgress($count,'general_flying.50_flights');
|
||||||
$this->awardProgress($count,'general_flying.100_flights');
|
$this->awardProgress($count,'general_flying.100_flights');
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Achievement;
|
||||||
|
use App\Models\AchievementCategory;
|
||||||
|
use App\Models\AchievementDifficulty;
|
||||||
|
use App\Models\Airline;
|
||||||
|
use App\Models\User;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
|
||||||
|
Achievement::create([
|
||||||
|
'name' => 'Circumnavigator',
|
||||||
|
'internal_name' => 'general_flying.circumference_of_the_earth',
|
||||||
|
'short_description' => 'Fly the same distance as the circumference of the Earth at the equator!',
|
||||||
|
'icon' => 'standard_achievement.png',
|
||||||
|
'progressive' => true,
|
||||||
|
'long_description' => '',
|
||||||
|
'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
|
||||||
|
'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'moderate')->first()->id,
|
||||||
|
'threshold' => 40075,
|
||||||
|
'has_page' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Achievement::create([
|
||||||
|
'name' => 'Fly Me to The Moon',
|
||||||
|
'internal_name' => 'general_flying.to_the_moon',
|
||||||
|
'short_description' => 'Fly the same distance as the Earth to the Moon!',
|
||||||
|
'icon' => 'standard_achievement.png',
|
||||||
|
'long_description' => '',
|
||||||
|
'progressive' => true,
|
||||||
|
'threshold' => 384400,
|
||||||
|
'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
|
||||||
|
'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'hard')->first()->id,
|
||||||
|
'has_page' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Achievement::whereInternalName('aircraft.all_boeing_7x7')->update(['has_page' => true]);
|
||||||
|
Achievement::whereInternalName('aircraft.all_airbus_a3xx')->update(['has_page' => true]);
|
||||||
|
Airline::whereInternalName('south-africa-airways')->update(['name' => 'South African Airways']);
|
||||||
|
|
||||||
|
Schema::table('achievements', function (Blueprint $table) {
|
||||||
|
$table->unsignedInteger('sort_order')->nullable()->after('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed sort_order from current id order, scoped per category
|
||||||
|
$achievements = DB::table('achievements')
|
||||||
|
->orderBy('achievement_category_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$position = 1;
|
||||||
|
$currentCategory = null;
|
||||||
|
|
||||||
|
foreach ($achievements as $achievement) {
|
||||||
|
if ($achievement->achievement_category_id !== $currentCategory) {
|
||||||
|
$position = 1;
|
||||||
|
$currentCategory = $achievement->achievement_category_id;
|
||||||
|
}
|
||||||
|
DB::table('achievements')
|
||||||
|
->where('id', $achievement->id)
|
||||||
|
->update(['sort_order' => $position++]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move "Four on the Floor" (id 30) after "Triple Threat" (id 34)
|
||||||
|
// within the aircraft category — swap their sort_order values
|
||||||
|
$triEngine = DB::table('achievements')->where('internal_name', 'aircraft.tri_engine')->first();
|
||||||
|
$quadEngine = DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')->first();
|
||||||
|
|
||||||
|
DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')
|
||||||
|
->update(['sort_order' => $triEngine->sort_order + 1]);
|
||||||
|
|
||||||
|
// Shift everything between them up by 1 to make room
|
||||||
|
DB::table('achievements')
|
||||||
|
->where('achievement_category_id', $quadEngine->achievement_category_id)
|
||||||
|
->where('sort_order', '>=', $triEngine->sort_order + 1)
|
||||||
|
->where('internal_name', '!=', 'aircraft.quad_engine')
|
||||||
|
->increment('sort_order');
|
||||||
|
|
||||||
|
$users = User::all();
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$user->calculateAchievements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -103,12 +103,16 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
color: #778;
|
color: #778;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.class-premium-global {
|
.class-premium-global {
|
||||||
background: rgba(75, 32, 137, 0.35);
|
background: rgba(75, 32, 137, 0.35);
|
||||||
border: 1px solid rgba(180, 130, 255, 0.25);
|
border: 1px solid rgba(180, 130, 255, 0.25);
|
||||||
color: #c49dff;
|
color: #c49dff;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.class-premium_economy-global {
|
.class-premium_economy-global {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import FormattedNumber from "@/Components/FormattedNumber.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
value: number
|
||||||
|
unit?: 'km' | 'mi' | 'nm'
|
||||||
|
showUnits?: boolean
|
||||||
|
includeSpace?: boolean
|
||||||
|
}>(), {
|
||||||
|
showUnits: true,
|
||||||
|
unit: 'km',
|
||||||
|
includeSpace: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const CONVERSIONS: Record<string, number> = {
|
||||||
|
km: 1,
|
||||||
|
mi: 0.621371,
|
||||||
|
nm: 0.539957,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
km: 'km',
|
||||||
|
mi: 'mi',
|
||||||
|
nm: 'nm',
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = computed(() => props.unit ?? 'km')
|
||||||
|
|
||||||
|
const converted = computed(() => props.value * CONVERSIONS[unit.value])
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormattedNumber :value="converted" />{{includeSpace ? ' ' : ''}}{{ showUnits ? LABELS[unit] : '' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,13 @@ import {computed} from "vue";
|
|||||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||||
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
||||||
import {Link} from '@inertiajs/vue3'
|
import {Link} from '@inertiajs/vue3'
|
||||||
|
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
|
||||||
|
import Distance from "@/Components/Distance.vue";
|
||||||
|
|
||||||
|
const distanceAchievements = [
|
||||||
|
'general_flying.circumference_of_the_earth',
|
||||||
|
'general_flying.to_the_moon',
|
||||||
|
];
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
achievement: Achievement
|
achievement: Achievement
|
||||||
@@ -85,13 +92,11 @@ const difficultyVariant = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="achievement-description">{{ achievement.short_description }}</p>
|
<p class="achievement-description">{{ achievement.short_description }}</p>
|
||||||
<Link v-if="achievement.has_page && user" :href="route('profile.achievement', { user: user.name, achievement: achievement.internal_name })">
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<template v-if="achievement.progressive && progress">
|
<template v-if="achievement.progressive && progress">
|
||||||
<div class="progress-label">
|
<div class="progress-label">
|
||||||
<span>{{ Math.min(progress.current, progress.threshold) }} / {{ progress.threshold }}</span>
|
<span><distance :showUnits="false" :value="Math.min(progress.current, progress.threshold)" /> / <distance :value="progress.threshold" :showUnits="distanceAchievements.includes(achievement.internal_name)" /></span>
|
||||||
<span>{{ progress.percentage }}%</span>
|
<span>{{ progress.percentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<v-progress-linear
|
<v-progress-linear
|
||||||
@@ -102,11 +107,17 @@ const difficultyVariant = computed(() => {
|
|||||||
bg-color="rgba(255,255,255,0.1)"
|
bg-color="rgba(255,255,255,0.1)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<br/>
|
||||||
|
<ButtonLink
|
||||||
|
variant="outlined"
|
||||||
|
label="View Details"
|
||||||
|
icon="mdi-magnify"
|
||||||
|
v-if="achievement.has_page && user" :href="route('profile.achievement', { user: user.name, achievement: achievement.internal_name })" />
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -115,19 +126,17 @@ const difficultyVariant = computed(() => {
|
|||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.achievement-card.locked {
|
.achievement-card.locked .achievement-inner {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.achievement-card:hover {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.achievement-card:hover .achievement-inner,
|
||||||
.achievement-inner {
|
.achievement-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.achievement-icon-wrap {
|
.achievement-icon-wrap {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Flight, Airline } from '@/Types/types'
|
||||||
|
import type { CodeType } from '@/Composables/useAlphabetAirlines'
|
||||||
|
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||||
|
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
|
||||||
|
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
letters: string[]
|
||||||
|
flightsByLetter: Record<string, Flight[]>
|
||||||
|
codeType: CodeType
|
||||||
|
selectedYear: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface AirlineEntry {
|
||||||
|
airline: Airline
|
||||||
|
code: string
|
||||||
|
firstYear: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCode(airline: Airline): string | null {
|
||||||
|
const raw = props.codeType === 'iata' ? airline.IATA_code : airline.ICAO_code
|
||||||
|
return raw?.trim().toUpperCase() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function airlineEntriesForLetter(letter: string): AirlineEntry[] {
|
||||||
|
const flights = props.flightsByLetter[letter] ?? []
|
||||||
|
const seen = new Map<string, AirlineEntry>()
|
||||||
|
|
||||||
|
for (const flight of flights) {
|
||||||
|
const airline = flight.airline
|
||||||
|
if (!airline) continue
|
||||||
|
|
||||||
|
const code = getCode(airline)
|
||||||
|
if (!code?.startsWith(letter)) continue
|
||||||
|
|
||||||
|
const year = new Date(flight.departure_date).getFullYear()
|
||||||
|
const existing = seen.get(code)
|
||||||
|
if (!existing || year < existing.firstYear) {
|
||||||
|
seen.set(code, { airline, code, firstYear: year })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...seen.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([, entry]) => entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHighlighted({ firstYear }: AirlineEntry): boolean {
|
||||||
|
return props.selectedYear !== null && firstYear === props.selectedYear
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBBCode(): string {
|
||||||
|
return props.letters
|
||||||
|
.map(letter => {
|
||||||
|
const entries = airlineEntriesForLetter(letter)
|
||||||
|
if (!entries.length) return letter
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map(entry =>
|
||||||
|
isHighlighted(entry)
|
||||||
|
? `[b][color=#00BF00]${entry.code}[/color][/b]`
|
||||||
|
: entry.code
|
||||||
|
)
|
||||||
|
.join(', ')
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ toBBCode })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BadgeTable
|
||||||
|
:rows="letters"
|
||||||
|
:rowKey="letter => letter"
|
||||||
|
:hasItems="letter => !!flightsByLetter[letter]?.length"
|
||||||
|
labelWidth="4em"
|
||||||
|
>
|
||||||
|
<template #label="{ row: letter }">
|
||||||
|
<div
|
||||||
|
style="width:100%;display:flex;justify-content:center;align-items:center"
|
||||||
|
:class="flightsByLetter[letter]?.length ? 'visited' : 'unvisited'"
|
||||||
|
>
|
||||||
|
{{ letter }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #items="{ row: letter }">
|
||||||
|
<div
|
||||||
|
v-for="entry in airlineEntriesForLetter(letter)"
|
||||||
|
:key="entry.airline.IATA_code!"
|
||||||
|
>
|
||||||
|
<InlineBadge style="align-items:center;gap:0.2em" :variant="isHighlighted(entry) ? 'business' : undefined">
|
||||||
|
<AirlineLogo :airline="entry.airline" /> {{ entry.code }}
|
||||||
|
</InlineBadge>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</BadgeTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.visited {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unvisited {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
|
||||||
|
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
|
||||||
|
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
|
||||||
|
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
|
||||||
|
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||||
|
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
|
||||||
|
import AirlineLogo from '@/Components/FlightsGoneBy/AirlineLogo.vue'
|
||||||
|
import AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
|
||||||
|
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
|
||||||
|
|
||||||
|
|
||||||
|
interface AirlineEntry {
|
||||||
|
airline: Airline
|
||||||
|
flights: Flight[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
alliance: Alliance
|
||||||
|
airlines: Airline[]
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const flightsByAirline = computed(() => {
|
||||||
|
const allianceAirlineIds = new Set(props.airlines.map(a => a.id))
|
||||||
|
const map = new Map<string, AirlineEntry>()
|
||||||
|
|
||||||
|
// Pre-populate every alliance airline so unflown ones still appear
|
||||||
|
for (const airline of props.airlines) {
|
||||||
|
map.set(rowKey(airline), { airline, flights: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const flight of props.flights) {
|
||||||
|
const airline = flight.airline
|
||||||
|
if (!airline || !allianceAirlineIds.has(airline.id)) continue
|
||||||
|
map.get(rowKey(airline))?.flights.push(flight)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
function rowKey(airline: Airline): string {
|
||||||
|
return airline.IATA_code ?? airline.internal_name
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = computed(() => [...flightsByAirline.value.keys()])
|
||||||
|
|
||||||
|
function entryFor(key: string): AirlineEntry {
|
||||||
|
return flightsByAirline.value.get(key)!
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Header -->
|
||||||
|
<Panel>
|
||||||
|
<div class="alliance-header">
|
||||||
|
<AllianceLogo :alliance="alliance" size="56" />
|
||||||
|
<div>
|
||||||
|
<PanelHeader centered>{{ alliance.name }}</PanelHeader>
|
||||||
|
<PanelSubHeader centered>
|
||||||
|
<slot />
|
||||||
|
</PanelSubHeader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Airlines table -->
|
||||||
|
<Panel>
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<PanelHeader>Airlines</PanelHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BadgeTable
|
||||||
|
:rows="rows"
|
||||||
|
:rowKey="key => key"
|
||||||
|
:hasItems="key => entryFor(key).flights.length > 0"
|
||||||
|
labelWidth="14em"
|
||||||
|
>
|
||||||
|
<template #label="{ row: key }">
|
||||||
|
<div class="airline-label" >
|
||||||
|
<AirlineLogo :airline="entryFor(key).airline" size="24" />
|
||||||
|
<span>{{ entryFor(key).airline.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #items="{ row: key }">
|
||||||
|
<FlightBadge
|
||||||
|
v-for="flight in entryFor(key).flights"
|
||||||
|
:key="flight.id"
|
||||||
|
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
|
||||||
|
:flight="flight" />
|
||||||
|
</template>
|
||||||
|
</BadgeTable>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Slot for alliance-specific panels -->
|
||||||
|
<slot name="extra" />
|
||||||
|
|
||||||
|
<!-- Requirements -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Requirements</PanelHeader>
|
||||||
|
<p>
|
||||||
|
To complete this challenge you must fly with every current member airline of
|
||||||
|
<strong>{{ alliance.name }}</strong>. Alliance membership changes over time, so the
|
||||||
|
required airlines reflect the current roster.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Codeshare flights do not count, the operating carrier must be a member of {{alliance.name}}.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alliance-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.airline-label {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {Airport, Flight} from '@/Types/types'
|
||||||
|
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||||
|
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
|
||||||
|
import AirportToolTip from '@/Components/FlightsGoneBy/AirportToolTip.vue'
|
||||||
|
|
||||||
|
type CodeType = 'iata' | 'icao'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
letters: string[]
|
||||||
|
flightsByLetter: Record<string, Flight[]>
|
||||||
|
codeType: CodeType
|
||||||
|
selectedYear: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function getCode(airport: Airport): string | null {
|
||||||
|
const raw = props.codeType === 'iata' ? airport.iata_code : airport.icao_code
|
||||||
|
return raw?.trim().toUpperCase() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AirportEntry {
|
||||||
|
airport: Airport
|
||||||
|
firstYear: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function airportEntriesForLetter(letter: string): AirportEntry[] {
|
||||||
|
const flights = props.flightsByLetter[letter] ?? []
|
||||||
|
const seen = new Map<string, AirportEntry>()
|
||||||
|
|
||||||
|
for (const flight of flights) {
|
||||||
|
const year = new Date(flight.departure_date).getFullYear()
|
||||||
|
|
||||||
|
for (const airport of [flight.departure_airport, flight.arrival_airport]) {
|
||||||
|
const code = getCode(airport)
|
||||||
|
if (!code?.startsWith(letter)) continue
|
||||||
|
|
||||||
|
const existing = seen.get(code)
|
||||||
|
if (!existing || year < existing.firstYear) {
|
||||||
|
seen.set(code, { airport, firstYear: year })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...seen.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([, entry]) => entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHighlighted({ firstYear }: AirportEntry): boolean {
|
||||||
|
return props.selectedYear !== null && firstYear === props.selectedYear
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBBCode(): string {
|
||||||
|
return props.letters
|
||||||
|
.map(letter => {
|
||||||
|
const entries = airportEntriesForLetter(letter)
|
||||||
|
if (!entries.length) return letter
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map(entry => {
|
||||||
|
const code = getCode(entry.airport)!
|
||||||
|
return isHighlighted(entry) ? `[b][color=#00BF00]${code}[/color][/b]` : code
|
||||||
|
})
|
||||||
|
.join(', ')
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ toBBCode })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BadgeTable
|
||||||
|
:rows="letters"
|
||||||
|
:rowKey="letter => letter"
|
||||||
|
:hasItems="letter => !!flightsByLetter[letter]?.length"
|
||||||
|
labelWidth="4em"
|
||||||
|
>
|
||||||
|
<template #label="{ row: letter }">
|
||||||
|
<div style="width:100%;display:flex;justify-content: center; align-items: center" :class="flightsByLetter[letter]?.length ? 'visited' : 'unvisited'">
|
||||||
|
{{ letter }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #items="{ row: letter }">
|
||||||
|
<AirportToolTip
|
||||||
|
v-for="entry in airportEntriesForLetter(letter)"
|
||||||
|
:key="getCode(entry.airport)!"
|
||||||
|
:airport="entry.airport"
|
||||||
|
>
|
||||||
|
<InlineBadge :variant="isHighlighted(entry) ? 'business' : undefined">
|
||||||
|
{{ getCode(entry.airport) }}
|
||||||
|
</InlineBadge>
|
||||||
|
</AirportToolTip>
|
||||||
|
</template>
|
||||||
|
</BadgeTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.visited {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unvisited {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.airport-count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 0.2em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { Flight } from "@/Types/types";
|
|||||||
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||||
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
|
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
|
||||||
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
|
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
|
||||||
|
import Distance from "@/Components/Distance.vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
flight: Flight
|
flight: Flight
|
||||||
@@ -70,7 +71,7 @@ defineProps<{
|
|||||||
<span class="pass-stat-divider" v-if="flight.duration_display && flight.distance">·</span>
|
<span class="pass-stat-divider" v-if="flight.duration_display && flight.distance">·</span>
|
||||||
<span v-if="flight.distance" class="pass-stat">
|
<span v-if="flight.distance" class="pass-stat">
|
||||||
<span class="pass-stat-label">DISTANCE</span>
|
<span class="pass-stat-label">DISTANCE</span>
|
||||||
<span class="pass-stat-value">{{ Math.round(flight.distance).toLocaleString() }} km</span>
|
<span class="pass-stat-value"><Distance :value="Math.round(flight.distance)" /></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,16 +81,6 @@ defineProps<{
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.feed-boarding-pass{
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 1em auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.boarding-pass{
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pass-stats-row {
|
.pass-stats-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {Link} from "@inertiajs/vue3";
|
||||||
|
type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||||
|
defineProps<{
|
||||||
|
href: string | {
|
||||||
|
url: string;
|
||||||
|
method: Method;
|
||||||
|
},
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
variant?: "flat" | "text" | "elevated" | "outlined" | "plain" | "tonal" | undefined
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Link :href="href">
|
||||||
|
<v-btn
|
||||||
|
style="width:100%;"
|
||||||
|
:prepend-icon="icon"
|
||||||
|
:variant="variant"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</v-btn>
|
||||||
|
</Link>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
a{
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,7 @@ import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
|||||||
import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue";
|
import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue";
|
||||||
import {Link, router} from "@inertiajs/vue3";
|
import {Link, router} from "@inertiajs/vue3";
|
||||||
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue";
|
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue";
|
||||||
|
import Distance from "@/Components/Distance.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
flightStats: FlightStats
|
flightStats: FlightStats
|
||||||
@@ -238,7 +239,7 @@ watch(
|
|||||||
|
|
||||||
<td class="v-data-table__td">
|
<td class="v-data-table__td">
|
||||||
<span class="mono-tag distance-cell">
|
<span class="mono-tag distance-cell">
|
||||||
{{ (item as Flight).distance ? Math.round((item as Flight).distance).toLocaleString() + ' km' : '' }}
|
<Distance :value="Math.round((item as Flight).distance)" />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flight-booked">
|
<div class="flight-booked">
|
||||||
<BoardingPass :class="`feed-boarding-pass`" :flight="flight" />
|
<BoardingPass style="max-width:90%; margin: 0 auto" :class="`feed-boarding-pass`" :flight="flight" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {Flight} from "@/Types/types";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
flight: Flight
|
||||||
|
}>()
|
||||||
|
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||||
|
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||||
|
import FlightToolTip from "@/Components/FlightsGoneBy/FlightToolTip.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlightToolTip
|
||||||
|
:flight="flight"
|
||||||
|
>
|
||||||
|
<InlineBadge class="flight-badge">
|
||||||
|
<AirlineLogo hideTooltip :airline="flight.airline" />
|
||||||
|
{{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }}
|
||||||
|
</InlineBadge>
|
||||||
|
</FlightToolTip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flight-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -471,6 +471,7 @@ export default defineComponent({
|
|||||||
map = new maplibregl.Map({
|
map = new maplibregl.Map({
|
||||||
container: mapContainer.value!,
|
container: mapContainer.value!,
|
||||||
cooperativeGestures: true,
|
cooperativeGestures: true,
|
||||||
|
attributionControl: false,
|
||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -483,7 +484,6 @@ export default defineComponent({
|
|||||||
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||||
],
|
],
|
||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
maxzoom: 19,
|
maxzoom: 19,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,49 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {Flight} from "@/Types/types";
|
import { Flight } from '@/Types/types'
|
||||||
import FlightToolTip from "@/Components/FlightsGoneBy/FlightToolTip.vue";
|
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||||
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
import FlightToolTip from '@/Components/FlightsGoneBy/FlightToolTip.vue'
|
||||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
import AirlineLogo from '@/Components/FlightsGoneBy/AirlineLogo.vue'
|
||||||
|
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
|
||||||
|
import {computed} from "vue";
|
||||||
|
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
regionCodes: string[]
|
regionCodes: string[]
|
||||||
flightsByRegion: Record<string, Flight[]>
|
flightsByRegion: Record<string, Flight[]>
|
||||||
regionNames?: Record<string, string>
|
regionNames?: Record<string, string>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const sortedRegionCodes = computed(() =>
|
||||||
|
[...props.regionCodes].sort((a, b) => {
|
||||||
|
const nameA = props.regionNames?.[a] ?? a
|
||||||
|
const nameB = props.regionNames?.[b] ?? b
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<table>
|
<BadgeTable
|
||||||
<tr v-for="code in regionCodes" :key="code">
|
:rows="sortedRegionCodes"
|
||||||
<td>{{ regionNames?.[code] ?? code }}</td>
|
:rowKey="code => code"
|
||||||
<td>
|
:hasItems="code => !!flightsByRegion[code]?.length"
|
||||||
<template v-if="flightsByRegion[code]?.length">
|
>
|
||||||
<span style="display:inline-flex; align-items:center; gap:0.25em; flex-wrap:wrap;">
|
<template #label="{ row: code }">
|
||||||
<FlightToolTip
|
{{ regionNames?.[code] ?? code }}
|
||||||
v-for="(flight, index) in flightsByRegion[code].slice(0, 5)"
|
</template>
|
||||||
:key="flight.id"
|
|
||||||
:flight="flight"
|
<template #items="{ row: code }">
|
||||||
>
|
<FlightBadge
|
||||||
<InlineBadge style="display:inline-flex; align-items:center; gap:0.25em;">
|
v-for="flight in flightsByRegion[code]"
|
||||||
<AirlineLogo hideTooltip :airline="flight.airline" />
|
:key="flight.id"
|
||||||
{{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }}
|
:flight="flight"
|
||||||
</InlineBadge>
|
/>
|
||||||
</FlightToolTip>
|
</template>
|
||||||
</span>
|
</BadgeTable>
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
table {
|
|
||||||
border-spacing: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
table td {
|
|
||||||
border: solid 1px;
|
|
||||||
padding: 0.5em;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,18 +35,13 @@
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<template v-if="totalDistanceKm">
|
<template v-if="totalDistanceKm">
|
||||||
<div class="stat-primary">
|
<div class="stat-primary">
|
||||||
<span class="stat-num">{{ totalDistanceKm.toLocaleString() }}</span>
|
<span class="stat-num"><Distance includeSpace :value="totalDistanceKm" /></span>
|
||||||
<span class="unit">km</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-sub">{{ totalDistanceMi.toLocaleString() }} miles</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-if="upcomingDistanceKm">
|
<template v-if="upcomingDistanceKm">
|
||||||
<div :class="totalDistanceKm ? 'stat-upcoming' : 'stat-primary'">
|
<div :class="totalDistanceKm ? 'stat-upcoming' : 'stat-primary'">
|
||||||
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingDistanceKm.toLocaleString() }}</span>
|
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'"><Distance includeSpace :value="upcomingDistanceKm"/></span>
|
||||||
<span :class="totalDistanceKm ? 'stat-upcoming-lbl' : 'unit'">{{ totalDistanceKm ? 'km upcoming' : 'km' }}</span>
|
|
||||||
<span v-if="!totalDistanceKm" class="upcoming-badge">upcoming</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!totalDistanceKm" class="stat-sub">{{ upcomingDistanceMi.toLocaleString() }} miles</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,6 +100,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { Flight } from '@/Types/types'
|
import type { Flight } from '@/Types/types'
|
||||||
|
import Distance from "@/Components/Distance.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
flights: Flight[]
|
flights: Flight[]
|
||||||
@@ -117,10 +113,6 @@ const totalDistanceKm = computed(() =>
|
|||||||
Math.round(props.flights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
|
Math.round(props.flights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
|
||||||
)
|
)
|
||||||
|
|
||||||
const totalDistanceMi = computed(() =>
|
|
||||||
Math.round(totalDistanceKm.value * 0.621371)
|
|
||||||
)
|
|
||||||
|
|
||||||
const uniqueRoutes = computed(() => {
|
const uniqueRoutes = computed(() => {
|
||||||
const keys = new Set(props.flights.map(f =>
|
const keys = new Set(props.flights.map(f =>
|
||||||
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
|
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
|
||||||
@@ -164,10 +156,6 @@ const upcomingDistanceKm = computed(() =>
|
|||||||
Math.round(props.upcomingFlights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
|
Math.round(props.upcomingFlights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
|
||||||
)
|
)
|
||||||
|
|
||||||
const upcomingDistanceMi = computed(() =>
|
|
||||||
Math.round(upcomingDistanceKm.value * 0.621371)
|
|
||||||
)
|
|
||||||
|
|
||||||
const uniqueUpcomingRoutes = computed(() => {
|
const uniqueUpcomingRoutes = computed(() => {
|
||||||
const keys = new Set(props.upcomingFlights.map(f =>
|
const keys = new Set(props.upcomingFlights.map(f =>
|
||||||
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
|
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Flight } from "@/Types/types";
|
|||||||
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
||||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||||
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||||
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
flight: Flight
|
flight: Flight
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
defineProps<{
|
||||||
|
rows: T[]
|
||||||
|
rowKey: (row: T) => string | number
|
||||||
|
hasItems: (row: T) => boolean
|
||||||
|
labelWidth?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="badge-table">
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="rowKey(row)"
|
||||||
|
class="badge-row"
|
||||||
|
>
|
||||||
|
<!-- Label column: consumer provides content -->
|
||||||
|
<div class="badge-label" :style="labelWidth ? `width: ${labelWidth}` : ''">
|
||||||
|
<slot name="label" :row="row" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable badge strip -->
|
||||||
|
<div class="badge-scroll-container">
|
||||||
|
<div v-if="hasItems(row)" class="badge-strip">
|
||||||
|
<slot name="items" :row="row" />
|
||||||
|
</div>
|
||||||
|
<span v-else class="badge-empty">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.badge-table {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--table-border);
|
||||||
|
border-bottom: none;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--table-border);
|
||||||
|
min-height: 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 15em;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-right: 1px solid var(--table-border);
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size:0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 480px) {
|
||||||
|
.badge-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: 9em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-scroll-container {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-strip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
padding: 0.5em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-empty {
|
||||||
|
padding: 0.5em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,8 +29,6 @@ defineProps<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-tooltip {
|
.glass-tooltip {
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--table-border);
|
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -38,5 +36,6 @@ defineProps<{
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
z-index: 20000
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ withDefaults(defineProps<{
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.class-badge {
|
.class-badge {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
font-family: 'Share Tech Mono', monospace;
|
font-family: 'Share Tech Mono', monospace;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
centered?: boolean
|
||||||
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="panel-sub-header"><slot/></div>
|
<div class="panel-sub-header" :class="centered ? 'centered' : ''"><slot/></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -12,4 +14,8 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.centered{
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const props = defineProps<{
|
|||||||
flightCount?: number
|
flightCount?: number
|
||||||
achievementCount?: number
|
achievementCount?: number
|
||||||
isFollowing?: boolean
|
isFollowing?: boolean
|
||||||
|
show: "flights" | "achievements"
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const auth = usePage<SharedProps>().props.auth
|
const auth = usePage<SharedProps>().props.auth
|
||||||
@@ -20,6 +21,13 @@ const processing = ref(false)
|
|||||||
const snackbar = ref(false)
|
const snackbar = ref(false)
|
||||||
const snackbarMessage = ref('')
|
const snackbarMessage = ref('')
|
||||||
|
|
||||||
|
const counts = computed(() => {
|
||||||
|
return {
|
||||||
|
flights: props.flightCount ?? 0,
|
||||||
|
achievements: props.achievementCount ?? 0,
|
||||||
|
} as Record<"flights" | "achievements", number>
|
||||||
|
})
|
||||||
|
|
||||||
const follow = async () => {
|
const follow = async () => {
|
||||||
processing.value = true
|
processing.value = true
|
||||||
const response = await fetch(route('profile.follow', { user: props.user.name }), {
|
const response = await fetch(route('profile.follow', { user: props.user.name }), {
|
||||||
@@ -61,8 +69,8 @@ const follow = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="board-count">
|
<div class="board-count">
|
||||||
<span class="count-number">{{ flightCount ?? achievementCount }}</span>
|
<span class="count-number">{{ counts[show] }}</span>
|
||||||
<span class="count-label">{{achievementCount ? 'Achievements' : 'Flights'}}</span>
|
<span class="count-label">{{show.toUpperCase()}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,6 +122,12 @@ const follow = async () => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.board-header {
|
||||||
|
padding: 1em
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.follow-btn {
|
.follow-btn {
|
||||||
font-family: 'Share Tech Mono', monospace;
|
font-family: 'Share Tech Mono', monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="board-wrapper">
|
<div class="board-wrapper">
|
||||||
<ProfileHeader :is-following="isFollowing" :user="user" :flightCount="flightCount" />
|
<ProfileHeader :show="achievementCount && achievementCount > 0 ? 'achievements' : 'flights'" :is-following="isFollowing" :user="user" :flightCount="flightCount" :achievementCount="achievementCount" />
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<PlaneLoader />
|
<PlaneLoader />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
value: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
|
||||||
|
const formatted = computed(() =>
|
||||||
|
new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(props.value)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{ formatted }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
import { computed, ComputedRef } from 'vue'
|
||||||
|
import { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AircraftFamilyEntry {
|
||||||
|
family: string
|
||||||
|
designators: string[]
|
||||||
|
flights: Flight[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Composable ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useAircraftFamilies(
|
||||||
|
flights: ComputedRef<Flight[]>,
|
||||||
|
families: Record<string, string[]>,
|
||||||
|
) {
|
||||||
|
// Build a lookup from designator → family name for O(1) matching
|
||||||
|
const designatorToFamily = Object.entries(families).reduce<Record<string, string>>(
|
||||||
|
(acc, [family, designators]) => {
|
||||||
|
for (const d of designators) acc[d] = family
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
|
||||||
|
const entries = computed<AircraftFamilyEntry[]>(() => {
|
||||||
|
// Pre-populate all families with empty flight lists
|
||||||
|
const map = new Map<string, AircraftFamilyEntry>(
|
||||||
|
Object.entries(families).map(([family, designators]) => [
|
||||||
|
family,
|
||||||
|
{ family, designators, flights: [] },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const flight of flights.value) {
|
||||||
|
const designator = flight.aircraft?.designator
|
||||||
|
if (!designator) continue
|
||||||
|
const family = designatorToFamily[designator]
|
||||||
|
if (!family) continue
|
||||||
|
map.get(family)!.flights.push(flight)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...map.values()]
|
||||||
|
})
|
||||||
|
|
||||||
|
const completedCount = computed(() => entries.value.filter(e => e.flights.length > 0).length)
|
||||||
|
const totalCount = computed(() => entries.value.length)
|
||||||
|
|
||||||
|
return { entries, completedCount, totalCount }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { computed, type Ref } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
export type CodeType = 'iata' | 'icao'
|
||||||
|
|
||||||
|
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||||
|
const digits = '0123456789'.split('')
|
||||||
|
|
||||||
|
export function getAllLetters(showNumbers: boolean): string[] {
|
||||||
|
return showNumbers ? [...digits, ...letters] : letters
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlphabetAirlines(
|
||||||
|
flights: Ref<Flight[]>,
|
||||||
|
codeType: Ref<CodeType>,
|
||||||
|
showNumbers: Ref<boolean>,
|
||||||
|
) {
|
||||||
|
const allLetters = computed(() => getAllLetters(showNumbers.value))
|
||||||
|
|
||||||
|
const flightsByLetter = computed(() => {
|
||||||
|
const map: Record<string, Flight[]> = {}
|
||||||
|
|
||||||
|
for (const flight of flights.value) {
|
||||||
|
const raw = codeType.value === 'iata'
|
||||||
|
? flight.airline?.IATA_code
|
||||||
|
: flight.airline?.ICAO_code
|
||||||
|
|
||||||
|
const code = raw?.trim().toUpperCase()
|
||||||
|
if (!code) continue
|
||||||
|
|
||||||
|
const key = code[0]
|
||||||
|
if (!allLetters.value.includes(key)) continue
|
||||||
|
|
||||||
|
;(map[key] ??= []).push(flight)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const visitedLetters = computed(() => new Set(Object.keys(flightsByLetter.value)))
|
||||||
|
|
||||||
|
return { flightsByLetter, visitedLetters, allLetters }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||||
|
import type { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
export type CodeType = 'iata' | 'icao'
|
||||||
|
|
||||||
|
export interface AlphabetFlightData {
|
||||||
|
/** All 26 uppercase letters A–Z */
|
||||||
|
allLetters: string[]
|
||||||
|
/** Letters the user has visited (has at least one qualifying airport) */
|
||||||
|
visitedLetters: ComputedRef<Set<string>>
|
||||||
|
/** Map of letter → flights that touch an airport starting with that letter */
|
||||||
|
flightsByLetter: ComputedRef<Record<string, Flight[]>>
|
||||||
|
/** The code type being used */
|
||||||
|
codeType: CodeType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns which letters of the alphabet have been "visited" based on
|
||||||
|
* the IATA or ICAO code of the departure or arrival airport on each flight.
|
||||||
|
*
|
||||||
|
* A letter is considered visited when at least one flight departs from or
|
||||||
|
* arrives at an airport whose chosen code starts with that letter.
|
||||||
|
*
|
||||||
|
* Airports without a code of the chosen type are ignored.
|
||||||
|
*/
|
||||||
|
export function useAlphabetFlights(
|
||||||
|
flights: Ref<Flight[]> | ComputedRef<Flight[]>,
|
||||||
|
codeType: CodeType = 'iata',
|
||||||
|
): AlphabetFlightData {
|
||||||
|
const allLetters = Array.from({ length: 26 }, (_, i) =>
|
||||||
|
String.fromCharCode(65 + i), // 'A' … 'Z'
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Pick the right code from an airport, uppercased first character or null */
|
||||||
|
function getCode(airport: Flight['departure_airport'] | Flight['arrival_airport']): string | null {
|
||||||
|
const raw = codeType === 'iata' ? airport.iata_code : airport.icao_code
|
||||||
|
return raw ? raw.trim().toUpperCase() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const flightsByLetter = computed<Record<string, Flight[]>>(() => {
|
||||||
|
const map: Record<string, Flight[]> = {}
|
||||||
|
for (const letter of allLetters) {
|
||||||
|
map[letter] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const flight of flights.value) {
|
||||||
|
const codes = new Set<string>()
|
||||||
|
|
||||||
|
const depCode = getCode(flight.departure_airport)
|
||||||
|
if (depCode) codes.add(depCode[0])
|
||||||
|
|
||||||
|
const arrCode = getCode(flight.arrival_airport)
|
||||||
|
if (arrCode) codes.add(arrCode[0])
|
||||||
|
|
||||||
|
for (const letter of codes) {
|
||||||
|
if (letter >= 'A' && letter <= 'Z') {
|
||||||
|
map[letter]?.push(flight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const visitedLetters = computed<Set<string>>(() => {
|
||||||
|
const visited = new Set<string>()
|
||||||
|
for (const [letter, letterFlights] of Object.entries(flightsByLetter.value)) {
|
||||||
|
if (letterFlights.length > 0) {
|
||||||
|
visited.add(letter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visited
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
allLetters,
|
||||||
|
visitedLetters,
|
||||||
|
flightsByLetter,
|
||||||
|
codeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { computed, ComputedRef } from 'vue'
|
||||||
|
import { Flight } from '@/Types/types'
|
||||||
|
|
||||||
|
export interface Continent {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
internal_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContinentPairEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
flights: Flight[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable undirected key — always alphabetically sorted so A|B === B|A */
|
||||||
|
export function undirectedKey(a: string, b: string): string {
|
||||||
|
return [a, b].sort().join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directed key — order preserved */
|
||||||
|
export function directedKey(dep: string, arr: string): string {
|
||||||
|
return `${dep}|${arr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelFor(a: string, b: string): string {
|
||||||
|
return `${a} ↔ ${b}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function continentNameOf(flight: Flight, side: 'departure' | 'arrival'): string | null {
|
||||||
|
const airport = side === 'departure' ? flight.departure_airport : flight.arrival_airport
|
||||||
|
return airport.region?.continent?.name ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInternational(flight: Flight): boolean {
|
||||||
|
const depCountry = flight.departure_airport.region?.country_id
|
||||||
|
const arrCountry = flight.arrival_airport.region?.country_id
|
||||||
|
if (depCountry == null || arrCountry == null) return true
|
||||||
|
return depCountry !== arrCountry
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter flights to only those that qualify for continent-pair achievements */
|
||||||
|
export function qualifyingFlights(flights: Flight[]): Flight[] {
|
||||||
|
return flights.filter(flight => {
|
||||||
|
const dep = continentNameOf(flight, 'departure')
|
||||||
|
const arr = continentNameOf(flight, 'arrival')
|
||||||
|
if (!dep || !arr) return false
|
||||||
|
if (dep === arr && !isInternational(flight)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One-way (undirected) pairs ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useUndirectedContinentPairs(
|
||||||
|
flights: ComputedRef<Flight[]>,
|
||||||
|
continents: ComputedRef<Continent[]>,
|
||||||
|
) {
|
||||||
|
const allKeys = computed<string[]>(() => {
|
||||||
|
const names = continents.value.map(c => c.name).sort()
|
||||||
|
const keys: string[] = []
|
||||||
|
for (let i = 0; i < names.length; i++) {
|
||||||
|
for (let j = i; j < names.length; j++) {
|
||||||
|
keys.push(undirectedKey(names[i], names[j]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys.sort((a, b) => {
|
||||||
|
const [a1, a2] = a.split('|')
|
||||||
|
const [b1, b2] = b.split('|')
|
||||||
|
return labelFor(a1, a2).localeCompare(labelFor(b1, b2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const flightsByKey = computed<Map<string, Flight[]>>(() => {
|
||||||
|
const map = new Map(allKeys.value.map(k => [k, [] as Flight[]]))
|
||||||
|
for (const flight of qualifyingFlights(flights.value)) {
|
||||||
|
const dep = continentNameOf(flight, 'departure')!
|
||||||
|
const arr = continentNameOf(flight, 'arrival')!
|
||||||
|
const key = undirectedKey(dep, arr)
|
||||||
|
map.get(key)?.push(flight)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const entries = computed<ContinentPairEntry[]>(() =>
|
||||||
|
allKeys.value.map(key => {
|
||||||
|
const [a, b] = key.split('|')
|
||||||
|
return { key, label: labelFor(a, b), flights: flightsByKey.value.get(key) ?? [] }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const completedCount = computed(() => entries.value.filter(e => e.flights.length > 0).length)
|
||||||
|
const totalCount = computed(() => entries.value.length)
|
||||||
|
|
||||||
|
return { entries, completedCount, totalCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Both-ways (directed) pairs ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useDirectedContinentPairs(
|
||||||
|
flights: ComputedRef<Flight[]>,
|
||||||
|
continents: ComputedRef<Continent[]>,
|
||||||
|
) {
|
||||||
|
/** All directed keys grouped by the departure continent name */
|
||||||
|
const keysByDeparture = computed<Map<string, string[]>>(() => {
|
||||||
|
const names = continents.value.map(c => c.name).sort()
|
||||||
|
const map = new Map<string, string[]>()
|
||||||
|
for (const dep of names) {
|
||||||
|
map.set(dep, names.map(arr => directedKey(dep, arr)).sort((a, b) => {
|
||||||
|
const [, a2] = a.split('|')
|
||||||
|
const [, b2] = b.split('|')
|
||||||
|
return a2.localeCompare(b2)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const flightsByKey = computed<Map<string, Flight[]>>(() => {
|
||||||
|
const map = new Map<string, Flight[]>()
|
||||||
|
for (const keys of keysByDeparture.value.values()) {
|
||||||
|
for (const key of keys) map.set(key, [])
|
||||||
|
}
|
||||||
|
for (const flight of qualifyingFlights(flights.value)) {
|
||||||
|
const dep = continentNameOf(flight, 'departure')!
|
||||||
|
const arr = continentNameOf(flight, 'arrival')!
|
||||||
|
const key = directedKey(dep, arr)
|
||||||
|
map.get(key)?.push(flight)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Entries grouped by departure continent, each sorted by arrival continent name */
|
||||||
|
const entriesByDeparture = computed<Map<string, ContinentPairEntry[]>>(() => {
|
||||||
|
const map = new Map<string, ContinentPairEntry[]>()
|
||||||
|
for (const [dep, keys] of keysByDeparture.value) {
|
||||||
|
map.set(dep, keys.map(key => {
|
||||||
|
const [a, b] = key.split('|')
|
||||||
|
return { key, label: `${a} → ${b}`, flights: flightsByKey.value.get(key) ?? [] }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const departureNames = computed(() => [...keysByDeparture.value.keys()])
|
||||||
|
|
||||||
|
const completedCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
for (const entries of entriesByDeparture.value.values()) {
|
||||||
|
count += entries.filter(e => e.flights.length > 0).length
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
for (const entries of entriesByDeparture.value.values()) {
|
||||||
|
count += entries.length
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
return { entriesByDeparture, departureNames, completedCount, totalCount }
|
||||||
|
}
|
||||||
@@ -33,7 +33,6 @@ function getDateParts(f: Flight): { year: number; month: number; day: number } {
|
|||||||
// ── Per year / month / day ────────────────────────────────────────────────────
|
// ── Per year / month / day ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getFlightsPerYear(flights: Flight[], upcomingFlights: Flight[]) {
|
export function getFlightsPerYear(flights: Flight[], upcomingFlights: Flight[]) {
|
||||||
console.time('getFlightsPerYear')
|
|
||||||
const allFlights = [...flights, ...upcomingFlights]
|
const allFlights = [...flights, ...upcomingFlights]
|
||||||
|
|
||||||
const allYears = new Set(allFlights.map(f => getDateParts(f).year))
|
const allYears = new Set(allFlights.map(f => getDateParts(f).year))
|
||||||
@@ -57,12 +56,10 @@ export function getFlightsPerYear(flights: Flight[], upcomingFlights: Flight[])
|
|||||||
{ name: 'Upcoming', data: countByYear(upcomingFlights) },
|
{ name: 'Upcoming', data: countByYear(upcomingFlights) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
console.timeEnd('getFlightsPerYear')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFlightsPerMonth(flights: Flight[], upcomingFlights: Flight[]) {
|
export function getFlightsPerMonth(flights: Flight[], upcomingFlights: Flight[]) {
|
||||||
console.time('getFlightsPerMonth')
|
|
||||||
const countByMonth = (list: Flight[]) =>
|
const countByMonth = (list: Flight[]) =>
|
||||||
MONTHS.map((_, i) => list.filter(f => getDateParts(f).month === i).length)
|
MONTHS.map((_, i) => list.filter(f => getDateParts(f).month === i).length)
|
||||||
|
|
||||||
@@ -73,12 +70,10 @@ export function getFlightsPerMonth(flights: Flight[], upcomingFlights: Flight[])
|
|||||||
{ name: 'Upcoming', data: countByMonth(upcomingFlights) },
|
{ name: 'Upcoming', data: countByMonth(upcomingFlights) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
console.timeEnd('getFlightsPerMonth')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
|
export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
|
||||||
console.time('getFlightsPerDay')
|
|
||||||
const countByDay = (list: Flight[]) =>
|
const countByDay = (list: Flight[]) =>
|
||||||
DAYS.map((_, i) => list.filter(f => getDateParts(f).day === i).length)
|
DAYS.map((_, i) => list.filter(f => getDateParts(f).day === i).length)
|
||||||
|
|
||||||
@@ -89,7 +84,6 @@ export function getFlightsPerDay(flights: Flight[], upcomingFlights: Flight[]) {
|
|||||||
{ name: 'Upcoming', data: countByDay(upcomingFlights) },
|
{ name: 'Upcoming', data: countByDay(upcomingFlights) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
console.timeEnd('getFlightsPerDay')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,23 +103,17 @@ function groupByName(flights: Flight[], accessor: (f: Flight) => string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFlightReasons(flights: Flight[]) {
|
export function getFlightReasons(flights: Flight[]) {
|
||||||
console.time('getFlightReasons')
|
|
||||||
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown')
|
const result = groupByName(flights, f => f.flight_reason?.name ?? 'Unknown')
|
||||||
console.timeEnd('getFlightReasons')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFlightClasses(flights: Flight[]) {
|
export function getFlightClasses(flights: Flight[]) {
|
||||||
console.time('getFlightClasses')
|
|
||||||
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown')
|
const result = groupByName(flights, f => f.flight_class?.name ?? 'Unknown')
|
||||||
console.timeEnd('getFlightClasses')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSeatTypes(flights: Flight[]) {
|
export function getSeatTypes(flights: Flight[]) {
|
||||||
console.time('getSeatTypes')
|
|
||||||
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown')
|
const result = groupByName(flights, f => f.seat_type?.name ?? 'Unknown')
|
||||||
console.timeEnd('getSeatTypes')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +140,6 @@ function countCountries(flights: Flight[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
|
export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
|
||||||
console.time('getCountries')
|
|
||||||
const past = countCountries(flights)
|
const past = countCountries(flights)
|
||||||
const upcoming = countCountries(upcomingFlights)
|
const upcoming = countCountries(upcomingFlights)
|
||||||
const allNames = new Set([...past.keys(), ...upcoming.keys()])
|
const allNames = new Set([...past.keys(), ...upcoming.keys()])
|
||||||
@@ -173,14 +160,12 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
|
|||||||
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
|
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
console.timeEnd('getCountries')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Continents ────────────────────────────────────────────────────────────────
|
// ── Continents ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getContinents(flights: Flight[]) {
|
export function getContinents(flights: Flight[]) {
|
||||||
console.time('getContinents')
|
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
flights.forEach(f => {
|
flights.forEach(f => {
|
||||||
const continents = new Set<string>()
|
const continents = new Set<string>()
|
||||||
@@ -195,7 +180,6 @@ export function getContinents(flights: Flight[]) {
|
|||||||
labels: sorted.map(([name]) => name),
|
labels: sorted.map(([name]) => name),
|
||||||
series: sorted.map(([, count]) => count),
|
series: sorted.map(([, count]) => count),
|
||||||
}
|
}
|
||||||
console.timeEnd('getContinents')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +198,6 @@ function countAirlines(flights: Flight[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
|
export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
|
||||||
console.time('getTopAirlines')
|
|
||||||
const past = countAirlines(flights)
|
const past = countAirlines(flights)
|
||||||
const upcoming = countAirlines(upcomingFlights)
|
const upcoming = countAirlines(upcomingFlights)
|
||||||
const allNames = new Set([...past.keys(), ...upcoming.keys()])
|
const allNames = new Set([...past.keys(), ...upcoming.keys()])
|
||||||
@@ -236,7 +219,6 @@ export function getTopAirlines(flights: Flight[], upcomingFlights: Flight[]) {
|
|||||||
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
|
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
console.timeEnd('getTopAirlines')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +238,6 @@ function airportLabel(airport: Airport | null | undefined): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTopAirports(flights: Flight[], upcomingFlights: Flight[]) {
|
export function getTopAirports(flights: Flight[], upcomingFlights: Flight[]) {
|
||||||
console.time('getTopAirports')
|
|
||||||
const map = new Map<string, AirportItem>()
|
const map = new Map<string, AirportItem>()
|
||||||
const empty = (): AirportItem => ({ departures: 0, arrivals: 0, upcoming: 0, label: '', fullName: '' })
|
const empty = (): AirportItem => ({ departures: 0, arrivals: 0, upcoming: 0, label: '', fullName: '' })
|
||||||
|
|
||||||
@@ -313,7 +294,6 @@ export function getTopAirports(flights: Flight[], upcomingFlights: Flight[]) {
|
|||||||
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
|
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
console.timeEnd('getTopAirports')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +401,6 @@ export function getTopRoutes(flights: Flight[], upcomingFlights: Flight[]) {
|
|||||||
// ── Flight types ──────────────────────────────────────────────────────────────
|
// ── Flight types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getFlightTypes(flights: Flight[]) {
|
export function getFlightTypes(flights: Flight[]) {
|
||||||
console.time('getFlightTypes')
|
|
||||||
const counts = { International: 0, Domestic: 0 }
|
const counts = { International: 0, Domestic: 0 }
|
||||||
flights.forEach(f => {
|
flights.forEach(f => {
|
||||||
const dep = f.departure_airport.region?.country?.id
|
const dep = f.departure_airport.region?.country?.id
|
||||||
@@ -435,7 +414,6 @@ export function getFlightTypes(flights: Flight[]) {
|
|||||||
labels: sorted.map(([name]) => name),
|
labels: sorted.map(([name]) => name),
|
||||||
series: sorted.map(([, count]) => count),
|
series: sorted.map(([, count]) => count),
|
||||||
}
|
}
|
||||||
console.timeEnd('getFlightTypes')
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,22 +425,16 @@ export function useFlightStats(flights: Ref<Flight[]>) {
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
watch(flights, (list) => {
|
watch(flights, (list) => {
|
||||||
console.time('dateCache warm')
|
|
||||||
list.forEach(f => getDateParts(f))
|
list.forEach(f => getDateParts(f))
|
||||||
console.timeEnd('dateCache warm')
|
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const pastFlights = computed(() => {
|
const pastFlights = computed(() => {
|
||||||
console.time('pastFlights')
|
|
||||||
const result = flights.value.filter(f => new Date(f.departure_date) <= now)
|
const result = flights.value.filter(f => new Date(f.departure_date) <= now)
|
||||||
console.timeEnd('pastFlights')
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const upcomingFlights = computed(() => {
|
const upcomingFlights = computed(() => {
|
||||||
console.time('upcomingFlights')
|
|
||||||
const result = flights.value.filter(f => new Date(f.departure_date) > now)
|
const result = flights.value.filter(f => new Date(f.departure_date) > now)
|
||||||
console.timeEnd('upcomingFlights')
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import {onMounted, ref} from "vue";
|
|||||||
import {Flight} from "@/Types/types";
|
import {Flight} from "@/Types/types";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export function useFlights(url: string) {
|
export function useFlights(url: string, departedOnly: boolean = false) {
|
||||||
const flights = ref<Flight[]>([])
|
const flights = ref<Flight[]>([])
|
||||||
const flightsLoading = ref(true)
|
const flightsLoading = ref(true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(url)
|
const response = await axios.get(url, {
|
||||||
|
params: departedOnly ? { departed_only: true } : {}
|
||||||
|
})
|
||||||
flights.value = response.data
|
flights.value = response.data
|
||||||
} finally {
|
} finally {
|
||||||
flightsLoading.value = false
|
flightsLoading.value = false
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function useRegionFlights(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const flightsByRegion = computed(() => {
|
const flightsByRegion = computed(() => {
|
||||||
return filteredFlights.value.reduce((grouped, flight) => {
|
const grouped = filteredFlights.value.reduce((grouped, flight) => {
|
||||||
const dep = flight.departure_airport?.region
|
const dep = flight.departure_airport?.region
|
||||||
const arr = flight.arrival_airport?.region
|
const arr = flight.arrival_airport?.region
|
||||||
|
|
||||||
@@ -37,6 +37,14 @@ export function useRegionFlights(
|
|||||||
|
|
||||||
return grouped
|
return grouped
|
||||||
}, {} as Record<string, Flight[]>)
|
}, {} as Record<string, Flight[]>)
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(grouped).sort(([codeA], [codeB]) => {
|
||||||
|
const nameA = regionNames.value[codeA] ?? codeA
|
||||||
|
const nameB = regionNames.value[codeB] ?? codeB
|
||||||
|
return nameA.localeCompare(nameB)
|
||||||
|
})
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const regionNames = computed(() => {
|
const regionNames = computed(() => {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Achievement, Flight, User } from '@/Types/types'
|
||||||
|
import { useAircraftFamilies } from '@/Composables/useAircraftFamilies'
|
||||||
|
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
|
||||||
|
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
|
||||||
|
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||||
|
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
flights: Flight[]
|
||||||
|
families: Record<string, string[]>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const flights = computed(() => props.flights)
|
||||||
|
const { entries } = useAircraftFamilies(flights, props.families)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Panel>
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<PanelHeader>Families</PanelHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BadgeTable
|
||||||
|
:rows="entries"
|
||||||
|
:rowKey="entry => entry.family"
|
||||||
|
:hasItems="entry => entry.flights.length > 0"
|
||||||
|
labelWidth="8em"
|
||||||
|
>
|
||||||
|
<template #label="{ row: entry }">
|
||||||
|
<div class="family-label">
|
||||||
|
<span class="family-name">Airbus {{ entry.family }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #items="{ row: entry }">
|
||||||
|
<FlightBadge
|
||||||
|
v-for="flight in entry.flights"
|
||||||
|
:key="flight.id"
|
||||||
|
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
|
||||||
|
:flight="flight"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BadgeTable>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<slot name="extra" />
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Requirements</PanelHeader>
|
||||||
|
<p>
|
||||||
|
To complete this challenge you must fly on at least one aircraft from every
|
||||||
|
Airbus A3xx family — the A300, A310, A318, A319, A320, A321, A330, A340, A350,
|
||||||
|
and A380.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Any variant within a family counts. For example, an Airbus A320neo (A20N) and
|
||||||
|
a classic A320 both satisfy the A320 family requirement.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Difficulty</PanelHeader>
|
||||||
|
<p>
|
||||||
|
If you are starting this challenge today, then this challenge is near-impossible to complete and will soon be impossible. At the time of writing, the following aircraft are very hard to fly on:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Airbus A300</b> - Only in service in Iran, mostly domestically. Very hard to arrange.</li>
|
||||||
|
<li><b>Airbus A310</b> - In commercial service in Iran and Afghanistan. Afghanistan is far less sanctioned, so easier to book a flight. Dubai to Kabul is a reliabe route to see the A310. An A310 also acts as a vomit comet in Europe</li>
|
||||||
|
<li><b>Airbus A318</b> - Very few left operating for Air France. Will likely be retired before you have read this.</li>
|
||||||
|
<li><b>Airbus A340</b> - Rapidly being retired from service in the western world with Lufthansa being the last hold out (at time of writing). Iran and Venezuela are likely to hold on to them for sometime. South African always seems to bring them back into service too. A few more scattered around in charter or smaller national airlines</li>
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-label {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.family-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Achievement, Flight, User } from '@/Types/types'
|
||||||
|
import { useAircraftFamilies } from '@/Composables/useAircraftFamilies'
|
||||||
|
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
|
||||||
|
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
|
||||||
|
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
|
||||||
|
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||||
|
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
flights: Flight[]
|
||||||
|
families: Record<string, string[]>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const flights = computed(() => props.flights)
|
||||||
|
const { entries, completedCount, totalCount } = useAircraftFamilies(flights, props.families)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<PanelHeader>Families</PanelHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BadgeTable
|
||||||
|
:rows="entries"
|
||||||
|
:rowKey="entry => entry.family"
|
||||||
|
:hasItems="entry => entry.flights.length > 0"
|
||||||
|
labelWidth="8em"
|
||||||
|
>
|
||||||
|
<template #label="{ row: entry }">
|
||||||
|
<div class="family-label">
|
||||||
|
<span class="family-name">Boeing {{ entry.family }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #items="{ row: entry }">
|
||||||
|
<FlightBadge
|
||||||
|
v-for="flight in entry.flights"
|
||||||
|
:key="flight.id"
|
||||||
|
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
|
||||||
|
:flight="flight"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BadgeTable>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<slot name="extra" />
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Requirements</PanelHeader>
|
||||||
|
<p>
|
||||||
|
To complete this challenge you must fly on at least one aircraft from every
|
||||||
|
Boeing 7x7 family — the 707, 717, 727, 737, 747, 757, 767, 777, and 787.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Any variant within a family counts. For example, a Boeing 737-800 (B738) and
|
||||||
|
a Boeing 737 MAX 8 (B38M) both satisfy the 737 family requirement.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Difficulty</PanelHeader>
|
||||||
|
<p>
|
||||||
|
If you are starting this challenge today, then this challenge is impossible to complete and will not count towards your achievement total. If you have historically completed it, it will.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Difficult types to fly on
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Boeing 707</b> - Completely impossible to fly today. The US Military still operates some derivatives, but they do not count towards this challenge</li>
|
||||||
|
<li><b>Boeing 727</b> - No commercial service. One operates as a vomit comet in the USA. Very expensive, but technically possible to fly on</li>
|
||||||
|
<li><b>Boeing 717</b> - Still in service, mostly in the US, but being withdrawn rapidly.</li>
|
||||||
|
<li><b>Boeing 747</b> - Being pulled from service rapidly. Almost no 747-400s left in service (just Lufthansa). 747-8s are still active with Korean Air, Air China and Lufthansa but the US Air Force has acquired a large number from these airlines and they are dwindling</li>
|
||||||
|
<li><b>Boeing 757</b> - Still fairly common in the USA but quickly going extinct elsewhere.</li>
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.family-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
|
||||||
|
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
alliance: Alliance
|
||||||
|
airlines: Airline[]
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AllianceChallenge
|
||||||
|
:achievement="achievement"
|
||||||
|
:user="user"
|
||||||
|
:isFollowing="isFollowing"
|
||||||
|
:alliance="alliance"
|
||||||
|
:airlines="airlines"
|
||||||
|
:flights="flights"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
The <b>Oneworld</b> Alliance is currently the smallest of the big 3 major alliances, but with members all around the world it's no mean feat to get on all of them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</AllianceChallenge>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
|
||||||
|
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
alliance: Alliance
|
||||||
|
airlines: Airline[]
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AllianceChallenge
|
||||||
|
:achievement="achievement"
|
||||||
|
:user="user"
|
||||||
|
:isFollowing="isFollowing"
|
||||||
|
:alliance="alliance"
|
||||||
|
:airlines="airlines"
|
||||||
|
:flights="flights"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
The SkyTeam Alliance is the youngest of the 3 major alliances, but does not have the smallest roster!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</AllianceChallenge>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
|
||||||
|
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
alliance: Alliance
|
||||||
|
airlines: Airline[]
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AllianceChallenge
|
||||||
|
:achievement="achievement"
|
||||||
|
:user="user"
|
||||||
|
:isFollowing="isFollowing"
|
||||||
|
:alliance="alliance"
|
||||||
|
:airlines="airlines"
|
||||||
|
:flights="flights"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fly with every member airline of <b>Star Alliance</b>, the world's largest airline
|
||||||
|
alliance by number of member carriers.
|
||||||
|
</p>
|
||||||
|
</AllianceChallenge>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
|
||||||
|
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
alliance: Alliance
|
||||||
|
airlines: Airline[]
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AllianceChallenge
|
||||||
|
:achievement="achievement"
|
||||||
|
:user="user"
|
||||||
|
:isFollowing="isFollowing"
|
||||||
|
:alliance="alliance"
|
||||||
|
:airlines="airlines"
|
||||||
|
:flights="flights"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<b>Vanilla Alliance</b> is a small, regional alliance in the Vanilla Islands. It is a paper alliance that is effectively defunct, but it's still a fun one to try for!
|
||||||
|
</p>
|
||||||
|
</AllianceChallenge>
|
||||||
|
</template>
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Achievement, Flight, User } from '@/Types/types'
|
||||||
|
import { Continent, useDirectedContinentPairs } from '@/Composables/useContinentPairs'
|
||||||
|
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
|
||||||
|
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
|
||||||
|
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
|
||||||
|
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||||
|
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
flights: Flight[]
|
||||||
|
continents: Continent[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const flights = computed(() => props.flights)
|
||||||
|
const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica'))
|
||||||
|
|
||||||
|
const { entriesByDeparture, departureNames, completedCount, totalCount } =
|
||||||
|
useDirectedContinentPairs(flights, continents)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<!-- One panel per departure continent -->
|
||||||
|
<Panel v-for="dep in departureNames" :key="dep">
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<PanelHeader>Departing {{ dep }}</PanelHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BadgeTable
|
||||||
|
:rows="entriesByDeparture.get(dep) ?? []"
|
||||||
|
:rowKey="entry => entry.key"
|
||||||
|
:hasItems="entry => entry.flights.length > 0"
|
||||||
|
labelWidth="18em"
|
||||||
|
>
|
||||||
|
<template #label="{ row: entry }">
|
||||||
|
<div class="pair-label">
|
||||||
|
<span class="pair-name">{{ entry.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #items="{ row: entry }">
|
||||||
|
<FlightBadge
|
||||||
|
v-for="flight in entry.flights"
|
||||||
|
:key="flight.id"
|
||||||
|
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
|
||||||
|
:flight="flight"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BadgeTable>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<slot name="extra" />
|
||||||
|
|
||||||
|
<!-- Requirements -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Requirements</PanelHeader>
|
||||||
|
<p>
|
||||||
|
To complete this challenge you must fly between every possible pair of continents
|
||||||
|
in <strong>both directions</strong> — a flight from Europe to Asia and a separate
|
||||||
|
flight from Asia to Europe are each required.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For <strong>same-continent routes</strong> (e.g. Africa → Africa), only
|
||||||
|
<strong>international flights</strong> count — domestic flights within the same
|
||||||
|
country are excluded.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Achievement, Flight, User } from '@/Types/types'
|
||||||
|
import { Continent, useUndirectedContinentPairs } from '@/Composables/useContinentPairs'
|
||||||
|
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
|
||||||
|
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
|
||||||
|
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||||
|
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
flights: Flight[]
|
||||||
|
continents: Continent[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const flights = computed(() => props.flights)
|
||||||
|
const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica'))
|
||||||
|
|
||||||
|
const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flights, continents)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Continent pairs table -->
|
||||||
|
<Panel>
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<PanelHeader>Continent Pairs</PanelHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BadgeTable
|
||||||
|
:rows="entries"
|
||||||
|
:rowKey="entry => entry.key"
|
||||||
|
:hasItems="entry => entry.flights.length > 0"
|
||||||
|
labelWidth="18em"
|
||||||
|
>
|
||||||
|
<template #label="{ row: entry }">
|
||||||
|
<div class="pair-label">
|
||||||
|
<span class="pair-name">{{ entry.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #items="{ row: entry }">
|
||||||
|
<FlightBadge
|
||||||
|
v-for="flight in entry.flights"
|
||||||
|
:key="flight.id"
|
||||||
|
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
|
||||||
|
:flight="flight"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BadgeTable>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<slot name="extra" />
|
||||||
|
|
||||||
|
<!-- Requirements -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Requirements</PanelHeader>
|
||||||
|
<p>
|
||||||
|
To complete this challenge you must fly at least one flight between every possible
|
||||||
|
pair of continents — including flights within the same continent.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For <strong>same-continent pairs</strong> (e.g. Africa ↔ Africa), only
|
||||||
|
<strong>international flights</strong> count — domestic flights within the same
|
||||||
|
country are excluded.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-label {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Achievement, Flight, User } from '@/Types/types'
|
||||||
|
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
|
||||||
|
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
|
||||||
|
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
|
||||||
|
import AirlineAlphabetTable from '@/Components/FlightsGoneBy/AirlineAlphabetTable.vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useAlphabetAirlines, type CodeType } from '@/Composables/useAlphabetAirlines'
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const codeType = ref<CodeType>('iata')
|
||||||
|
const showNumbers = ref(false)
|
||||||
|
const flightsRef = computed(() => props.flights)
|
||||||
|
|
||||||
|
const { flightsByLetter, visitedLetters, allLetters } = useAlphabetAirlines(
|
||||||
|
flightsRef,
|
||||||
|
codeType,
|
||||||
|
showNumbers,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only count letters (A–Z) towards progress, never digits
|
||||||
|
const visitedLetterCount = computed(() =>
|
||||||
|
[...visitedLetters.value].filter(k => isNaN(Number(k))).length
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableYears = computed(() => {
|
||||||
|
const years = new Set(props.flights.map(f => new Date(f.departure_date).getFullYear()))
|
||||||
|
return [...years].sort((a, b) => b - a)
|
||||||
|
})
|
||||||
|
|
||||||
|
const yearItems = computed(() => [
|
||||||
|
{ title: 'None', value: null },
|
||||||
|
...availableYears.value.map(y => ({ title: String(y), value: y })),
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const selectedYear = ref<number | null>(
|
||||||
|
availableYears.value.includes(currentYear) ? currentYear : (availableYears.value[0] ?? null)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Copy BBCode
|
||||||
|
const airlineTable = ref<InstanceType<typeof AirlineAlphabetTable> | null>(null)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
async function copyBBCode() {
|
||||||
|
const text = airlineTable.value?.toBBCode()
|
||||||
|
if (text == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = text
|
||||||
|
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.focus()
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => (copied.value = false), 2000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Challenge description -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>The Airline Alphabet Challenge</PanelHeader>
|
||||||
|
<PanelSubHeader centered>
|
||||||
|
<p>
|
||||||
|
A twist on the classic Alphabet Challenge — instead of airports, you need to fly with
|
||||||
|
airlines whose <b>IATA codes</b> begin with each letter of the alphabet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can copy your results as BBCode using the copy icon above the table, and select
|
||||||
|
a year to highlight airlines that are new for that given year.
|
||||||
|
</p>
|
||||||
|
</PanelSubHeader>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Progress grid -->
|
||||||
|
<Panel style="display: flex; flex-direction: column; align-items: center; gap: 1rem;">
|
||||||
|
<PanelHeader centered>Progress</PanelHeader>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div>
|
||||||
|
<button :class="['code-toggle', codeType === 'iata' ? 'active' : '']" @click="codeType = 'iata'">IATA</button>
|
||||||
|
<button :class="['code-toggle', codeType === 'icao' ? 'active' : '']" @click="codeType = 'icao'">ICAO</button>
|
||||||
|
</div>
|
||||||
|
<button :class="['code-toggle', showNumbers ? 'active' : '']" @click="showNumbers = !showNumbers">
|
||||||
|
Show 0–9
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="progress-summary">{{ visitedLetterCount }} / {{allLetters.length}} letters visited</p>
|
||||||
|
|
||||||
|
<div class="alphabet-grid">
|
||||||
|
<div
|
||||||
|
v-for="letter in allLetters"
|
||||||
|
:key="letter"
|
||||||
|
:class="['letter-tile', visitedLetters.has(letter) ? 'visited' : 'unvisited']"
|
||||||
|
:title="visitedLetters.has(letter) ? 'Visited' : 'Not yet visited'"
|
||||||
|
>
|
||||||
|
{{ letter }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Airlines by letter -->
|
||||||
|
<Panel>
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<PanelHeader>Airlines By Letter</PanelHeader>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<v-select
|
||||||
|
v-model="selectedYear"
|
||||||
|
:items="yearItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="New For"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
class="year-select"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
:icon="copied ? 'mdi-check' : 'mdi-content-copy'"
|
||||||
|
:color="copied ? 'success' : undefined"
|
||||||
|
density="compact"
|
||||||
|
variant="text"
|
||||||
|
title="Copy as BBCode"
|
||||||
|
@click="copyBBCode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AirlineAlphabetTable
|
||||||
|
ref="airlineTable"
|
||||||
|
:letters="allLetters"
|
||||||
|
:flightsByLetter="flightsByLetter"
|
||||||
|
:codeType="codeType"
|
||||||
|
:selectedYear="selectedYear"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Requirements -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Requirements</PanelHeader>
|
||||||
|
<p>
|
||||||
|
To complete this challenge you must fly with airlines whose IATA codes begin with each
|
||||||
|
of the 26 letters of the alphabet — A through Z. Airlines without an IATA code do not count.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A single flight contributes one letter — the operating airline's IATA code. So a flight
|
||||||
|
on <strong>QF</strong> (Qantas) counts toward <strong>Q</strong>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
ICAO codes do not count towards this challenge, but you can toggle to ICAO view to see
|
||||||
|
where you're at for fun. Similarly, numeric IATA codes (such as <strong>3U</strong>) exist
|
||||||
|
but do not count towards the challenge — enable "Show 0–9" to see them.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It would unlikely to be possible to complete the challenge with both numbers and letters required.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Difficulty -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Difficulty</PanelHeader>
|
||||||
|
<p>
|
||||||
|
Like the airport version, this challenge is <b>very</b> difficult, but it is slightly easier and most letters have a fair few options.
|
||||||
|
Some difficulty.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>X</b> — Very few airlines; worth researching regional carriers in your area.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Most other letters have a reasonable selection of options across different continents,
|
||||||
|
though some will require deliberate routing to achieve.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress-summary {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-toggle {
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-toggle.active {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(13, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.alphabet-grid {
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-tile {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-tile.visited {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-tile.unvisited {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-select {
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Achievement, Flight, User } from '@/Types/types'
|
||||||
|
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
|
||||||
|
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
|
||||||
|
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
|
||||||
|
import AlphabetTable from '@/Components/FlightsGoneBy/AlphabetTable.vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useAlphabetFlights, type CodeType } from '@/Composables/useAlphabetFlights'
|
||||||
|
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
flights: Flight[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const codeType = ref<CodeType>('iata')
|
||||||
|
const flightsRef = computed(() => props.flights)
|
||||||
|
|
||||||
|
const visited = computed(() => useAlphabetFlights(flightsRef, codeType.value).visitedLetters.value)
|
||||||
|
const byLetter = computed(() => useAlphabetFlights(flightsRef, codeType.value).flightsByLetter.value)
|
||||||
|
const { allLetters } = useAlphabetFlights(flightsRef, codeType.value)
|
||||||
|
|
||||||
|
const visitedCount = computed(() => visited.value.size)
|
||||||
|
|
||||||
|
const availableYears = computed(() => {
|
||||||
|
const years = new Set(props.flights.map(f => new Date(f.departure_date).getFullYear()))
|
||||||
|
return [...years].sort((a, b) => b - a)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Year select items: "None" option + all flight years
|
||||||
|
const yearItems = computed(() => [
|
||||||
|
{ title: 'None', value: null },
|
||||||
|
...availableYears.value.map(y => ({ title: String(y), value: y })),
|
||||||
|
])
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const selectedYear = ref<number | null>(
|
||||||
|
availableYears.value.includes(currentYear) ? currentYear : (availableYears.value[0] ?? null)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
const alphabetTable = ref<InstanceType<typeof AlphabetTable> | null>(null)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
async function copyBBCode() {
|
||||||
|
const text = alphabetTable.value?.toBBCode()
|
||||||
|
if (text == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch {
|
||||||
|
// Fallback for non-HTTPS or browsers that block clipboard API
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = text
|
||||||
|
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.focus()
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => (copied.value = false), 2000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Challenge description -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>The Alphabet Challenge</PanelHeader>
|
||||||
|
<PanelSubHeader centered>
|
||||||
|
<p>
|
||||||
|
Originating from aviation forums, as <b>Land at the Alphabet</b>, this challenge is a tad more lenient
|
||||||
|
as it allows for both landing and taking off to count towards a letter.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In honor of this challenge's forum roots, you can copy your results of this challenge as BBCode by pressing the copy icon above the table, and select
|
||||||
|
a year to highlight airports that are new for that given year.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</PanelSubHeader>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Progress grid -->
|
||||||
|
<Panel style="display: flex; flex-direction: column; align-items: center; gap: 1rem;">
|
||||||
|
<PanelHeader centered>Progress</PanelHeader>
|
||||||
|
<div>
|
||||||
|
<button :class="['code-toggle', codeType === 'iata' ? 'active' : '']" @click="codeType = 'iata'">IATA</button>
|
||||||
|
<button :class="['code-toggle', codeType === 'icao' ? 'active' : '']" @click="codeType = 'icao'">ICAO</button>
|
||||||
|
</div>
|
||||||
|
<p class="progress-summary">{{ visitedCount }} / {{ allLetters.length }} letters visited</p>
|
||||||
|
|
||||||
|
<div class="alphabet-grid">
|
||||||
|
<div
|
||||||
|
v-for="letter in allLetters"
|
||||||
|
:key="letter"
|
||||||
|
:class="['letter-tile', visited.has(letter) ? 'visited' : 'unvisited']"
|
||||||
|
:title="visited.has(letter) ? 'Visited' : 'Not yet visited'"
|
||||||
|
>
|
||||||
|
{{ letter }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Airports by letter -->
|
||||||
|
<Panel>
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<PanelHeader>Airports By Letter</PanelHeader>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<v-select
|
||||||
|
v-model="selectedYear"
|
||||||
|
:items="yearItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="New For"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
class="year-select"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
:icon="copied ? 'mdi-check' : 'mdi-content-copy'"
|
||||||
|
:color="copied ? 'success' : undefined"
|
||||||
|
density="compact"
|
||||||
|
variant="text"
|
||||||
|
title="Copy as BBCode"
|
||||||
|
@click="copyBBCode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlphabetTable
|
||||||
|
ref="alphabetTable"
|
||||||
|
:letters="allLetters"
|
||||||
|
:flightsByLetter="byLetter"
|
||||||
|
:codeType="codeType"
|
||||||
|
:selectedYear="selectedYear"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Requirements -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Requirements</PanelHeader>
|
||||||
|
<p>
|
||||||
|
To complete this challenge you must take off from or land at airports whose
|
||||||
|
IATA codes begin with each of the 26 letters of the alphabet —
|
||||||
|
A through Z. Airports without an IATA code do not count.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Both the departure and arrival airport on a single flight can count toward different
|
||||||
|
letters simultaneously, so a flight from <strong>ABX</strong> to <strong>BNE</strong>
|
||||||
|
would count toward both A and B.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
ICAO codes do not count towards this challenge (and it is not possible to visit an ICAO code for every letter), but you can
|
||||||
|
toggle to ICAO view to see where you're at for fun.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Difficulty -->
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Difficulty</PanelHeader>
|
||||||
|
<p>
|
||||||
|
This is a <b>very</b> difficult challenge no matter how well travelled you are. However it one of the most fun to try and accomplish as it will get you travelling
|
||||||
|
to places you might never have otherwise considered.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Most letters are possible to visit somewhat organically if you fly enough, but some will be difficult. <b>Q</b> is probably the most difficult letter to obtain -
|
||||||
|
there's no rules against having a code starting with Q, but due to many aviation terms starting with Q (such as QNH), very few airports use it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
At time of writing, there are just 2 major airports that have daily scheduled service by major airlines, and 4 airports with any commercial service at all.
|
||||||
|
Others - such as seaplane ports - might have GA or charter service.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>QRO</b> in Mexico - a fantastic place to visit and with flights from around Mexico and the US.</li>
|
||||||
|
<li><b>QSZ</b> in China is also a very interesting place to visit and is served well from Urumqi and Xi'an.</li>
|
||||||
|
<li><b>QBC</b> is a small airport in Canada and has daily runs on commuter aircraft from Vancouver.</li>
|
||||||
|
<li><b>QSR</b> is probably most promising recently - it did not have commercial service for 10 years and is having good growth now with European low cost carriers.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Other potential candidates do not seem so promising
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>QAH</b> was in India as a secondary Delhi airport, but the code was changed to <b>HDO</b> </li>
|
||||||
|
<li><b>QAJ</b> was proposed for Ajman Airport which might have acted as an alternate to Dubai and Sharjah, but construction has not moved forward.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Every other letter usually has a decent selection of options across most continents.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress-summary {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-toggle {
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-toggle.active {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(13, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.alphabet-grid {
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-tile {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-tile.visited {
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-tile.unvisited {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table toolbar */
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-select {
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -36,9 +36,9 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
|
|||||||
<template>
|
<template>
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader centered>The Challenge</PanelHeader>
|
<PanelHeader centered>The Challenge</PanelHeader>
|
||||||
<p>
|
<PanelSubHeader centered>
|
||||||
Either take off or land from an airport in each Australian State and Internal Territory.
|
Either take off or land from an airport in each Australian State and Internal Territory.
|
||||||
</p>
|
</PanelSubHeader>
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel style="display:flex; flex-direction: column; align-items: center; justify-content: center;">
|
<Panel style="display:flex; flex-direction: column; align-items: center; justify-content: center;">
|
||||||
<PanelHeader centered>Progress</PanelHeader>
|
<PanelHeader centered>Progress</PanelHeader>
|
||||||
@@ -46,7 +46,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
|
|||||||
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
|
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
|
||||||
<br/>
|
<br/>
|
||||||
<PanelHeader center>Flights By State</PanelHeader>
|
<PanelHeader center>Flights By State</PanelHeader>
|
||||||
<PanelSubHeader>5 Most Recent Flights By State</PanelSubHeader>
|
|
||||||
<FlightRegionTable
|
<FlightRegionTable
|
||||||
:regionCodes="stateLocalCodes"
|
:regionCodes="stateLocalCodes"
|
||||||
:regionNames="regionNames"
|
:regionNames="regionNames"
|
||||||
@@ -56,7 +56,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
|
|||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader centered>Requirements</PanelHeader>
|
<PanelHeader centered>Requirements</PanelHeader>
|
||||||
<p>
|
<p>
|
||||||
The 8 states and territories you need to visit to complete this challenge are Queensland, Victoria, New South Wales, South Australia, Tasmania, Western Australia, the Northern Territory
|
The 8 states and territories you need to visit to complete this challenge are Queensland, Victoria, New South Wales, South Australia, Tasmania, Western Australia, the Northern Territory
|
||||||
and the Australian Capital Territory.
|
and the Australian Capital Territory.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import MainLayout from "@/Layouts/MainLayout.vue";
|
||||||
|
import {Achievement, Flight, Region, User} from "@/Types/types";
|
||||||
|
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
|
||||||
|
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
|
||||||
|
import {computed} from "vue";
|
||||||
|
import PanelSubHeader from "@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue";
|
||||||
|
|
||||||
|
import FlightRegionTable from "@/Components/FlightsGoneBy/FlightRegionTable.vue";
|
||||||
|
import {useRegionFlights} from "@/Composables/useRegionFlights";
|
||||||
|
import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue";
|
||||||
|
import Brazil from "@/Components/Maps/Brazil.vue";
|
||||||
|
|
||||||
|
|
||||||
|
defineOptions({ layout: MainLayout })
|
||||||
|
const props = defineProps<{
|
||||||
|
achievement: Achievement
|
||||||
|
user: User
|
||||||
|
isFollowing: boolean
|
||||||
|
flights: Flight[]
|
||||||
|
regions: Region[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const countryCode = 'BR'
|
||||||
|
const stateLocalCodes = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
|
||||||
|
|
||||||
|
const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
|
||||||
|
computed(() => props.flights),
|
||||||
|
stateLocalCodes,
|
||||||
|
countryCode,
|
||||||
|
computed(() => props.regions)
|
||||||
|
)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>The Challenge</PanelHeader>
|
||||||
|
<p>
|
||||||
|
Either take off or land from an airport in each Brazilian State plus the Distrito Federal (Brasilia).
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
<Panel style="display:flex; flex-direction: column; align-items: center; justify-content: center;">
|
||||||
|
<PanelHeader centered>Progress</PanelHeader>
|
||||||
|
<Brazil :visitedStates="visitedRegions" :flights="flightsByRegion" style="width: 100%;"/>
|
||||||
|
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
|
||||||
|
<br/>
|
||||||
|
<PanelHeader center>Flights By State</PanelHeader>
|
||||||
|
<FlightRegionTable
|
||||||
|
:regionCodes="stateLocalCodes"
|
||||||
|
:regionNames="regionNames"
|
||||||
|
:flightsByRegion="flightsByRegion"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<PanelHeader centered>Difficulty</PanelHeader>
|
||||||
|
<p>
|
||||||
|
This is a very difficult challenge - Brazil is a reasonably mature aviation market by South American standards, but flights can still be expensive and the major
|
||||||
|
hubs tend to be in the south so you can expect a lot of doubling back. Brasilia might not be a bad base as it's quite central and well connected, but if you're trying
|
||||||
|
to achieve this trophy organically, it's unlikely to just happen!
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -49,8 +49,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
|
|||||||
<Canada :visitedStates="visitedRegions" :flights="flightsByRegion" style="width: 100%;"/>
|
<Canada :visitedStates="visitedRegions" :flights="flightsByRegion" style="width: 100%;"/>
|
||||||
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
|
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
|
||||||
<br/>
|
<br/>
|
||||||
<PanelHeader center>Flights By State</PanelHeader>
|
<PanelHeader centered>Flights By Province</PanelHeader>
|
||||||
<PanelSubHeader>5 Most Recent Flights By State</PanelSubHeader>
|
|
||||||
<FlightRegionTable
|
<FlightRegionTable
|
||||||
:regionCodes="stateLocalCodes"
|
:regionCodes="stateLocalCodes"
|
||||||
:regionNames="regionNames"
|
:regionNames="regionNames"
|
||||||
@@ -60,7 +59,7 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
|
|||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader centered>Difficulty</PanelHeader>
|
<PanelHeader centered>Difficulty</PanelHeader>
|
||||||
<p>
|
<p>
|
||||||
This challenge is not extremely difficult bur Canada's geography does complicate it and it could get expensive!
|
This challenge is not extremely difficult but Canada's geography does complicate it and it could get expensive!
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Canada's population is all in the South, so visiting the Northwest Territories, Yukon and Nanavut will likely require doubling back south to get connecting flights.
|
Canada's population is all in the South, so visiting the Northwest Territories, Yukon and Nanavut will likely require doubling back south to get connecting flights.
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
|
|||||||
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
|
<RegionLegend :regionNames="regionNames" :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes"/>
|
||||||
<br/>
|
<br/>
|
||||||
<PanelHeader center>Flights By Region</PanelHeader>
|
<PanelHeader center>Flights By Region</PanelHeader>
|
||||||
<PanelSubHeader>5 Most Recent Flights Per Region</PanelSubHeader>
|
|
||||||
<FlightRegionTable
|
<FlightRegionTable
|
||||||
:regionCodes="stateLocalCodes"
|
:regionCodes="stateLocalCodes"
|
||||||
:regionNames="regionNames"
|
:regionNames="regionNames"
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const { flightsByRegion, visitedRegions, regionNames } = useRegionFlights(
|
|||||||
<RegionLegend :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes" :regionNames="regionNames"/>
|
<RegionLegend :visitedRegions="visitedRegions" :stateLocalCodes="stateLocalCodes" :regionNames="regionNames"/>
|
||||||
<br/>
|
<br/>
|
||||||
<PanelHeader center>Flights By State</PanelHeader>
|
<PanelHeader center>Flights By State</PanelHeader>
|
||||||
<PanelSubHeader>5 Most Recent Flights By State</PanelSubHeader>
|
|
||||||
<FlightRegionTable
|
<FlightRegionTable
|
||||||
:regionCodes="stateLocalCodes"
|
:regionCodes="stateLocalCodes"
|
||||||
:regionNames="regionNames"
|
:regionNames="regionNames"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {Achievement, Region, User, UserAchievement} from "@/Types/types";
|
import {Achievement, Airline, Continent, Region, User, UserAchievement} from "@/Types/types";
|
||||||
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
|
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
|
||||||
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
|
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
|
||||||
import {computed, defineAsyncComponent} from 'vue';
|
import {computed, defineAsyncComponent} from 'vue';
|
||||||
@@ -11,6 +11,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
|
|||||||
import {useFlights} from "@/Composables/useFlights";
|
import {useFlights} from "@/Composables/useFlights";
|
||||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||||
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
||||||
|
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
|
||||||
|
|
||||||
defineOptions({ layout: MainLayout })
|
defineOptions({ layout: MainLayout })
|
||||||
|
|
||||||
@@ -18,13 +19,18 @@ const props = defineProps<{
|
|||||||
achievement: Achievement
|
achievement: Achievement
|
||||||
userAchievement: UserAchievement
|
userAchievement: UserAchievement
|
||||||
user: User
|
user: User
|
||||||
|
loggedInUser: User | null
|
||||||
isFollowing: boolean
|
isFollowing: boolean
|
||||||
flight_api_url: string
|
flight_api_url: string
|
||||||
regions: Region[]
|
regions: Region[]
|
||||||
|
alliance: string | null
|
||||||
|
airlines: Airline[]
|
||||||
|
continents: Continent[]
|
||||||
|
aircraft_families: Record<string, string[]>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
||||||
const { flights, flightsLoading } = useFlights(props.flight_api_url)
|
const { flights, flightsLoading } = useFlights(props.flight_api_url, true)
|
||||||
|
|
||||||
const AchievementDetail = defineAsyncComponent(
|
const AchievementDetail = defineAsyncComponent(
|
||||||
() => import(`./Achievements/${props.achievement.internal_name}.vue`)
|
() => import(`./Achievements/${props.achievement.internal_name}.vue`)
|
||||||
@@ -63,14 +69,14 @@ const difficultyVariant = computed(() => {
|
|||||||
<ProfileLayout :user="user" :isFollowing="isFollowing" :loading="flightsLoading">
|
<ProfileLayout :user="user" :isFollowing="isFollowing" :loading="flightsLoading">
|
||||||
<Head :title="`${achievement.name}`" />
|
<Head :title="`${achievement.name}`" />
|
||||||
<div class="innerLayout">
|
<div class="innerLayout">
|
||||||
<v-btn
|
<ButtonLink variant="flat" icon="mdi-arrow-left" :label="`Back to ${user.name}'s Achievements`" :href="route('profile.achievements', { user: user.name })" />
|
||||||
prepend-icon="mdi-arrow-left"
|
|
||||||
variant="flat"
|
|
||||||
>
|
<VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser">
|
||||||
<Link :href="route('profile.achievements', { user: user.name })">
|
You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress,
|
||||||
Back to {{ user.name }}'s Achievements
|
<Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})">please click here</Link>.
|
||||||
</Link>
|
</VAlert>
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<div class="achievement-hero">
|
<div class="achievement-hero">
|
||||||
@@ -110,7 +116,18 @@ const difficultyVariant = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<component :is="AchievementDetail" :regions="regions" :flights="flights" :achievement="achievement" :user="user" :isFollowing="isFollowing" />
|
<component
|
||||||
|
:is="AchievementDetail"
|
||||||
|
:regions="regions"
|
||||||
|
:flights="flights"
|
||||||
|
:achievement="achievement"
|
||||||
|
:user="user"
|
||||||
|
:isFollowing="isFollowing"
|
||||||
|
:airlines="airlines"
|
||||||
|
:alliance="alliance"
|
||||||
|
:continents="continents"
|
||||||
|
:families="aircraft_families"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ProfileLayout>
|
</ProfileLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MainLayout from "@/Layouts/MainLayout.vue";
|
import MainLayout from "@/Layouts/MainLayout.vue";
|
||||||
import {Head} from "@inertiajs/vue3";
|
import {Head, Link} from "@inertiajs/vue3";
|
||||||
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
|
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
|
||||||
import {Achievement, Flight, User, UserAchievement} from "@/Types/types";
|
import {Achievement, Flight, User, UserAchievement} from "@/Types/types";
|
||||||
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
|
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
|
||||||
@@ -10,6 +10,7 @@ import AirlinePanel from "@/Components/FlightsGoneBy/Panels/AirlinePanel.vue";
|
|||||||
import AircraftPanel from "@/Components/FlightsGoneBy/Panels/AircraftPanel.vue";
|
import AircraftPanel from "@/Components/FlightsGoneBy/Panels/AircraftPanel.vue";
|
||||||
import RoutePanel from "@/Components/FlightsGoneBy/Panels/RoutePanel.vue";
|
import RoutePanel from "@/Components/FlightsGoneBy/Panels/RoutePanel.vue";
|
||||||
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
|
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
|
||||||
|
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
|
||||||
|
|
||||||
defineOptions({ layout: MainLayout })
|
defineOptions({ layout: MainLayout })
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ const props = defineProps<{
|
|||||||
<Head :title="`${flight.flight_number ?? user.name + '\'s Flight'}`" />
|
<Head :title="`${flight.flight_number ?? user.name + '\'s Flight'}`" />
|
||||||
|
|
||||||
<div class="flight-profile">
|
<div class="flight-profile">
|
||||||
|
<ButtonLink variant="flat" icon="mdi-arrow-left" :label="`Back to ${user.name}'s Flights`" :href="route('profile.departure-board', { user: user.name, flight: flight.id })" />
|
||||||
|
|
||||||
<!-- Main grid -->
|
<!-- Main grid -->
|
||||||
<div class="profile-grid">
|
<div class="profile-grid">
|
||||||
<RoutePanel :flight="flight" />
|
<RoutePanel :flight="flight" />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const props = defineProps<{
|
|||||||
initialView?: ProfileView
|
initialView?: ProfileView
|
||||||
isFollowing: boolean
|
isFollowing: boolean
|
||||||
flight_api_url: string
|
flight_api_url: string
|
||||||
|
flightCount: number,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Flights state ─────────────────────────────────────────────────────────────
|
// ── Flights state ─────────────────────────────────────────────────────────────
|
||||||
@@ -120,7 +121,7 @@ function switchView(view: ProfileView) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head :title="`${user.name}'s Flights`" />
|
<Head :title="`${user.name}'s Flights`" />
|
||||||
<ProfileLayout :is-following="isFollowing" :flightCount="flights.length" :user="user" :loading="flightsLoading">
|
<ProfileLayout :is-following="isFollowing" :flightCount="flightCount" :user="user" :loading="flightsLoading">
|
||||||
<ProfileViewSwitcher :user="user" :active-view="activeView" @update:active-view="switchView" />
|
<ProfileViewSwitcher :user="user" :active-view="activeView" @update:active-view="switchView" />
|
||||||
<DepartureBoard v-if="activeView === 'board'" :flight-id="localSelectedFlightId" :flight-stats="stats" :canEdit="canEdit" :user="user" />
|
<DepartureBoard v-if="activeView === 'board'" :flight-id="localSelectedFlightId" :flight-stats="stats" :canEdit="canEdit" :user="user" />
|
||||||
<BoardingPasses v-if="activeView === 'passes'" :flight-stats="stats" :canEdit="canEdit" />
|
<BoardingPasses v-if="activeView === 'passes'" :flight-stats="stats" :canEdit="canEdit" />
|
||||||
|
|||||||
Vendored
+1
@@ -99,6 +99,7 @@ export interface Achievement {
|
|||||||
category?: AchievementCategory
|
category?: AchievementCategory
|
||||||
difficulty?: AchievementDifficulty
|
difficulty?: AchievementDifficulty
|
||||||
has_page: boolean
|
has_page: boolean
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAchievement {
|
export interface UserAchievement {
|
||||||
|
|||||||
Reference in New Issue
Block a user