Compare commits

...
16 Commits
Author SHA1 Message Date
dredgy 8514d76724 Merge pull request 'Tooltip on Mobile' (#12) from make-flighttooltip-better-on-mobile into main
Reviewed-on: #12
2026-08-16 20:48:02 +10:00
dredgy 7bca9a8d31 Tooltip on Mobile 2026-08-16 20:47:20 +10:00
dredgy e17203aefc Merge pull request 'Formatting and Share Buttons' (#11) from form-validation-improvements into main
Reviewed-on: #11
2026-08-15 21:05:22 +10:00
dredgy 02521da9c3 Merge remote-tracking branch 'origin/main' into form-validation-improvements
# Conflicts:
#	resources/js/Components/FlightsGoneBy/MainHeader.vue
2026-08-15 21:05:07 +10:00
dredgy ed0ce97548 Formatting and Share Buttons 2026-08-15 21:02:02 +10:00
dredgy 1030a14094 Merge pull request 'Add London Airport and Million Mile Challenges' (#10) from add-london-airport-challenge into main
Reviewed-on: #10
2026-08-11 20:53:13 +10:00
dredgy 2b41c3ed44 Add London Airport and Million Mile Challenges 2026-08-11 20:52:21 +10:00
dredgy 65e8c7575e Allowed filters in the url 2026-08-11 20:04:11 +10:00
dredgy 9d175a1525 Allowed filters in the url 2026-08-11 16:11:31 +10:00
dredgy c602393c95 Adds a user search box 2026-08-09 20:08:49 +10:00
dredgy 8832028304 Following Select Box Shows only When User Is Following People 2026-07-22 21:46:02 +10:00
dredgy 7cecdb6bde Added engine count/type filters 2026-07-22 21:25:54 +10:00
dredgy 39c1f78b20 Compact map padding improved 2026-07-22 19:08:27 +10:00
dredgy 923d9a2c3e Added user select box to achievement pages 2026-07-21 21:03:46 +10:00
dredgy 3abd66001b Fixed Chart Axes 2026-07-20 20:34:43 +10:00
dredgy fe642300b6 Bug fix for individual alliance challenges 2026-07-20 15:59:15 +10:00
33 changed files with 1243 additions and 445 deletions
+20
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Aircraft; use App\Models\Aircraft;
use App\Models\Airline; use App\Models\Airline;
use App\Models\Airport; use App\Models\Airport;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class SearchController extends Controller class SearchController extends Controller
@@ -82,4 +83,23 @@ class SearchController extends Controller
]) ])
->values(); ->values();
} }
public function users()
{
$q = request('q', '');
if (strlen($q) < 2) return [];
return User::where('name', 'ilike', "%{$q}%")
->orderBy('name')
->limit(50)
->get(['id', 'name'])
->map(fn($user) => [
'value' => $user->id,
'title' => $user->name,
])
->values();
}
} }
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Achievement; use App\Models\Achievement;
use App\Models\Aircraft; use App\Models\Aircraft;
use App\Models\Airport;
use App\Models\Alliance; use App\Models\Alliance;
use App\Models\Continent; use App\Models\Continent;
use App\Models\Country; use App\Models\Country;
@@ -199,6 +200,11 @@ class UserProfileController extends Controller
default => [], default => [],
}; };
$londonAirports = match($achievement->internal_name){
'fun_challenges.all_london_airports' => Airport::LONDON_AIRPORTS,
default => [],
};
$canView = Gate::allows('viewProfileData', $user); $canView = Gate::allows('viewProfileData', $user);
return Inertia::render('Profile/UserAchievement', [ return Inertia::render('Profile/UserAchievement', [
@@ -212,6 +218,7 @@ class UserProfileController extends Controller
'airlines' => $airlines, 'airlines' => $airlines,
'continents' => $continents, 'continents' => $continents,
'aircraft_families' => $aircraftFamilies, 'aircraft_families' => $aircraftFamilies,
'london_airports' => $londonAirports,
'achievementCount' => $user->unlockedAchievements()->count(), 'achievementCount' => $user->unlockedAchievements()->count(),
'majorAlliances' => Alliance::whereIn('internal_name', $majorAlliances)->get(), 'majorAlliances' => Alliance::whereIn('internal_name', $majorAlliances)->get(),
'canView' => $canView, 'canView' => $canView,
@@ -38,6 +38,7 @@ class HandleInertiaRequests extends Middleware
'roles' => $request->user()?->getRoleNames() ?? [], 'roles' => $request->user()?->getRoleNames() ?? [],
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [], 'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
'apiToken' => session('api_token'), 'apiToken' => session('api_token'),
'following' => $request->user()?->following()->with('followee')->get()->pluck('followee')->filter()->pluck('name')->toArray() ?? [],
], ],
'flash' => [ 'flash' => [
'success' => $request->session()->get('success'), 'success' => $request->session()->get('success'),
+4
View File
@@ -33,6 +33,10 @@ class Airport extends Model
'display_name', 'display_name',
]; ];
const array LONDON_AIRPORTS = [
'LHR', 'LGW', 'STN', 'LTN', 'LCY', 'SEN',
];
protected function displayName() : Attribute{ protected function displayName() : Attribute{
return Attribute::make( return Attribute::make(
get: function () { get: function () {
@@ -12,6 +12,7 @@ use App\Services\Achievements\Checkers\AchievementCheckerInterface;
use App\Services\Achievements\Checkers\AircraftChecker; use App\Services\Achievements\Checkers\AircraftChecker;
use App\Services\Achievements\Checkers\CountriesAndContinentsChecker; use App\Services\Achievements\Checkers\CountriesAndContinentsChecker;
use App\Services\Achievements\Checkers\AirlinesAndAlliancesChecker; use App\Services\Achievements\Checkers\AirlinesAndAlliancesChecker;
use App\Services\Achievements\Checkers\DistanceChecker;
use App\Services\Achievements\Checkers\FunChallengesChecker; use App\Services\Achievements\Checkers\FunChallengesChecker;
use App\Services\Achievements\Checkers\GeneralFlyingChecker; use App\Services\Achievements\Checkers\GeneralFlyingChecker;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -30,6 +31,7 @@ class AchievementService
AircraftChecker::class, AircraftChecker::class,
AirlinesAndAlliancesChecker::class, AirlinesAndAlliancesChecker::class,
FunChallengesChecker::class, FunChallengesChecker::class,
DistanceChecker::class,
]; ];
public function __construct() public function __construct()
@@ -0,0 +1,26 @@
<?php
namespace App\Services\Achievements\Checkers;
use App\Models\User;
use App\Models\UserFlight;
use Illuminate\Database\Eloquent\Collection;
class DistanceChecker extends BaseChecker
{
public function check(): void
{
/**
* @var $flights Collection<int, UserFlight>
*/
$flights = $this->flights();
$totalDistance = $flights->sum('distance');
$this->awardProgress((int) $totalDistance, 'distance.circumference_of_the_earth');
$this->awardProgress((int) $totalDistance, 'distance.to_the_moon');
$this->awardProgress((int) $totalDistance, 'distance.gigametre');
$this->awardProgress((int) $totalDistance, 'distance.million_miles');
}
}
@@ -2,6 +2,7 @@
namespace App\Services\Achievements\Checkers; namespace App\Services\Achievements\Checkers;
use App\Models\Airport;
use App\Models\User; use App\Models\User;
use App\Models\UserFlight; use App\Models\UserFlight;
@@ -131,5 +132,19 @@ class FunChallengesChecker extends BaseChecker
->count(); ->count();
$this->awardProgress($visitedCanadianProvinces, 'fun_challenges.canadian_provinces'); $this->awardProgress($visitedCanadianProvinces, 'fun_challenges.canadian_provinces');
// --- London Airports ---
$visitedLondonAirports = $flights
->flatMap(fn(UserFlight $f) => [
$f->departureAirport?->iata_code,
$f->arrivalAirport?->iata_code,
])
->filter()
->map(fn($code) => strtoupper($code))
->filter(fn($code) => in_array($code, Airport::LONDON_AIRPORTS))
->unique()
->count();
$this->awardProgress($visitedLondonAirports, 'fun_challenges.all_london_airports');
} }
} }
@@ -68,14 +68,6 @@ class GeneralFlyingChecker extends BaseChecker
'general_flying.domestic_two_countries' 'general_flying.domestic_two_countries'
); );
// --- 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((int) $totalDistance, 'general_flying.gigametre');
$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,87 @@
<?php
use App\Models\Achievement;
use App\Models\AchievementCategory;
use App\Models\AchievementDifficulty;
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
{
$hard = AchievementDifficulty::where('internal_name', 'hard')->first();
$near_impossible = AchievementDifficulty::where('internal_name', 'near_impossible')->first();
$fun_challenges = AchievementCategory::where('internal_name', 'fun_challenges')->first();
Achievement::create([
'name' => 'London Calling',
'internal_name' => 'fun_challenges.all_london_airports',
'short_description' => 'Fly to all official London airports',
'progressive' => true,
'difficulty_description' => 'Much more achievable if you live in London!',
'threshold' => 6,
'achievement_category_id' => $fun_challenges->id,
'achievement_difficulty_id' => $hard->id,
'has_page' => true,
'sort_order' => 10,
'icon' => 'standard_achievement.png',
'long_description' => '',
]);
$distance = AchievementCategory::create([
'name' => 'Distance',
'internal_name' => 'distance',
'description' => 'Achievements that are based on distance'
]);
Achievement::create([
'name' => 'Million Miler',
'internal_name' => 'distance.million_miles',
'short_description' => 'Fly one million miles',
'progressive' => true,
'threshold' => 1609344,
'achievement_category_id' => $distance->id,
'achievement_difficulty_id' => $near_impossible->id,
'has_page' => false,
'sort_order' => 4,
'icon' => 'standard_achievement.png',
'long_description' => '',
]);
Achievement::whereInternalName('general_flying.gigametre')->update([
'internal_name' => 'distance.gigametre',
'achievement_category_id' => $distance->id,
'sort_order' => 3
]);
Achievement::whereInternalName('general_flying.circumference_of_the_earth')->update([
'internal_name' => 'distance.circumference_of_the_earth',
'achievement_category_id' => $distance->id,
'sort_order' => 1
]);
Achievement::whereInternalName('general_flying.to_the_moon')->update([
'internal_name' => 'distance.to_the_moon',
'achievement_category_id' => $distance->id,
'sort_order' => 2
]);
foreach(User::all() as $user){
$user->calculateAchievements();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
@@ -1,115 +1,125 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { Achievement, Alliance, Flight, User } from '@/Types/types' import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue' import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue' import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue' import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.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 AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue' import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
defineOptions({ inheritAttrs: false })
interface AirlineEntry {
airline: Airline
flights: Flight[]
}
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
followStatus: string followStatus: string
majorAlliances: Alliance[] alliance: Alliance
airlines: Airline[]
flights: Flight[] flights: Flight[]
}>() }>()
interface AllianceEntry { const flightsByAirline = computed(() => {
alliance: Alliance const map = new Map<string, AirlineEntry>()
flights: Flight[] const idToKey = new Map<number, string>()
}
const alliances = computed<AllianceEntry[]>(() => { for (const airline of props.airlines) {
const entries = props.majorAlliances.map(alliance => ({ alliance, flights: [] as Flight[] })) const key = rowKey(airline)
const byName = new Map(entries.map(e => [e.alliance.internal_name, e])) map.set(key, { airline, flights: [] })
idToKey.set(airline.id, key)
for (const flight of props.flights) { for (const alias of airline.merged_from ?? []) {
const key = flight.airline?.alliance?.internal_name idToKey.set(alias.id, key)
if (!key) continue }
byName.get(key)?.flights.push(flight)
} }
return entries for (const flight of props.flights) {
const airline = flight.airline
if (!airline) continue
const key = idToKey.get(airline.id)
if (!key) continue
map.get(key)?.flights.push(flight)
}
return map
}) })
const completedCount = computed( function rowKey(airline: Airline): string {
() => alliances.value.filter(a => a.flights.length > 0).length return airline.iata_code ?? airline.internal_name
)
function flightTitle(flight: Flight): string {
const name = flight.airline?.name ?? 'Unknown airline'
return `${name}: ${flight.departure_airport?.iata_code}${flight.arrival_airport?.iata_code}`
} }
const rows = computed(() => [...flightsByAirline.value.keys()])
function entryFor(key: string): AirlineEntry {
return flightsByAirline.value.get(key)!
}
</script> </script>
<template> <template>
<!-- Header --> <!-- Header -->
<Panel> <Panel>
<div class="alliance-header"> <div class="alliance-header">
<div class="logo-row"> <AllianceLogo :alliance="alliance" size="56" />
<AllianceLogo
v-for="entry in alliances"
:key="entry.alliance.internal_name"
:alliance="entry.alliance"
size="56"
/>
</div>
<div> <div>
<PanelHeader centered>Major Alliances</PanelHeader> <PanelHeader centered>{{ alliance.name }}</PanelHeader>
<PanelSubHeader centered> <PanelSubHeader centered>
{{ completedCount }} / {{ alliances.length }} alliances flown <slot />
</PanelSubHeader> </PanelSubHeader>
</div> </div>
</div> </div>
</Panel> </Panel>
<!-- Per-alliance breakdown -->
<Panel v-for="entry in alliances" :key="entry.alliance.internal_name"> <!-- Airlines table -->
<Panel>
<div class="table-toolbar"> <div class="table-toolbar">
<PanelHeader>{{ entry.alliance.name }}</PanelHeader> <PanelHeader>Airlines</PanelHeader>
</div> </div>
<BadgeTable <BadgeTable
:rows="[entry.alliance.internal_name]" :rows="rows"
:rowKey="key => key" :rowKey="key => key"
:hasItems="() => entry.flights.length > 0" :hasItems="key => entryFor(key).flights.length > 0"
labelWidth="14em" labelWidth="14em"
> >
<template #label> <template #label="{ row: key }">
<div class="airline-label"> <div class="airline-label" >
<AllianceLogo :alliance="entry.alliance" size="24" /> <AirlineLogo :airline="entryFor(key).airline" size="24" />
<span>{{ entry.alliance.name }}</span> <span>{{ entryFor(key).airline.name }}</span>
</div> </div>
</template> </template>
<template #items> <template #items="{ row: key }">
<FlightBadge <FlightBadge
v-for="flight in entry.flights" v-for="flight in entryFor(key).flights"
:key="flight.id" :key="flight.id"
:title="flightTitle(flight)" :title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
:flight="flight" :flight="flight" />
/>
</template> </template>
</BadgeTable> </BadgeTable>
</Panel> </Panel>
<!-- Slot for alliance-specific panels -->
<slot name="extra" />
<!-- Requirements --> <!-- Requirements -->
<Panel> <Panel>
<PanelHeader centered>Requirements</PanelHeader> <PanelHeader centered>Requirements</PanelHeader>
<p> <p>
To complete this challenge you must fly with at least one member airline from each of To complete this challenge you must fly with every current member airline of
the three major alliances. Unlike the individual alliance challenges, you don't need <strong>{{ alliance.name }}</strong>. Alliance membership changes over time, so the
to fly every airline in an alliance just one flight per alliance is enough. required airlines reflect the current roster.
</p> </p>
<p> <p>
Codeshare flights do not count, the operating carrier must be a member of the Codeshare flights do not count, the operating carrier must be a member of {{alliance.name}}.
alliance.
</p> </p>
</Panel> </Panel>
</template> </template>
@@ -123,10 +133,6 @@ function flightTitle(flight: Flight): string {
flex-wrap: wrap; flex-wrap: wrap;
} }
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar { .table-toolbar {
display: flex; display: flex;
@@ -136,6 +142,7 @@ function flightTitle(flight: Flight): string {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.airline-label { .airline-label {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -143,4 +150,5 @@ function flightTitle(flight: Flight): string {
gap: 0.5rem; gap: 0.5rem;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
</style> </style>
@@ -250,7 +250,7 @@ const legendItems = computed(() => {
<div v-if="mode === 'all'" class="ring-labels"> <div v-if="mode === 'all'" class="ring-labels">
<span v-for="(r, i) in normalizedRings" :key="r.name"> <span v-for="(r, i) in normalizedRings" :key="r.name">
&#9899; {{ i === 0 ? 'Outer' : i === normalizedRings.length - 1 ? 'Inner' : `Ring ${i + 1}` }} {{ i === 0 ? 'Outer' : i === normalizedRings.length - 1 ? 'Inner' : `Ring ${i + 1}` }}
Ring = {{ r.name }} Ring = {{ r.name }}
</span> </span>
</div> </div>
@@ -7,10 +7,9 @@ const props = defineProps<{
</script> </script>
<template> <template>
<DonutChart <DonutChart
title="Flight Scopes" title="Flight Scope"
:height="280" :height="280"
center-mode="count" total-label="Flights"
total-label="Flight Scopes"
:labels="flightStats.flightTypes.value.labels" :labels="flightStats.flightTypes.value.labels"
:series="flightStats.flightTypes.value.series" :series="flightStats.flightTypes.value.series"
/> />
@@ -0,0 +1,55 @@
<template>
<v-btn
:color="copied ? 'success' : color"
:variant="variant"
:icon="iconOnly"
:size="iconOnly ? 'x-small' : 'default'"
@click="handleCopy"
>
<v-icon :start="!iconOnly" :icon="copied ? 'mdi-check' : 'mdi-content-copy'" />
<template v-if="!iconOnly">{{ copied ? 'Copied Link To Page!' : label }}</template>
<v-tooltip v-if="iconOnly" activator="parent" location="top">
{{ copied ? 'Copied Link To Page!' : label }}
</v-tooltip>
</v-btn>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useDisplay } from 'vuetify';
import { copyToClipboard } from '@/Composables/useClipboard';
interface Props {
label?: string;
color?: string;
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain';
resetDelay?: number;
}
const props = withDefaults(defineProps<Props>(), {
label: 'Copy link',
color: 'primary',
variant: 'outlined',
resetDelay: 2000,
});
const { mobile } = useDisplay();
const iconOnly = mobile;
const copied = ref(false);
let resetTimeout: ReturnType<typeof setTimeout> | null = null;
async function handleCopy() {
try {
await copyToClipboard(window.location.href);
copied.value = true;
if (resetTimeout) clearTimeout(resetTimeout);
resetTimeout = setTimeout(() => {
copied.value = false;
}, props.resetDelay);
} catch (err) {
console.error('Failed to copy URL:', err);
}
}
</script>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Airline, Alliance, Flight, RegionRange, FlightRange, FlightScope } from '@/Types/types' import type { Airline, Alliance, Flight, RegionRange, FlightRange, FlightScope } from '@/Types/types'
import { ref, watch } from 'vue' import { reactive, ref, watch, onMounted, nextTick } from 'vue'
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue" import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"
const props = defineProps<{ const props = defineProps<{
@@ -23,11 +23,63 @@ const emit = defineEmits<{
seatTypes: number[] seatTypes: number[]
manufacturers: string[] manufacturers: string[]
aircraftModels: number[] aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[] airportRegions: number[]
otherUsers: string[] otherUsers: string[]
}] }]
}>() }>()
// ── Filter registry ──────────────────────────────────────────────────────────
//
// This is the single source of truth for every filter's identity. To add a
// new filter:
// 1. Add one entry here (name = the key used in `filters` + the URL param,
// emitKey = the key expected by the `change` event, kind = how to parse
// values coming back out of the URL).
// 2. Add a <v-select> in the template with :name="'yourName'" and
// v-model="filters.yourName" (initialise it in `filters` below, or just
// let step 1 do it for you since `filters` is derived from this list).
//
// Nothing else needs to know about the new filter — URL loading, URL syncing,
// and the emitted payload are all generic over this list.
type FilterKind = 'number' | 'string'
interface FilterDef {
name: string // key in `filters`, and the URL query param name
emitKey: string // key in the payload sent via the `change` event
kind: FilterKind // how to coerce values parsed back out of the URL
}
const FILTER_DEFS: FilterDef[] = [
{ name: 'year', emitKey: 'years', kind: 'number' },
{ name: 'airline', emitKey: 'airlines', kind: 'number' },
{ name: 'alliance', emitKey: 'alliances', kind: 'number' },
{ name: 'country', emitKey: 'countries', kind: 'string' },
{ name: 'continent', emitKey: 'continents', kind: 'string' },
{ name: 'flightClass', emitKey: 'flightClasses', kind: 'number' },
{ name: 'crewType', emitKey: 'crewTypes', kind: 'number' },
{ name: 'flightScope', emitKey: 'flightScopes', kind: 'string' },
{ name: 'flightRange', emitKey: 'flightRanges', kind: 'string' },
{ name: 'regionRange', emitKey: 'regionRanges', kind: 'string' },
{ name: 'flightReason', emitKey: 'flightReasons', kind: 'number' },
{ name: 'seatType', emitKey: 'seatTypes', kind: 'number' },
{ name: 'manufacturer', emitKey: 'manufacturers', kind: 'string' },
{ name: 'aircraftModel', emitKey: 'aircraftModels', kind: 'number' },
{ name: 'engineType', emitKey: 'engineTypes', kind: 'string' },
{ name: 'engineCount', emitKey: 'engineCounts', kind: 'number' },
{ name: 'airportRegion', emitKey: 'airportRegions', kind: 'number' },
{ name: 'otherUsers', emitKey: 'otherUsers', kind: 'string' },
]
// Single reactive bag of filter state, one array per FILTER_DEFS entry.
// `filters.year`, `filters.airline`, etc. This is what every v-select
// v-models against.
const filters = reactive<Record<string, any[]>>(
Object.fromEntries(FILTER_DEFS.map(d => [d.name, []])),
)
// ── Available options ───────────────────────────────────────────────────────── // ── Available options ─────────────────────────────────────────────────────────
type AirlineData = { id: number; name: string; airline: Airline } type AirlineData = { id: number; name: string; airline: Airline }
@@ -62,6 +114,8 @@ function buildOptions(flights: Flight[]) {
const regionRanges = new Set<RegionRange>() const regionRanges = new Set<RegionRange>()
const manufacturers = new Map<string, { name: string }>() const manufacturers = new Map<string, { name: string }>()
const aircraftModels = new Map<number, { id: number; name: string }>() const aircraftModels = new Map<number, { id: number; name: string }>()
const engineTypes = new Map<string, { name: string }>()
const engineCounts = new Map<number, { count: number }>()
const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>() const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>()
const otherUsers = new Set<string>() const otherUsers = new Set<string>()
@@ -120,6 +174,12 @@ function buildOptions(flights: Flight[]) {
if (f.aircraft?.id != null && f.aircraft?.display_name_short) if (f.aircraft?.id != null && f.aircraft?.display_name_short)
aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short }) aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short })
if (f.aircraft?.engine_type)
engineTypes.set(f.aircraft.engine_type, { name: f.aircraft.engine_type })
if (f.aircraft?.engine_count != null)
engineCounts.set(f.aircraft.engine_count, { count: f.aircraft.engine_count })
flightScopes.add(f.scope) flightScopes.add(f.scope)
flightRanges.add(f.range) flightRanges.add(f.range)
regionRanges.add(f.region_range) regionRanges.add(f.region_range)
@@ -144,6 +204,8 @@ function buildOptions(flights: Flight[]) {
regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })), regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })),
manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)), manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)),
aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)), aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)),
engineTypes: [...engineTypes.values()].sort((a, b) => a.name.localeCompare(b.name)),
engineCounts: [...engineCounts.values()].sort((a, b) => a.count - b.count),
airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)), airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)),
otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)), otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)),
} }
@@ -151,57 +213,64 @@ function buildOptions(flights: Flight[]) {
const availableOptions = buildOptions(props.flights) const availableOptions = buildOptions(props.flights)
// ── Filter state ────────────────────────────────────────────────────────────── // ── URL query sync ──────────────────────────────────────────────────────────
const selectedYears = ref<number[]>([]) function loadFiltersFromQuery(): boolean {
const selectedAirlines = ref<number[]>([]) const params = new URLSearchParams(window.location.search)
const selectedAlliances = ref<number[]>([]) let foundAny = false
const selectedCountries = ref<string[]>([]) for (const def of FILTER_DEFS) {
const selectedContinents = ref<string[]>([]) const raw = params.get(def.name)
const selectedFlightClasses = ref<number[]>([]) if (!raw) continue
const selectedCrewTypes = ref<number[]>([]) const values = raw.split(',').filter(Boolean)
const selectedFlightScopes = ref<FlightScope[]>([]) filters[def.name] = def.kind === 'number'
const selectedFlightRanges = ref<FlightRange[]>([]) ? values.map(Number).filter(n => !Number.isNaN(n))
const selectedRegionRanges = ref<RegionRange[]>([]) : values
const selectedFlightReasons = ref<number[]>([]) foundAny = true
const selectedSeatTypes = ref<number[]>([]) }
const selectedManufacturers = ref<string[]>([]) return foundAny
const selectedAircraftModels = ref<number[]>([]) }
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
// When selected countries change, drop any selected regions that no longer function syncUrlWithFilters() {
// belong to any of the selected countries. const params = new URLSearchParams()
watch(selectedCountries, (countries) => { for (const def of FILTER_DEFS) {
if (countries.length === 0) return const value = filters[def.name]
selectedAirportRegions.value = selectedAirportRegions.value.filter(regionId => { if (value.length > 0)
const region = availableOptions.airportRegions.find(r => r.id === regionId) params.set(def.name, value.join(','))
if (!region) return false }
return [...(region as any).countryCodes].some((code: string) => countries.includes(code)) const query = params.toString()
}) const newUrl = `${window.location.pathname}${query ? `?${query}` : ''}`
}) window.history.replaceState(window.history.state, '', newUrl)
}
// ── Emit ──────────────────────────────────────────────────────────────────────
function emitFilters() { function emitFilters() {
emit('change', { syncUrlWithFilters()
years: selectedYears.value, const payload = Object.fromEntries(
airlines: selectedAirlines.value, FILTER_DEFS.map(def => [def.emitKey, filters[def.name]]),
alliances: selectedAlliances.value, )
countries: selectedCountries.value, emit('change', payload as Parameters<typeof emit>[1] extends [infer T] ? T : never)
continents: selectedContinents.value,
flightClasses: selectedFlightClasses.value,
crewTypes: selectedCrewTypes.value,
flightScopes: selectedFlightScopes.value,
flightRanges: selectedFlightRanges.value,
regionRanges: selectedRegionRanges.value,
flightReasons: selectedFlightReasons.value,
seatTypes: selectedSeatTypes.value,
manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value,
airportRegions: selectedAirportRegions.value,
otherUsers: selectedOtherUsers.value,
})
} }
onMounted(async () => {
const hasQueryFilters = loadFiltersFromQuery()
// Only emit if the URL actually seeded some filters - a plain page load
// (no query params) should behave exactly like before and not emit at all,
// since the parent already renders its own initial, unfiltered state.
if (!hasQueryFilters) return
// Wait a beat before emitting. Charts further down the tree (ApexCharts,
// in particular) initialize asynchronously on their own mount; firing our
// update in the same synchronous pass can hand them new series data before
// their internal chart instance exists yet, which throws inside the
// charting library. A couple of ticks is enough to let that settle.
await nextTick()
await nextTick()
emitFilters()
})
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const countryFlagClass = (code: string) => const countryFlagClass = (code: string) =>
@@ -209,326 +278,384 @@ const countryFlagClass = (code: string) =>
</script> </script>
<template> <template>
<div class="flight-filters"> <div class="flight-filters-wrapper">
<div class="flight-filters">
<v-select <v-select
v-model="selectedYears" name="year"
:items="availableOptions.years" v-model="filters.year"
label="Year" :items="availableOptions.years"
multiple clearable hide-details label="Year"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ item }}<span v-if="index < Math.min(selectedYears.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ item }}<span v-if="index < Math.min(filters.year.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedYears.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.year.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-model="selectedAirlines" name="airline"
:items="availableOptions.airlines" v-model="filters.airline"
item-title="name" item-value="id" :items="availableOptions.airlines"
label="Airline" item-title="name" item-value="id"
multiple clearable hide-details label="Airline"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #item="{ item, props: itemProps }"> >
<v-list-item v-bind="itemProps"> <template #item="{ item, props: itemProps }">
<template #prepend="{ isSelected }"> <v-list-item v-bind="itemProps">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" /> <template #prepend="{ isSelected }">
</template> <v-checkbox-btn :model-value="isSelected" tabindex="-1" />
<template #title> </template>
<img <template #title>
:src="(item as AirlineData).airline.logo_url" <img
width="32" height="32" :src="(item as AirlineData).airline.logo_url"
style="object-fit: contain; margin-right: 8px; vertical-align: middle;" width="32" height="32"
alt="" style="object-fit: contain; margin-right: 8px; vertical-align: middle;"
/> alt=""
{{ (item as any).name }} />
</template> {{ (item as any).name }}
</v-list-item> </template>
</template> </v-list-item>
<template #selection="{ item, index }"> </template>
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirlines.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.airline.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirlines.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.airline.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.alliances.length > 0" v-if="availableOptions.alliances.length > 0"
v-model="selectedAlliances" name="alliance"
:items="availableOptions.alliances" v-model="filters.alliance"
item-title="name" item-value="id" :items="availableOptions.alliances"
label="Alliance" item-title="name" item-value="id"
multiple clearable hide-details label="Alliance"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #item="{ item, props: itemProps }"> >
<v-list-item v-bind="itemProps"> <template #item="{ item, props: itemProps }">
<template #prepend="{ isSelected }"> <v-list-item v-bind="itemProps">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" /> <template #prepend="{ isSelected }">
</template> <v-checkbox-btn :model-value="isSelected" tabindex="-1" />
<template #title> </template>
<AllianceLogo :size="22" :alliance="(item as any).alliance" style="margin-right: 8px;" /> <template #title>
{{ (item as any).name }} <AllianceLogo :size="22" :alliance="(item as any).alliance" style="margin-right: 8px;" />
</template> {{ (item as any).name }}
</v-list-item> </template>
</template> </v-list-item>
<template #selection="{ item, index }"> </template>
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAlliances.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.alliance.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAlliances.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.alliance.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-model="selectedCountries" name="country"
:items="availableOptions.countries" v-model="filters.country"
item-title="name" item-value="code" :items="availableOptions.countries"
label="Country" item-title="name" item-value="code"
multiple clearable hide-details label="Country"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #item="{ item, props: itemProps }"> >
<v-list-item v-bind="itemProps"> <template #item="{ item, props: itemProps }">
<template #prepend="{ isSelected }"> <v-list-item v-bind="itemProps">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" /> <template #prepend="{ isSelected }">
</template> <v-checkbox-btn :model-value="isSelected" tabindex="-1" />
<template #title> </template>
<span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" /> <template #title>
{{ (item as any).name }} <span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" />
</template> {{ (item as any).name }}
</v-list-item> </template>
</template> </v-list-item>
<template #selection="{ item, index }"> </template>
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
<span :class="countryFlagClass((item as any).code)" style="margin-right: 4px;" /> <span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedCountries.length, 2) - 1">,&nbsp;</span> <span :class="countryFlagClass((item as any).code)" style="margin-right: 4px;" />
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.country.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCountries.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.country.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.flightScopes.length > 1" v-if="availableOptions.flightScopes.length > 1"
v-model="selectedFlightScopes" name="flightScope"
:items="availableOptions.flightScopes" v-model="filters.flightScope"
item-title="label" item-value="value" :items="availableOptions.flightScopes"
label="Flight Scope" item-title="label" item-value="value"
multiple clearable hide-details label="Flight Scope"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightScopes.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).label }}<span v-if="index < Math.min(filters.flightScope.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightScopes.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightScope.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.airportRegions.length > 0" v-if="availableOptions.airportRegions.length > 0"
v-model="selectedAirportRegions" name="airportRegion"
:items="availableOptions.airportRegions" v-model="filters.airportRegion"
item-title="name" item-value="id" :items="availableOptions.airportRegions"
label="Region" item-title="name" item-value="id"
multiple clearable hide-details label="Region"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirportRegions.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.airportRegion.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirportRegions.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.airportRegion.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.regionRanges.length > 1" v-if="availableOptions.regionRanges.length > 1"
v-model="selectedRegionRanges" name="regionRange"
:items="availableOptions.regionRanges" v-model="filters.regionRange"
item-title="label" item-value="value" :items="availableOptions.regionRanges"
label="Region Range" item-title="label" item-value="value"
multiple clearable hide-details label="Region Range"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).label }}<span v-if="index < Math.min(selectedRegionRanges.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).label }}<span v-if="index < Math.min(filters.regionRange.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedRegionRanges.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.regionRange.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-model="selectedContinents" name="continent"
:items="availableOptions.continents" v-model="filters.continent"
item-title="name" item-value="code" :items="availableOptions.continents"
label="Continent" item-title="name" item-value="code"
multiple clearable hide-details label="Continent"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedContinents.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.continent.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedContinents.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.continent.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.flightRanges.length > 1" v-if="availableOptions.flightRanges.length > 1"
v-model="selectedFlightRanges" name="flightRange"
:items="availableOptions.flightRanges" v-model="filters.flightRange"
item-title="label" item-value="value" :items="availableOptions.flightRanges"
label="Flight Range" item-title="label" item-value="value"
multiple clearable hide-details label="Flight Range"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightRanges.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).label }}<span v-if="index < Math.min(filters.flightRange.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightRanges.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightRange.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-model="selectedFlightClasses" name="flightClass"
:items="availableOptions.classes" v-model="filters.flightClass"
item-title="name" item-value="id" :items="availableOptions.classes"
label="Flight Class" item-title="name" item-value="id"
multiple clearable hide-details label="Flight Class"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightClasses.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.flightClass.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightClass.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.flightReasons.length > 0" v-if="availableOptions.flightReasons.length > 0"
v-model="selectedFlightReasons" name="flightReason"
:items="availableOptions.flightReasons" v-model="filters.flightReason"
item-title="name" item-value="id" :items="availableOptions.flightReasons"
label="Flight Reason" item-title="name" item-value="id"
multiple clearable hide-details label="Flight Reason"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightReasons.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.flightReason.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightReasons.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightReason.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.seatTypes.length > 0" v-if="availableOptions.seatTypes.length > 0"
v-model="selectedSeatTypes" name="seatType"
:items="availableOptions.seatTypes" v-model="filters.seatType"
item-title="name" item-value="id" :items="availableOptions.seatTypes"
label="Seat Type" item-title="name" item-value="id"
multiple clearable hide-details label="Seat Type"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedSeatTypes.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.seatType.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedSeatTypes.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.seatType.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.manufacturers.length > 0" v-if="availableOptions.manufacturers.length > 0"
v-model="selectedManufacturers" name="manufacturer"
:items="availableOptions.manufacturers" v-model="filters.manufacturer"
item-title="name" item-value="name" :items="availableOptions.manufacturers"
label="Aircraft Manufacturer" item-title="name" item-value="name"
multiple clearable hide-details label="Aircraft Manufacturer"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedManufacturers.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.manufacturer.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedManufacturers.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.manufacturer.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.aircraftModels.length > 0" v-if="availableOptions.aircraftModels.length > 0"
v-model="selectedAircraftModels" name="aircraftModel"
:items="availableOptions.aircraftModels" v-model="filters.aircraftModel"
item-title="name" item-value="id" :items="availableOptions.aircraftModels"
label="Aircraft Model" item-title="name" item-value="id"
multiple clearable hide-details label="Aircraft Model"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAircraftModels.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.aircraftModel.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAircraftModels.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.aircraftModel.length - 2 }}</span>
</v-select> </template>
</v-select>
<v-select <v-select
v-if="availableOptions.crewTypes.length > 0" v-if="availableOptions.engineTypes.length > 0"
v-model="selectedCrewTypes" name="engineType"
:items="availableOptions.crewTypes" v-model="filters.engineType"
item-title="name" item-value="id" :items="availableOptions.engineTypes"
label="Crew Type" item-title="name" item-value="name"
multiple clearable hide-details label="Engine Type"
density="compact" variant="outlined" multiple clearable hide-details
@update:model-value="emitFilters" density="compact" variant="outlined"
> @update:model-value="emitFilters"
<template #selection="{ item, index }"> >
<span v-if="index < 2" class="v-select__selection-text"> <template #selection="{ item, index }">
{{ (item as any).name }}<span v-if="index < Math.min(selectedCrewTypes.length, 2) - 1">,&nbsp;</span> <span v-if="index < 2" class="v-select__selection-text">
</span> {{ (item as any).name }}<span v-if="index < Math.min(filters.engineType.length, 2) - 1">,&nbsp;</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCrewTypes.length - 2 }}</span> </span>
</template> <span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.engineType.length - 2 }}</span>
</v-select> </template>
<v-select </v-select>
v-if="availableOptions.otherUsers.length > 0"
v-model="selectedOtherUsers" <v-select
:items="availableOptions.otherUsers" v-if="availableOptions.engineCounts.length > 0"
label="Flew With" name="engineCount"
multiple clearable hide-details v-model="filters.engineCount"
density="compact" variant="outlined" :items="availableOptions.engineCounts"
@update:model-value="emitFilters" item-title="count" item-value="count"
> label="Engine Count"
<template #selection="{ item, index }"> multiple clearable hide-details
<span v-if="index < 2" class="v-select__selection-text"> density="compact" variant="outlined"
{{ item }}<span v-if="index < Math.min(selectedOtherUsers.length, 2) - 1">,&nbsp;</span> @update:model-value="emitFilters"
</span> >
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedOtherUsers.length - 2 }}</span> <template #selection="{ item, index }">
</template> <span v-if="index < 2" class="v-select__selection-text">
</v-select> {{ (item as any).count }}<span v-if="index < Math.min(filters.engineCount.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.engineCount.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.crewTypes.length > 0"
name="crewType"
v-model="filters.crewType"
:items="availableOptions.crewTypes"
item-title="name" item-value="id"
label="Crew Type"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(filters.crewType.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.crewType.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.otherUsers.length > 0"
name="otherUsers"
v-model="filters.otherUsers"
:items="availableOptions.otherUsers"
label="Flew With"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ item }}<span v-if="index < Math.min(filters.otherUsers.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.otherUsers.length - 2 }}</span>
</template>
</v-select>
</div>
</div> </div>
</template> </template>
@@ -543,4 +670,8 @@ const countryFlagClass = (code: string) =>
.flight-filters .v-select { .flight-filters .v-select {
flex: 1 1 160px; flex: 1 1 160px;
} }
.flight-filters-actions {
margin-top: 12px;
}
</style> </style>
@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div class="flight-map-wrapper"> <div class="flight-map-wrapper" :class="{ tooltip }">
<PlaneLoader v-if="!mapReady" class="map-loader" /> <PlaneLoader v-if="!mapReady" class="map-loader" />
<div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" /> <div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" />
<button @click="exportMapBasic()" class="export-btn" title="Download as Image"> <button @click="exportMapBasic()" class="export-btn" title="Download as Image">
@@ -277,8 +277,11 @@ export default defineComponent({
compact: { compact: {
type: Boolean, type: Boolean,
default: false, default: false,
},
tooltip: {
type: Boolean,
default: false,
} }
}, },
setup(props) { setup(props) {
@@ -659,7 +662,9 @@ export default defineComponent({
const fitBounds = (): void => { const fitBounds = (): void => {
if (!props.flights.length) return if (!props.flights.length) return
const padding = props.compact ? 50 : 60 const padding = props.compact
? { top: 20, bottom: 20, left: 55, right: 55 }
: { top: 60, bottom: 60, left: 60, right: 60 }
const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg]) const lngs = props.flights.flatMap(f => [f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg])
const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg]) const lats = props.flights.flatMap(f => [f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg])
@@ -819,6 +824,11 @@ export default defineComponent({
aspect-ratio: 7 / 10; aspect-ratio: 7 / 10;
max-height: none; max-height: none;
} }
.flight-map-wrapper.tooltip {
aspect-ratio: 16 / 10;
max-height: 30vh;
}
} }
small.attribution{ small.attribution{
@@ -40,7 +40,7 @@ defineProps<{
<ToolTipRows v-if="isActive.value"> <ToolTipRows v-if="isActive.value">
<FlightMap :flights="[flight]" :showLegend="false" compact /> <FlightMap :flights="[flight]" :showLegend="false" compact tooltip />
</ToolTipRows> </ToolTipRows>
<ToolTipDivider /> <ToolTipDivider />
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { usePage, router } from "@inertiajs/vue3";
import { SharedProps } from "@/Types/types";
const props = withDefaults(
defineProps<{
redirectRoute: string;
routeParams?: Record<string, string | number>;
placeholder?: string;
}>(),
{
routeParams: () => ({}),
}
);
const page = usePage<SharedProps>().props;
const following = [...page.auth?.following ?? [], page.auth?.user?.name]
.filter((x, i, a) => a.indexOf(x) === i);
function onUpdate(user: string) {
if (!user) return;
router.get(
route(props.redirectRoute, {
user,
...props.routeParams,
})
);
}
</script>
<template>
<VSelect
v-if="following.length > 1"
:placeholder="placeholder"
variant="outlined"
density="comfortable"
:items="following"
@update:model-value="onUpdate"
>
</VSelect>
</template>
<style scoped>
</style>
@@ -4,6 +4,7 @@ defineProps<{
rowKey: (row: T) => string | number rowKey: (row: T) => string | number
hasItems: (row: T) => boolean hasItems: (row: T) => boolean
labelWidth?: string labelWidth?: string
compactOnMobile?: boolean
}>() }>()
</script> </script>
@@ -15,7 +16,11 @@ defineProps<{
class="badge-row" class="badge-row"
> >
<!-- Label column: consumer provides content --> <!-- Label column: consumer provides content -->
<div class="badge-label" :style="labelWidth ? `width: ${labelWidth}` : ''"> <div
class="badge-label"
:class="{ 'badge-label--compact': compactOnMobile }"
:style="labelWidth ? `width: ${labelWidth}` : ''"
>
<slot name="label" :row="row" /> <slot name="label" :row="row" />
</div> </div>
@@ -38,8 +43,6 @@ defineProps<{
container-type: inline-size; container-type: inline-size;
} }
.badge-row { .badge-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -49,13 +52,13 @@ defineProps<{
.badge-label { .badge-label {
flex: 0 0 auto; flex: 0 0 auto;
width: 15em; width: 21em;
padding: 0.5em; padding: 0.5em;
border-right: 1px solid var(--table-border); border-right: 1px solid var(--table-border);
align-self: stretch; align-self: stretch;
display: flex; display: flex;
align-items: center; align-items: center;
font-size:0.9rem; font-size: 0.9rem;
} }
@container (max-width: 480px) { @container (max-width: 480px) {
@@ -63,6 +66,12 @@ defineProps<{
font-size: 0.75rem; font-size: 0.75rem;
width: 9em; width: 9em;
} }
.badge-label--compact {
font-size: 0.7rem;
width: clamp(6em, 50cqw, 9em);
flex-shrink: 0;
}
} }
.badge-scroll-container { .badge-scroll-container {
@@ -25,5 +25,4 @@ defineProps<{
background-size: cover; background-size: cover;
min-width: 300px; min-width: 300px;
} }
</style> </style>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from "@inertiajs/vue3" import { Link, usePage } from "@inertiajs/vue3"
import { SharedProps } from "@/Types/types" import { SharedProps } from "@/Types/types"
import CopyPageButton from "@/Components/FlightsGoneBy/CopyPageButton.vue";
const page = usePage<SharedProps>().props const page = usePage<SharedProps>().props
const year = new Date().getFullYear() const year = new Date().getFullYear()
@@ -63,9 +64,17 @@ footer {
color: #ffc107; color: #ffc107;
} }
.footer-version {
opacity: 0.8;
margin-left: 0.4em;
}
@media (max-width: 600px) { @media (max-width: 600px) {
footer { footer {
padding: 0 1rem; flex-wrap: wrap;
flex: 0 0 auto;
row-gap: 0.5rem;
padding: 0.6rem 1rem;
} }
.footer-copyright { .footer-copyright {
@@ -75,10 +84,6 @@ footer {
.footer-link { .footer-link {
font-size: 0.6rem; font-size: 0.6rem;
} }
}
.footer-version {
opacity: 0.8;
margin-left: 0.4em;
} }
</style> </style>
@@ -5,6 +5,7 @@ import type { SharedProps } from '@/Types/types'
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import NotificationMenu from "@/Components/FlightsGoneBy/NotificationMenu.vue"; import NotificationMenu from "@/Components/FlightsGoneBy/NotificationMenu.vue";
import Logo from "@/Components/FlightsGoneBy/Logo.vue"; import Logo from "@/Components/FlightsGoneBy/Logo.vue";
import UserSearchBox from "@/Components/UserSearchBox.vue";
const page = usePage<SharedProps>() const page = usePage<SharedProps>()
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -30,6 +31,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<Logo variant="color" /> <Logo variant="color" />
</Link> </Link>
<UserSearchBox class="nav-desktop" density="compact" style="max-width:400px; width: 400px;" />
<NotificationMenu v-if="page.props.auth?.user" :unread-count="page.props.unread_notification_count" /> <NotificationMenu v-if="page.props.auth?.user" :unread-count="page.props.unread_notification_count" />
<!-- Desktop nav --> <!-- Desktop nav -->
@@ -369,5 +371,6 @@ header {
@media (max-width: 600px) { @media (max-width: 600px) {
.nav-desktop { display: none; } .nav-desktop { display: none; }
.hamburger { display: flex; } .hamburger { display: flex; }
.user-search-box { display: none; }
} }
</style> </style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Link } from '@inertiajs/vue3'
defineProps<{
density?: null | 'default' | 'comfortable' | 'compact';
}>()
interface UserOption {
value: number
title: string
}
const search = ref('')
const selectedUser = ref<UserOption | null>(null)
const items = ref<UserOption[]>([])
const loading = ref(false)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
async function fetchUsers(query: string) {
if (!query) {
items.value = []
return
}
loading.value = true
try {
const response = await fetch(`/search/users?q=${encodeURIComponent(query)}`)
if (!response.ok) throw new Error('Failed to fetch users')
items.value = await response.json()
} catch (error) {
console.error('User search failed:', error)
items.value = []
} finally {
loading.value = false
}
}
watch(search, (newQuery) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
fetchUsers(newQuery)
}, 300)
})
</script>
<template>
<div class="user-search-box">
<v-autocomplete
v-model="selectedUser"
v-model:search="search"
:items="items"
:loading="loading"
item-title="title"
item-value="value"
return-object
label="Search users"
placeholder="Type a name..."
prepend-inner-icon="mdi-magnify"
:density="density ?? 'default'"
variant="outlined"
no-filter
clearable
hide-details
hide-no-data
autocomplete="off"
>
<template #item="{ item }">
<Link :href="`/u/${item.title}`" class="user-search-result">
<v-list-item :title="item.title" />
</Link>
</template>
</v-autocomplete>
</div>
</template>
<style scoped>
.user-search-box {
display: block;
align-self: center;
width: 100%;
}
</style>
+1 -1
View File
@@ -175,7 +175,7 @@ export function getCountries(flights: Flight[], upcomingFlights: Flight[]) {
const result = { const result = {
countries: sorted, countries: sorted,
series: [ series: [
{ name: 'Departed', data: sorted.map(s => s.past) }, { name: 'Flown', data: sorted.map(s => s.past) },
{ name: 'Upcoming', data: sorted.map(s => s.upcoming) }, { name: 'Upcoming', data: sorted.map(s => s.upcoming) },
], ],
} }
+18
View File
@@ -9,6 +9,7 @@ import { ref } from "vue";
import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue"; import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue";
import {SharedProps} from "@/Types/types"; import {SharedProps} from "@/Types/types";
import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue"; import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue";
import CopyPageButton from "@/Components/FlightsGoneBy/CopyPageButton.vue";
const transitionKey = ref(0); const transitionKey = ref(0);
const page = usePage<SharedProps>().props const page = usePage<SharedProps>().props
@@ -27,6 +28,9 @@ router.on('success', () => {
<slot /> <slot />
</main> </main>
</Transition> </Transition>
<span class="footer-share">
<CopyPageButton label="Share Link" />
</span>
<MainFooter :key="`footer-${transitionKey}`" /> <MainFooter :key="`footer-${transitionKey}`" />
</div> </div>
<NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" /> <NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" />
@@ -51,6 +55,14 @@ footer {
min-height: 3rem; min-height: 3rem;
} }
@media (max-width: 600px) {
footer {
flex: 0 1 auto;
min-height: 3.25rem;
padding-block: 0.35rem;
}
}
main { main {
flex: 1 1 auto; flex: 1 1 auto;
background: transparent; background: transparent;
@@ -60,6 +72,12 @@ main {
align-items: center; align-items: center;
} }
.footer-share {
padding:1em;
display: flex;
justify-content: center;
}
.fade-enter-from, .fade-enter-from,
.fade-leave-to { opacity: 0; } .fade-leave-to { opacity: 0; }
.fade-enter-active, .fade-enter-active,
+3 -1
View File
@@ -9,6 +9,7 @@ import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
import { useFlights } from "@/Composables/useFlights"; import { useFlights } from "@/Composables/useFlights";
import { useApiResource } from "@/Composables/useApiResource"; import { useApiResource } from "@/Composables/useApiResource";
import Avatar from "@/Components/FlightsGoneBy/Feed/Avatar.vue"; import Avatar from "@/Components/FlightsGoneBy/Feed/Avatar.vue";
import UserSearchBox from "@/Components/UserSearchBox.vue";
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
@@ -46,8 +47,9 @@ const { data: following, loading: followingLoading } = useApiResource<User[]>('/
</div> </div>
</section> </section>
<UserSearchBox />
<div class="feed-grid"> <div class="feed-grid">
<!-- FOLLOWING: secondary, supporting content -->
<aside class="following-box"> <aside class="following-box">
<h2>Following</h2> <h2>Following</h2>
<div v-if="followingLoading" class="following-loading"> <div v-if="followingLoading" class="following-loading">
@@ -21,6 +21,12 @@ const continents = computed(() => props.continents.filter(c => c.internal_name !
const { entriesByDeparture, departureNames, completedCount, totalCount } = const { entriesByDeparture, departureNames, completedCount, totalCount } =
useDirectedContinentPairs(flights, continents) useDirectedContinentPairs(flights, continents)
// Split "Africa Asia" into ["Africa", "Asia"] for independent flex items
function splitLabel(label: string): [string, string] {
const [from, to] = label.split('→').map(part => part.trim())
return [from, to]
}
</script> </script>
<template> <template>
@@ -35,11 +41,16 @@ const { entriesByDeparture, departureNames, completedCount, totalCount } =
:rows="entriesByDeparture.get(dep) ?? []" :rows="entriesByDeparture.get(dep) ?? []"
:rowKey="entry => entry.key" :rowKey="entry => entry.key"
:hasItems="entry => entry.flights.length > 0" :hasItems="entry => entry.flights.length > 0"
labelWidth="18em" compactOnMobile
> >
<template #label="{ row: entry }"> <template #label="{ row: entry }">
<div class="pair-label"> <div class="pair-label">
<span class="pair-name">{{ entry.label }}</span> <span class="pair-part">{{ splitLabel(entry.label)[0] }}</span>
<span class="pair-divider">
<span class="divider-horizontal">{{ '\u2192\uFE0E' }}</span>
<span class="divider-vertical">{{ '\u2193\uFE0E' }}</span>
</span>
<span class="pair-part">{{ splitLabel(entry.label)[1] }}</span>
</div> </div>
</template> </template>
@@ -80,4 +91,55 @@ const { entriesByDeparture, departureNames, completedCount, totalCount } =
gap: 1rem; gap: 1rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.pair-label {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0 0.25rem;
}
.pair-part {
font-size: 0.9rem;
text-align: center;
white-space: nowrap;
}
.pair-divider {
font-size: 0.9rem;
opacity: 0.6;
flex-shrink: 0;
}
.divider-vertical {
display: none;
}
@container (max-width: 480px) {
.pair-label {
flex-direction: column;
gap: 0.1rem;
}
.pair-part {
font-size: 0.7rem;
white-space: normal;
}
.pair-divider {
font-size: 0.65rem;
line-height: 1;
}
.divider-horizontal {
display: none;
}
.divider-vertical {
display: inline;
}
}
</style> </style>
@@ -19,6 +19,12 @@ const flights = computed(() => props.flights)
const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica')) const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica'))
const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flights, continents) const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flights, continents)
// Split "AfricaAsia" into ["Africa", "Asia"] for independent flex items
function splitLabel(label: string): [string, string] {
const [from, to] = label.split('↔')
return [from, to]
}
</script> </script>
<template> <template>
@@ -32,11 +38,16 @@ const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flig
:rows="entries" :rows="entries"
:rowKey="entry => entry.key" :rowKey="entry => entry.key"
:hasItems="entry => entry.flights.length > 0" :hasItems="entry => entry.flights.length > 0"
labelWidth="18em" compactOnMobile
> >
<template #label="{ row: entry }"> <template #label="{ row: entry }">
<div class="pair-label"> <div class="pair-label">
<span class="pair-name">{{ entry.label }}</span> <span class="pair-part">{{ splitLabel(entry.label)[0] }}</span>
<span class="pair-divider">
<span class="divider-horizontal">{{ '\u2194\uFE0E' }}</span>
<span class="divider-vertical">{{ '\u2195\uFE0E' }}</span>
</span>
<span class="pair-part">{{ splitLabel(entry.label)[1] }}</span>
</div> </div>
</template> </template>
@@ -80,12 +91,51 @@ const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flig
.pair-label { .pair-label {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: center;
gap: 0.35rem;
padding: 0 0.25rem; padding: 0 0.25rem;
} }
.pair-name { .pair-part {
font-size: 0.9rem; font-size: 0.9rem;
text-align: center;
white-space: nowrap;
}
.pair-divider {
font-size: 0.9rem;
opacity: 0.6;
flex-shrink: 0;
}
.divider-vertical {
display: none;
}
@container (max-width: 480px) {
.pair-label {
flex-direction: column;
gap: 0.1rem;
}
.pair-part {
font-size: 0.7rem;
white-space: normal;
}
.pair-divider {
font-size: 0.65rem;
line-height: 1;
}
.divider-horizontal {
display: none;
}
.divider-vertical {
display: inline;
}
} }
</style> </style>
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, Flight, User, Airport } from '@/Types/types'
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'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
londonAirports: string[]
flights: Flight[]
}>()
interface AirportEntry {
code: string
airport: Airport | null
flights: Flight[]
}
const LONDON_AIRPORT_NAMES: Record<string, string> = {
LHR: 'Heathrow Airport',
LGW: 'Gatwick Airport',
STN: 'Stansted Airport',
LTN: 'Luton Airport',
LCY: 'City Airport',
SEN: 'Southend Airport',
}
const airports = computed<AirportEntry[]>(() => {
const entries = props.londonAirports.map(code => ({
code,
airport: null as Airport | null,
flights: [] as Flight[],
}))
const byCode = new Map(entries.map(e => [e.code, e]))
for (const flight of props.flights) {
const dep = flight.departure_airport
const arr = flight.arrival_airport
if (dep?.iata_code && byCode.has(dep.iata_code)) {
const entry = byCode.get(dep.iata_code)!
entry.airport ??= dep
entry.flights.push(flight)
}
if (arr?.iata_code && byCode.has(arr.iata_code)) {
const entry = byCode.get(arr.iata_code)!
entry.airport ??= arr
entry.flights.push(flight)
}
}
return entries
})
const rows = computed(() => airports.value.map(e => e.code))
function entryFor(code: string): AirportEntry {
return airports.value.find(e => e.code === code)!
}
function flightTitle(flight: Flight): string {
const name = flight.airline?.name ?? 'Unknown airline'
return `${name}: ${flight.departure_airport?.iata_code}${flight.arrival_airport?.iata_code}`
}
</script>
<template>
<!-- Airports table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>London Airports</PanelHeader>
</div>
<BadgeTable
:rows="rows"
:rowKey="code => code"
:hasItems="code => entryFor(code).flights.length > 0"
labelWidth="14em"
>
<template #label="{ row: code }">
<div class="airport-label">
<span class="airport-code">{{ code }}</span>
<span>{{ LONDON_AIRPORT_NAMES[code] ?? code }}</span>
</div>
</template>
<template #items="{ row: code }">
<FlightBadge
v-for="flight in entryFor(code).flights"
:key="flight.id"
:title="flightTitle(flight)"
:flight="flight"
/>
</template>
</BadgeTable>
</Panel>
<!-- Requirements -->
<Panel>
<PanelHeader centered>Requirements</PanelHeader>
<p>
To complete this challenge you must fly to all {{ londonAirports.length }} of
London's airports Heathrow, Gatwick, Stansted, Luton, London City, and Southend.
</p>
</Panel>
</template>
<style scoped>
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.airport-label {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.25rem;
}
.airport-code {
font-weight: 600;
opacity: 0.7;
min-width: 2.75rem;
}
</style>
@@ -12,6 +12,7 @@ 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"; import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
@@ -24,6 +25,7 @@ const props = defineProps<{
regions: Region[] regions: Region[]
alliance: Alliance | null alliance: Alliance | null
airlines: Airline[] airlines: Airline[]
london_airports: string[]
continents: Continent[] continents: Continent[]
aircraft_families: Record<string, string[]> aircraft_families: Record<string, string[]>
majorAlliances: Alliance[] majorAlliances: Alliance[]
@@ -118,6 +120,13 @@ const unlocked = computed(() => {
:continents="continents" :continents="continents"
:families="aircraft_families" :families="aircraft_families"
:major-alliances="majorAlliances" :major-alliances="majorAlliances"
:london-airports="london_airports"
/>
<FollowingSelectBox
redirect-route="profile.achievement"
:route-params="{ achievement: achievement.internal_name }"
placeholder="View the Progress of Someone You Follow"
/> />
</div> </div>
</ProfileLayout> </ProfileLayout>
+3
View File
@@ -9,6 +9,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue"; import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue"; import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import {useUpdateSetting} from "@/Composables/useUpdateSetting"; import {useUpdateSetting} from "@/Composables/useUpdateSetting";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
const {updateSetting} = useUpdateSetting() const {updateSetting} = useUpdateSetting()
@@ -121,6 +122,8 @@ const filteredUnlockedCount = computed(() =>
/> />
</div> </div>
</Panel> </Panel>
<FollowingSelectBox redirect-route="profile.achievements" placeholder="View The Achievements of Someone You Follow" />
</div> </div>
</ProfileLayout> </ProfileLayout>
</template> </template>
+10
View File
@@ -56,6 +56,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([]) const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([]) const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([]) const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([]) const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([]) const selectedOtherUsers = ref<string[]>([])
@@ -74,6 +76,8 @@ const activeFilterCount = computed(() =>
selectedSeatTypes.value.length + selectedSeatTypes.value.length +
selectedManufacturers.value.length + selectedManufacturers.value.length +
selectedAircraftModels.value.length + selectedAircraftModels.value.length +
selectedEngineTypes.value.length +
selectedEngineCounts.value.length +
selectedAirportRegions.value.length + selectedAirportRegions.value.length +
selectedOtherUsers.value.length selectedOtherUsers.value.length
) )
@@ -93,6 +97,8 @@ function onFiltersChange(filters: {
seatTypes: number[] seatTypes: number[]
manufacturers: string[] manufacturers: string[]
aircraftModels: number[] aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[] airportRegions: number[]
otherUsers: string[] otherUsers: string[]
}) { }) {
@@ -111,6 +117,8 @@ function onFiltersChange(filters: {
selectedSeatTypes.value = filters.seatTypes selectedSeatTypes.value = filters.seatTypes
selectedManufacturers.value = filters.manufacturers selectedManufacturers.value = filters.manufacturers
selectedAircraftModels.value = filters.aircraftModels selectedAircraftModels.value = filters.aircraftModels
selectedEngineTypes.value = filters.engineTypes
selectedEngineCounts.value = filters.engineCounts
selectedAirportRegions.value = filters.airportRegions selectedAirportRegions.value = filters.airportRegions
selectedOtherUsers.value = filters.otherUsers selectedOtherUsers.value = filters.otherUsers
} }
@@ -141,6 +149,8 @@ function matchesFilters(f: Flight): boolean {
if (selectedSeatTypes.value.length && !selectedSeatTypes.value.includes(f.seat_type?.id ?? -1)) return false if (selectedSeatTypes.value.length && !selectedSeatTypes.value.includes(f.seat_type?.id ?? -1)) return false
if (selectedManufacturers.value.length && !selectedManufacturers.value.includes(f.aircraft?.manufacturer_code ?? '')) return false if (selectedManufacturers.value.length && !selectedManufacturers.value.includes(f.aircraft?.manufacturer_code ?? '')) return false
if (selectedAircraftModels.value.length && !selectedAircraftModels.value.includes(f.aircraft?.id ?? -1)) return false if (selectedAircraftModels.value.length && !selectedAircraftModels.value.includes(f.aircraft?.id ?? -1)) return false
if (selectedEngineTypes.value.length && !selectedEngineTypes.value.includes(f.aircraft?.engine_type ?? '')) return false
if (selectedEngineCounts.value.length && !selectedEngineCounts.value.includes(f.aircraft?.engine_count ?? -1)) return false
if (selectedAirportRegions.value.length) { if (selectedAirportRegions.value.length) {
const depRegion = f.departure_airport.region?.id const depRegion = f.departure_airport.region?.id
const arrRegion = f.arrival_airport.region?.id const arrRegion = f.arrival_airport.region?.id
+1
View File
@@ -101,6 +101,7 @@ export type SharedProps = import('@inertiajs/core').PageProps & {
roles: string[]; roles: string[];
permissions: string[]; permissions: string[];
apiToken: string | null; apiToken: string | null;
following: string[];
}, },
flash: { flash: {
success?: string; success?: string;
+2 -1
View File
@@ -103,10 +103,11 @@ Route::middleware(['auth'])->group(function () {
//Search Routes //Search Routes
Route::get('/search/airlines', [SearchController::class, 'airlines'])->name('search.airlines'); Route::get('/search/airlines', [SearchController::class, 'airlines'])->name('search.airlines');
Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft'); Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');
Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports'); Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports');
Route::get('/search/users', [SearchController::class, 'users'])->name('search.users');
Route::get('/u/{user}', [UserProfileController::class, 'view'])->name('profile.view'); Route::get('/u/{user}', [UserProfileController::class, 'view'])->name('profile.view');
Route::get('/u/{user}/map', [UserProfileController::class, 'map'])->name('profile.map'); Route::get('/u/{user}/map', [UserProfileController::class, 'map'])->name('profile.map');