Compare commits

..
Author SHA1 Message Date
dredgy cf1a2f6cba Merge branch 'main' into make-flighttooltip-better-on-mobile 2026-08-20 16:49:41 +10:00
dredgy 3ae1e75275 Merge branch 'make-flighttooltip-better-on-mobile' into main 2026-08-20 16:47:07 +10:00
dredgy 1ef4ae4a21 Merge remote-tracking branch 'origin/main' into make-flighttooltip-better-on-mobile 2026-08-20 16:46:44 +10:00
dredgy 90ea8d81d2 Allow "Log 1st Flight" achievement to be awarded even if flight is upcoming 2026-08-20 16:21:14 +10:00
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
35 changed files with 1278 additions and 453 deletions
@@ -3,19 +3,35 @@
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Attributes\Argument;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('app:recalculate-achivements')]
#[Description('Recalculate achievements for all users')]
#[Signature('app:recalculate-achievements {username? : The username of a single user to recalculate}')]
#[Description('Recalculate achievements for all users, or a single user by username')]
class RecalculateAchievements extends Command
{
public function handle(): void
{
$users = User::all();
foreach ($users as $user) {
$user->calculateAchievements();
$username = $this->argument('username');
if ($username) {
$user = User::where('name', $username)->first();
if (! $user) {
$this->error("No user found with username [{$username}].");
return;
}
$user->calculateAchievements();
$this->info("Recalculated achievements for {$username}.");
return;
}
$users = User::all();
$this->withProgressBar($users, fn (User $user) => $user->calculateAchievements());
$this->newLine();
$this->info("Recalculated achievements for {$users->count()} users.");
}
}
+20
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Aircraft;
use App\Models\Airline;
use App\Models\Airport;
use App\Models\User;
use Illuminate\Http\Request;
class SearchController extends Controller
@@ -82,4 +83,23 @@ class SearchController extends Controller
])
->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\Aircraft;
use App\Models\Airport;
use App\Models\Alliance;
use App\Models\Continent;
use App\Models\Country;
@@ -199,6 +200,11 @@ class UserProfileController extends Controller
default => [],
};
$londonAirports = match($achievement->internal_name){
'fun_challenges.all_london_airports' => Airport::LONDON_AIRPORTS,
default => [],
};
$canView = Gate::allows('viewProfileData', $user);
return Inertia::render('Profile/UserAchievement', [
@@ -212,6 +218,7 @@ class UserProfileController extends Controller
'airlines' => $airlines,
'continents' => $continents,
'aircraft_families' => $aircraftFamilies,
'london_airports' => $londonAirports,
'achievementCount' => $user->unlockedAchievements()->count(),
'majorAlliances' => Alliance::whereIn('internal_name', $majorAlliances)->get(),
'canView' => $canView,
@@ -38,6 +38,7 @@ class HandleInertiaRequests extends Middleware
'roles' => $request->user()?->getRoleNames() ?? [],
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
'apiToken' => session('api_token'),
'following' => $request->user()?->following()->with('followee')->get()->pluck('followee')->filter()->pluck('name')->toArray() ?? [],
],
'flash' => [
'success' => $request->session()->get('success'),
+4
View File
@@ -33,6 +33,10 @@ class Airport extends Model
'display_name',
];
const array LONDON_AIRPORTS = [
'LHR', 'LGW', 'STN', 'LTN', 'LCY', 'SEN',
];
protected function displayName() : Attribute{
return Attribute::make(
get: function () {
@@ -12,6 +12,7 @@ use App\Services\Achievements\Checkers\AchievementCheckerInterface;
use App\Services\Achievements\Checkers\AircraftChecker;
use App\Services\Achievements\Checkers\CountriesAndContinentsChecker;
use App\Services\Achievements\Checkers\AirlinesAndAlliancesChecker;
use App\Services\Achievements\Checkers\DistanceChecker;
use App\Services\Achievements\Checkers\FunChallengesChecker;
use App\Services\Achievements\Checkers\GeneralFlyingChecker;
use Illuminate\Support\Collection;
@@ -30,6 +31,7 @@ class AchievementService
AircraftChecker::class,
AirlinesAndAlliancesChecker::class,
FunChallengesChecker::class,
DistanceChecker::class,
];
public function __construct()
@@ -45,6 +47,7 @@ class AchievementService
* @var Collection<int,UserFlight> $flights
*/
private Collection $flights;
private int $loggedFlightCount;
public function calculate(User $user): void
{
@@ -62,20 +65,24 @@ class AchievementService
'arrivalAirport.region.continent',
])->where('departure_date', '<=', now('UTC'))->get();
$this->loggedFlightCount = $user->flights()->count();
foreach ($this->checkers as $checkerClass) {
$checker = new $checkerClass($this);
$checker->check($user);
}
}
/**
* @return Collection<int,UserFlight>
*/
public function getFlights(): Collection
{
return $this->flights;
}
public function getLoggedFlightCount(): int
{
return $this->loggedFlightCount;
}
// ---------------------------------------------------------------
// Award / revoke
@@ -15,6 +15,11 @@ abstract class BaseChecker implements AchievementCheckerInterface
return $this->service->getFlights();
}
protected function allLoggedFlightsCount(): int
{
return $this->service->getLoggedFlightCount();
}
/**
* Resolve an achievement ID from its internal name.
* Results are cached on the service so repeated lookups are free.
@@ -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;
use App\Models\Airport;
use App\Models\User;
use App\Models\UserFlight;
@@ -131,5 +132,19 @@ class FunChallengesChecker extends BaseChecker
->count();
$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');
}
}
@@ -15,10 +15,11 @@ class GeneralFlyingChecker extends BaseChecker
*/
$flights = $this->flights();
$count = $flights->count();
$allLoggedFlightsCount = $this->allLoggedFlightsCount();
// --- Boolean achievements ---
$this->awardIf($count >= 1, 'general_flying.first_flight');
$this->awardIf($allLoggedFlightsCount >= 1, 'general_flying.first_flight');
$this->awardIf(
$flights->contains(fn ($f) => $f->isDomestic()),
@@ -68,14 +69,6 @@ class GeneralFlyingChecker extends BaseChecker
'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.50_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">
import { computed } from 'vue'
import { Achievement, Alliance, Flight, User } from '@/Types/types'
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'
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
defineOptions({ inheritAttrs: false })
interface AirlineEntry {
airline: Airline
flights: Flight[]
}
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
majorAlliances: Alliance[]
alliance: Alliance
airlines: Airline[]
flights: Flight[]
}>()
interface AllianceEntry {
alliance: Alliance
flights: Flight[]
}
const flightsByAirline = computed(() => {
const map = new Map<string, AirlineEntry>()
const idToKey = new Map<number, string>()
const alliances = computed<AllianceEntry[]>(() => {
const entries = props.majorAlliances.map(alliance => ({ alliance, flights: [] as Flight[] }))
const byName = new Map(entries.map(e => [e.alliance.internal_name, e]))
for (const flight of props.flights) {
const key = flight.airline?.alliance?.internal_name
if (!key) continue
byName.get(key)?.flights.push(flight)
for (const airline of props.airlines) {
const key = rowKey(airline)
map.set(key, { airline, flights: [] })
idToKey.set(airline.id, key)
for (const alias of airline.merged_from ?? []) {
idToKey.set(alias.id, key)
}
}
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(
() => alliances.value.filter(a => a.flights.length > 0).length
)
function flightTitle(flight: Flight): string {
const name = flight.airline?.name ?? 'Unknown airline'
return `${name}: ${flight.departure_airport?.iata_code}${flight.arrival_airport?.iata_code}`
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">
<div class="logo-row">
<AllianceLogo
v-for="entry in alliances"
:key="entry.alliance.internal_name"
:alliance="entry.alliance"
size="56"
/>
</div>
<AllianceLogo :alliance="alliance" size="56" />
<div>
<PanelHeader centered>Major Alliances</PanelHeader>
<PanelHeader centered>{{ alliance.name }}</PanelHeader>
<PanelSubHeader centered>
{{ completedCount }} / {{ alliances.length }} alliances flown
<slot />
</PanelSubHeader>
</div>
</div>
</Panel>
<!-- Per-alliance breakdown -->
<Panel v-for="entry in alliances" :key="entry.alliance.internal_name">
<!-- Airlines table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>{{ entry.alliance.name }}</PanelHeader>
<PanelHeader>Airlines</PanelHeader>
</div>
<BadgeTable
:rows="[entry.alliance.internal_name]"
:rows="rows"
:rowKey="key => key"
:hasItems="() => entry.flights.length > 0"
:hasItems="key => entryFor(key).flights.length > 0"
labelWidth="14em"
>
<template #label>
<div class="airline-label">
<AllianceLogo :alliance="entry.alliance" size="24" />
<span>{{ entry.alliance.name }}</span>
<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>
<template #items="{ row: key }">
<FlightBadge
v-for="flight in entry.flights"
v-for="flight in entryFor(key).flights"
:key="flight.id"
:title="flightTitle(flight)"
:flight="flight"
/>
: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 at least one member airline from each of
the three major alliances. Unlike the individual alliance challenges, you don't need
to fly every airline in an alliance just one flight per alliance is enough.
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 the
alliance.
Codeshare flights do not count, the operating carrier must be a member of {{alliance.name}}.
</p>
</Panel>
</template>
@@ -123,10 +133,6 @@ function flightTitle(flight: Flight): string {
flex-wrap: wrap;
}
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar {
display: flex;
@@ -136,6 +142,7 @@ function flightTitle(flight: Flight): string {
margin-bottom: 0.75rem;
}
.airline-label {
width: 100%;
display: flex;
@@ -143,4 +150,5 @@ function flightTitle(flight: Flight): string {
gap: 0.5rem;
padding: 0 0.25rem;
}
</style>
@@ -250,7 +250,7 @@ const legendItems = computed(() => {
<div v-if="mode === 'all'" class="ring-labels">
<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 }}
</span>
</div>
@@ -7,10 +7,9 @@ const props = defineProps<{
</script>
<template>
<DonutChart
title="Flight Scopes"
title="Flight Scope"
:height="280"
center-mode="count"
total-label="Flight Scopes"
total-label="Flights"
:labels="flightStats.flightTypes.value.labels"
: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">
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"
const props = defineProps<{
@@ -23,11 +23,63 @@ const emit = defineEmits<{
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[]
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 ─────────────────────────────────────────────────────────
type AirlineData = { id: number; name: string; airline: Airline }
@@ -62,6 +114,8 @@ function buildOptions(flights: Flight[]) {
const regionRanges = new Set<RegionRange>()
const manufacturers = new Map<string, { 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 otherUsers = new Set<string>()
@@ -120,6 +174,12 @@ function buildOptions(flights: Flight[]) {
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 })
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)
flightRanges.add(f.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] })),
manufacturers: [...manufacturers.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)),
otherUsers: [...otherUsers].sort((a, b) => a.localeCompare(b)),
}
@@ -151,57 +213,64 @@ function buildOptions(flights: Flight[]) {
const availableOptions = buildOptions(props.flights)
// ── Filter state ──────────────────────────────────────────────────────────────
// ── URL query sync ──────────────────────────────────────────────────────────
const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([])
const selectedAlliances = ref<number[]>([])
const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([])
const selectedCrewTypes = ref<number[]>([])
const selectedFlightScopes = ref<FlightScope[]>([])
const selectedFlightRanges = ref<FlightRange[]>([])
const selectedRegionRanges = ref<RegionRange[]>([])
const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
function loadFiltersFromQuery(): boolean {
const params = new URLSearchParams(window.location.search)
let foundAny = false
for (const def of FILTER_DEFS) {
const raw = params.get(def.name)
if (!raw) continue
const values = raw.split(',').filter(Boolean)
filters[def.name] = def.kind === 'number'
? values.map(Number).filter(n => !Number.isNaN(n))
: values
foundAny = true
}
return foundAny
}
// When selected countries change, drop any selected regions that no longer
// belong to any of the selected countries.
watch(selectedCountries, (countries) => {
if (countries.length === 0) return
selectedAirportRegions.value = selectedAirportRegions.value.filter(regionId => {
const region = availableOptions.airportRegions.find(r => r.id === regionId)
if (!region) return false
return [...(region as any).countryCodes].some((code: string) => countries.includes(code))
})
})
function syncUrlWithFilters() {
const params = new URLSearchParams()
for (const def of FILTER_DEFS) {
const value = filters[def.name]
if (value.length > 0)
params.set(def.name, value.join(','))
}
const query = params.toString()
const newUrl = `${window.location.pathname}${query ? `?${query}` : ''}`
window.history.replaceState(window.history.state, '', newUrl)
}
// ── Emit ──────────────────────────────────────────────────────────────────────
function emitFilters() {
emit('change', {
years: selectedYears.value,
airlines: selectedAirlines.value,
alliances: selectedAlliances.value,
countries: selectedCountries.value,
continents: selectedContinents.value,
flightClasses: selectedFlightClasses.value,
crewTypes: selectedCrewTypes.value,
flightScopes: selectedFlightScopes.value,
flightRanges: selectedFlightRanges.value,
regionRanges: selectedRegionRanges.value,
flightReasons: selectedFlightReasons.value,
seatTypes: selectedSeatTypes.value,
manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value,
airportRegions: selectedAirportRegions.value,
otherUsers: selectedOtherUsers.value,
})
syncUrlWithFilters()
const payload = Object.fromEntries(
FILTER_DEFS.map(def => [def.emitKey, filters[def.name]]),
)
emit('change', payload as Parameters<typeof emit>[1] extends [infer T] ? T : never)
}
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 ───────────────────────────────────────────────────────────────────
const countryFlagClass = (code: string) =>
@@ -209,10 +278,12 @@ const countryFlagClass = (code: string) =>
</script>
<template>
<div class="flight-filters-wrapper">
<div class="flight-filters">
<v-select
v-model="selectedYears"
name="year"
v-model="filters.year"
:items="availableOptions.years"
label="Year"
multiple clearable hide-details
@@ -221,14 +292,15 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ item }}<span v-if="index < Math.min(selectedYears.length, 2) - 1">,&nbsp;</span>
{{ item }}<span v-if="index < Math.min(filters.year.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedYears.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.year.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedAirlines"
name="airline"
v-model="filters.airline"
:items="availableOptions.airlines"
item-title="name" item-value="id"
label="Airline"
@@ -254,15 +326,16 @@ const countryFlagClass = (code: string) =>
</template>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirlines.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.airline.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirlines.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.airline.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.alliances.length > 0"
v-model="selectedAlliances"
name="alliance"
v-model="filters.alliance"
:items="availableOptions.alliances"
item-title="name" item-value="id"
label="Alliance"
@@ -283,14 +356,15 @@ const countryFlagClass = (code: string) =>
</template>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAlliances.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.alliance.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAlliances.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.alliance.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedCountries"
name="country"
v-model="filters.country"
:items="availableOptions.countries"
item-title="name" item-value="code"
label="Country"
@@ -312,15 +386,16 @@ const countryFlagClass = (code: string) =>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
<span :class="countryFlagClass((item as any).code)" style="margin-right: 4px;" />
{{ (item as any).name }}<span v-if="index < Math.min(selectedCountries.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.country.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCountries.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.country.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.flightScopes.length > 1"
v-model="selectedFlightScopes"
name="flightScope"
v-model="filters.flightScope"
:items="availableOptions.flightScopes"
item-title="label" item-value="value"
label="Flight Scope"
@@ -330,15 +405,16 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightScopes.length, 2) - 1">,&nbsp;</span>
{{ (item as any).label }}<span v-if="index < Math.min(filters.flightScope.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightScopes.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightScope.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.airportRegions.length > 0"
v-model="selectedAirportRegions"
name="airportRegion"
v-model="filters.airportRegion"
:items="availableOptions.airportRegions"
item-title="name" item-value="id"
label="Region"
@@ -348,15 +424,16 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirportRegions.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.airportRegion.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirportRegions.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.airportRegion.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.regionRanges.length > 1"
v-model="selectedRegionRanges"
name="regionRange"
v-model="filters.regionRange"
:items="availableOptions.regionRanges"
item-title="label" item-value="value"
label="Region Range"
@@ -366,14 +443,15 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedRegionRanges.length, 2) - 1">,&nbsp;</span>
{{ (item as any).label }}<span v-if="index < Math.min(filters.regionRange.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedRegionRanges.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.regionRange.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedContinents"
name="continent"
v-model="filters.continent"
:items="availableOptions.continents"
item-title="name" item-value="code"
label="Continent"
@@ -383,15 +461,16 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedContinents.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.continent.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedContinents.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.continent.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.flightRanges.length > 1"
v-model="selectedFlightRanges"
name="flightRange"
v-model="filters.flightRange"
:items="availableOptions.flightRanges"
item-title="label" item-value="value"
label="Flight Range"
@@ -401,14 +480,15 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightRanges.length, 2) - 1">,&nbsp;</span>
{{ (item as any).label }}<span v-if="index < Math.min(filters.flightRange.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightRanges.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightRange.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedFlightClasses"
name="flightClass"
v-model="filters.flightClass"
:items="availableOptions.classes"
item-title="name" item-value="id"
label="Flight Class"
@@ -418,15 +498,16 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightClasses.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.flightClass.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightClass.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.flightReasons.length > 0"
v-model="selectedFlightReasons"
name="flightReason"
v-model="filters.flightReason"
:items="availableOptions.flightReasons"
item-title="name" item-value="id"
label="Flight Reason"
@@ -436,15 +517,16 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightReasons.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.flightReason.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightReasons.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.flightReason.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.seatTypes.length > 0"
v-model="selectedSeatTypes"
name="seatType"
v-model="filters.seatType"
:items="availableOptions.seatTypes"
item-title="name" item-value="id"
label="Seat Type"
@@ -454,15 +536,16 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedSeatTypes.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.seatType.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedSeatTypes.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.seatType.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.manufacturers.length > 0"
v-model="selectedManufacturers"
name="manufacturer"
v-model="filters.manufacturer"
:items="availableOptions.manufacturers"
item-title="name" item-value="name"
label="Aircraft Manufacturer"
@@ -472,15 +555,16 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedManufacturers.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.manufacturer.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedManufacturers.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.manufacturer.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.aircraftModels.length > 0"
v-model="selectedAircraftModels"
name="aircraftModel"
v-model="filters.aircraftModel"
:items="availableOptions.aircraftModels"
item-title="name" item-value="id"
label="Aircraft Model"
@@ -490,15 +574,54 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAircraftModels.length, 2) - 1">,&nbsp;</span>
{{ (item as any).name }}<span v-if="index < Math.min(filters.aircraftModel.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAircraftModels.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.aircraftModel.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.engineTypes.length > 0"
name="engineType"
v-model="filters.engineType"
:items="availableOptions.engineTypes"
item-title="name" item-value="name"
label="Engine 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.engineType.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.engineType.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.engineCounts.length > 0"
name="engineCount"
v-model="filters.engineCount"
:items="availableOptions.engineCounts"
item-title="count" item-value="count"
label="Engine Count"
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).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"
v-model="selectedCrewTypes"
name="crewType"
v-model="filters.crewType"
:items="availableOptions.crewTypes"
item-title="name" item-value="id"
label="Crew Type"
@@ -508,14 +631,16 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedCrewTypes.length, 2) - 1">,&nbsp;</span>
{{ (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">+{{ selectedCrewTypes.length - 2 }}</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"
v-model="selectedOtherUsers"
name="otherUsers"
v-model="filters.otherUsers"
:items="availableOptions.otherUsers"
label="Flew With"
multiple clearable hide-details
@@ -524,11 +649,13 @@ const countryFlagClass = (code: string) =>
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ item }}<span v-if="index < Math.min(selectedOtherUsers.length, 2) - 1">,&nbsp;</span>
{{ 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">+{{ selectedOtherUsers.length - 2 }}</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ filters.otherUsers.length - 2 }}</span>
</template>
</v-select>
</div>
</div>
</template>
@@ -543,4 +670,8 @@ const countryFlagClass = (code: string) =>
.flight-filters .v-select {
flex: 1 1 160px;
}
.flight-filters-actions {
margin-top: 12px;
}
</style>
@@ -1,6 +1,6 @@
<template>
<div>
<div class="flight-map-wrapper">
<div class="flight-map-wrapper" :class="{ tooltip }">
<PlaneLoader v-if="!mapReady" class="map-loader" />
<div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" />
<button @click="exportMapBasic()" class="export-btn" title="Download as Image">
@@ -277,8 +277,11 @@ export default defineComponent({
compact: {
type: Boolean,
default: false,
},
tooltip: {
type: Boolean,
default: false,
}
},
setup(props) {
@@ -659,7 +662,9 @@ export default defineComponent({
const fitBounds = (): void => {
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 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;
max-height: none;
}
.flight-map-wrapper.tooltip {
aspect-ratio: 16 / 10;
max-height: 30vh;
}
}
small.attribution{
@@ -40,7 +40,7 @@ defineProps<{
<ToolTipRows v-if="isActive.value">
<FlightMap :flights="[flight]" :showLegend="false" compact />
<FlightMap :flights="[flight]" :showLegend="false" compact tooltip />
</ToolTipRows>
<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
hasItems: (row: T) => boolean
labelWidth?: string
compactOnMobile?: boolean
}>()
</script>
@@ -15,7 +16,11 @@ defineProps<{
class="badge-row"
>
<!-- 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" />
</div>
@@ -38,8 +43,6 @@ defineProps<{
container-type: inline-size;
}
.badge-row {
display: flex;
align-items: center;
@@ -49,13 +52,13 @@ defineProps<{
.badge-label {
flex: 0 0 auto;
width: 15em;
width: 21em;
padding: 0.5em;
border-right: 1px solid var(--table-border);
align-self: stretch;
display: flex;
align-items: center;
font-size:0.9rem;
font-size: 0.9rem;
}
@container (max-width: 480px) {
@@ -63,6 +66,12 @@ defineProps<{
font-size: 0.75rem;
width: 9em;
}
.badge-label--compact {
font-size: 0.7rem;
width: clamp(6em, 50cqw, 9em);
flex-shrink: 0;
}
}
.badge-scroll-container {
@@ -25,5 +25,4 @@ defineProps<{
background-size: cover;
min-width: 300px;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Link, usePage } from "@inertiajs/vue3"
import { SharedProps } from "@/Types/types"
import CopyPageButton from "@/Components/FlightsGoneBy/CopyPageButton.vue";
const page = usePage<SharedProps>().props
const year = new Date().getFullYear()
@@ -63,9 +64,17 @@ footer {
color: #ffc107;
}
.footer-version {
opacity: 0.8;
margin-left: 0.4em;
}
@media (max-width: 600px) {
footer {
padding: 0 1rem;
flex-wrap: wrap;
flex: 0 0 auto;
row-gap: 0.5rem;
padding: 0.6rem 1rem;
}
.footer-copyright {
@@ -75,10 +84,6 @@ footer {
.footer-link {
font-size: 0.6rem;
}
}
.footer-version {
opacity: 0.8;
margin-left: 0.4em;
}
</style>
@@ -5,6 +5,7 @@ import type { SharedProps } from '@/Types/types'
import { ref, onMounted, onUnmounted } from 'vue'
import NotificationMenu from "@/Components/FlightsGoneBy/NotificationMenu.vue";
import Logo from "@/Components/FlightsGoneBy/Logo.vue";
import UserSearchBox from "@/Components/UserSearchBox.vue";
const page = usePage<SharedProps>()
const menuOpen = ref(false)
@@ -30,6 +31,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<Logo variant="color" />
</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" />
<!-- Desktop nav -->
@@ -369,5 +371,6 @@ header {
@media (max-width: 600px) {
.nav-desktop { display: none; }
.hamburger { display: flex; }
.user-search-box { display: none; }
}
</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 = {
countries: sorted,
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) },
],
}
+18
View File
@@ -9,6 +9,7 @@ import { ref } from "vue";
import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue";
import {SharedProps} from "@/Types/types";
import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue";
import CopyPageButton from "@/Components/FlightsGoneBy/CopyPageButton.vue";
const transitionKey = ref(0);
const page = usePage<SharedProps>().props
@@ -27,6 +28,9 @@ router.on('success', () => {
<slot />
</main>
</Transition>
<span class="footer-share">
<CopyPageButton label="Share Link" />
</span>
<MainFooter :key="`footer-${transitionKey}`" />
</div>
<NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" />
@@ -51,6 +55,14 @@ footer {
min-height: 3rem;
}
@media (max-width: 600px) {
footer {
flex: 0 1 auto;
min-height: 3.25rem;
padding-block: 0.35rem;
}
}
main {
flex: 1 1 auto;
background: transparent;
@@ -60,6 +72,12 @@ main {
align-items: center;
}
.footer-share {
padding:1em;
display: flex;
justify-content: center;
}
.fade-enter-from,
.fade-leave-to { opacity: 0; }
.fade-enter-active,
+3 -1
View File
@@ -9,6 +9,7 @@ import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
import { useFlights } from "@/Composables/useFlights";
import { useApiResource } from "@/Composables/useApiResource";
import Avatar from "@/Components/FlightsGoneBy/Feed/Avatar.vue";
import UserSearchBox from "@/Components/UserSearchBox.vue";
defineOptions({ layout: MainLayout })
@@ -46,8 +47,9 @@ const { data: following, loading: followingLoading } = useApiResource<User[]>('/
</div>
</section>
<UserSearchBox />
<div class="feed-grid">
<!-- FOLLOWING: secondary, supporting content -->
<aside class="following-box">
<h2>Following</h2>
<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 } =
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>
<template>
@@ -35,11 +41,16 @@ const { entriesByDeparture, departureNames, completedCount, totalCount } =
:rows="entriesByDeparture.get(dep) ?? []"
:rowKey="entry => entry.key"
:hasItems="entry => entry.flights.length > 0"
labelWidth="18em"
compactOnMobile
>
<template #label="{ row: entry }">
<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>
</template>
@@ -80,4 +91,55 @@ const { entriesByDeparture, departureNames, completedCount, totalCount } =
gap: 1rem;
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>
@@ -19,6 +19,12 @@ const flights = computed(() => props.flights)
const continents = computed(() => props.continents.filter(c => c.internal_name !== 'antarctica'))
const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flights, continents)
// Split "Africa↔Asia" into ["Africa", "Asia"] for independent flex items
function splitLabel(label: string): [string, string] {
const [from, to] = label.split('↔')
return [from, to]
}
</script>
<template>
@@ -32,11 +38,16 @@ const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flig
:rows="entries"
:rowKey="entry => entry.key"
:hasItems="entry => entry.flights.length > 0"
labelWidth="18em"
compactOnMobile
>
<template #label="{ row: entry }">
<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>
</template>
@@ -80,12 +91,51 @@ const { entries, completedCount, totalCount } = useUndirectedContinentPairs(flig
.pair-label {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
justify-content: center;
gap: 0.35rem;
padding: 0 0.25rem;
}
.pair-name {
.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>
@@ -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 GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
defineOptions({ layout: MainLayout })
@@ -24,6 +25,7 @@ const props = defineProps<{
regions: Region[]
alliance: Alliance | null
airlines: Airline[]
london_airports: string[]
continents: Continent[]
aircraft_families: Record<string, string[]>
majorAlliances: Alliance[]
@@ -118,6 +120,13 @@ const unlocked = computed(() => {
:continents="continents"
:families="aircraft_families"
: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>
</ProfileLayout>
+3
View File
@@ -9,6 +9,7 @@ import MainLayout from "@/Layouts/MainLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
import FollowingSelectBox from "@/Components/FlightsGoneBy/FollowingSelectBox.vue";
const {updateSetting} = useUpdateSetting()
@@ -121,6 +122,8 @@ const filteredUnlockedCount = computed(() =>
/>
</div>
</Panel>
<FollowingSelectBox redirect-route="profile.achievements" placeholder="View The Achievements of Someone You Follow" />
</div>
</ProfileLayout>
</template>
+10
View File
@@ -56,6 +56,8 @@ const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedEngineTypes = ref<string[]>([])
const selectedEngineCounts = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
@@ -74,6 +76,8 @@ const activeFilterCount = computed(() =>
selectedSeatTypes.value.length +
selectedManufacturers.value.length +
selectedAircraftModels.value.length +
selectedEngineTypes.value.length +
selectedEngineCounts.value.length +
selectedAirportRegions.value.length +
selectedOtherUsers.value.length
)
@@ -93,6 +97,8 @@ function onFiltersChange(filters: {
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
engineTypes: string[]
engineCounts: number[]
airportRegions: number[]
otherUsers: string[]
}) {
@@ -111,6 +117,8 @@ function onFiltersChange(filters: {
selectedSeatTypes.value = filters.seatTypes
selectedManufacturers.value = filters.manufacturers
selectedAircraftModels.value = filters.aircraftModels
selectedEngineTypes.value = filters.engineTypes
selectedEngineCounts.value = filters.engineCounts
selectedAirportRegions.value = filters.airportRegions
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 (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 (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) {
const depRegion = f.departure_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[];
permissions: string[];
apiToken: string | null;
following: string[];
},
flash: {
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/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');
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}/map', [UserProfileController::class, 'map'])->name('profile.map');