Compare commits

..
23 changed files with 925 additions and 357 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();
}
} }
@@ -102,6 +102,7 @@ class UserProfileController extends Controller
$canView = Gate::allows('viewProfileData', $user); $canView = Gate::allows('viewProfileData', $user);
$achievements = Achievement::with(['category', 'difficulty']) $achievements = Achievement::with(['category', 'difficulty'])
->orderBy('id', 'asc')
->get() ->get()
->groupBy(fn(Achievement $a) => $a->category->name) ->groupBy(fn(Achievement $a) => $a->category->name)
->map(fn($group) => $group->sortBy('sort_order')->values()); ->map(fn($group) => $group->sortBy('sort_order')->values());
@@ -171,6 +172,11 @@ class UserProfileController extends Controller
default => null, default => null,
}; };
$majorAlliances = match($achievement->internal_name){
'airlines_alliances.all_major_alliances' => Alliance::MAJOR_ALLIANCES,
default => [],
};
$continents = match($achievement->internal_name){ $continents = match($achievement->internal_name){
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(), 'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
default => [], default => [],
@@ -207,6 +213,7 @@ class UserProfileController extends Controller
'continents' => $continents, 'continents' => $continents,
'aircraft_families' => $aircraftFamilies, 'aircraft_families' => $aircraftFamilies,
'achievementCount' => $user->unlockedAchievements()->count(), 'achievementCount' => $user->unlockedAchievements()->count(),
'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'),
+3
View File
@@ -26,6 +26,9 @@ class Alliance extends Model
'skyteam' => 'airlines_alliances.all_skyteam', 'skyteam' => 'airlines_alliances.all_skyteam',
]; ];
const array MAJOR_ALLIANCES = ['skyteam', 'oneworld', 'star_alliance'];
public function airlines(): HasMany public function airlines(): HasMany
{ {
return $this->hasMany(Airline::class); return $this->hasMany(Airline::class);
@@ -28,6 +28,12 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0); $check = fn(string $alliance): int => $flownAllianceAirlines->get($alliance, 0);
$majorAlliancesFlown = collect(Alliance::MAJOR_ALLIANCES)
->filter(fn(string $alliance) => $check($alliance) > 0)
->count();
$this->awardProgress($majorAlliancesFlown, 'airlines_alliances.all_major_alliances');
$this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam'); $this->awardProgress($check('skyteam'), 'airlines_alliances.all_skyteam');
$this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld'); $this->awardProgress($check('oneworld'), 'airlines_alliances.all_oneworld');
$this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance'); $this->awardProgress($check('star_alliance'), 'airlines_alliances.all_star_alliance');
@@ -40,6 +46,8 @@ class AirlinesAndAlliancesChecker extends BaseChecker
$checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count(); $checkGroup = fn(array $group): int => $flownAirlines->intersect($group)->count();
$this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3'); $this->awardProgress($checkGroup(self::ME_3), 'airlines_alliances.me3');
$this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3'); $this->awardProgress($checkGroup(self::US_3), 'airlines_alliances.us3');
$this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3'); $this->awardProgress($checkGroup(self::CN_3), 'airlines_alliances.cn3');
@@ -0,0 +1,58 @@
<?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
{
$moderate = AchievementDifficulty::where('internal_name', 'moderate')->first();
$airlines_and_alliances = AchievementCategory::where('internal_name', 'airlines_and_alliances')->first();
Achievement::create([
'name' => 'Allied Forces',
'internal_name' => 'airlines_alliances.all_major_alliances',
'short_description' => 'Fly The 3 Major Airline Alliances (Skyteam, Star Alliance, Oneworld)',
'progressive' => true,
'difficulty_description' => 'For most people in most parts of the world this is quite achievable',
'threshold' => 3,
'achievement_category_id' => $airlines_and_alliances->id,
'achievement_difficulty_id' => $moderate->id,
'has_page' => true,
'sort_order' => 1,
'icon' => 'standard_achievement.png',
'long_description' => '',
]);
Achievement::where('internal_name', 'airlines_alliances.cn3')->update(['sort_order' => 3]);
Achievement::where('internal_name', 'airlines_alliances.us3')->update(['sort_order' => 1]);
Achievement::where('internal_name', 'airlines_alliances.me3')->update(['sort_order' => 2]);
Achievement::where('internal_name', 'airlines_alliances.all_major_alliances')->update(['sort_order' => 4]);
Achievement::where('internal_name', 'airlines_alliances.all_skyteam')->update(['sort_order' => 5]);
Achievement::where('internal_name', 'airlines_alliances.all_oneworld')->update(['sort_order' => 6]);
Achievement::where('internal_name', 'airlines_alliances.all_star_alliance')->update(['sort_order' => 7]);
Achievement::where('internal_name', 'airlines_alliances.all_vanilla_alliance')->update(['sort_order' => 8]);
foreach(User::all() as $user){
$user->calculateAchievements();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
@@ -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"
/> />
@@ -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,55 +213,87 @@ function buildOptions(flights: Flight[]) {
const availableOptions = buildOptions(props.flights) const availableOptions = buildOptions(props.flights)
// ── Filter state ──────────────────────────────────────────────────────────────
const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([])
const selectedAlliances = ref<number[]>([])
const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([])
const selectedCrewTypes = ref<number[]>([])
const selectedFlightScopes = ref<FlightScope[]>([])
const selectedFlightRanges = ref<FlightRange[]>([])
const selectedRegionRanges = ref<RegionRange[]>([])
const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
// When selected countries change, drop any selected regions that no longer // When selected countries change, drop any selected regions that no longer
// belong to any of the selected countries. // belong to any of the selected countries.
watch(selectedCountries, (countries) => { watch(() => filters.country, (countries) => {
if (countries.length === 0) return if (countries.length === 0) return
selectedAirportRegions.value = selectedAirportRegions.value.filter(regionId => { filters.airportRegion = filters.airportRegion.filter((regionId: number) => {
const region = availableOptions.airportRegions.find(r => r.id === regionId) const region = availableOptions.airportRegions.find(r => r.id === regionId)
if (!region) return false if (!region) return false
return [...(region as any).countryCodes].some((code: string) => countries.includes(code)) return [...region.countryCodes].some((code: string) => countries.includes(code))
}) })
}) })
// ── URL query sync ──────────────────────────────────────────────────────────
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
}
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() { 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, onMounted(async () => {
flightScopes: selectedFlightScopes.value, const hasQueryFilters = loadFiltersFromQuery()
flightRanges: selectedFlightRanges.value,
regionRanges: selectedRegionRanges.value, // Only emit if the URL actually seeded some filters - a plain page load
flightReasons: selectedFlightReasons.value, // (no query params) should behave exactly like before and not emit at all,
seatTypes: selectedSeatTypes.value, // since the parent already renders its own initial, unfiltered state.
manufacturers: selectedManufacturers.value, if (!hasQueryFilters) return
aircraftModels: selectedAircraftModels.value,
airportRegions: selectedAirportRegions.value, // Wait a beat before emitting. Charts further down the tree (ApexCharts,
otherUsers: selectedOtherUsers.value, // 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()
})
// ── Copy link ─────────────────────────────────────────────────────────────────
const linkCopied = ref(false)
async function copyFilterLink() {
try {
await navigator.clipboard.writeText(window.location.href)
linkCopied.value = true
setTimeout(() => { linkCopied.value = false }, 2000)
} catch {
// Clipboard API unavailable (e.g. non-secure context) - nothing to fall
// back to here, the button just silently no-ops.
}
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -209,326 +303,396 @@ 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 class="flight-filters-actions">
<v-btn
:prepend-icon="linkCopied ? 'mdi-check' : 'mdi-link-variant'"
:color="linkCopied ? 'success' : undefined"
variant="outlined"
size="small"
@click="copyFilterLink"
>
{{ linkCopied ? 'Copied!' : 'Copy Link to This Page With Filters' }}
</v-btn>
</div>
</div> </div>
</template> </template>
@@ -543,4 +707,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>
@@ -659,7 +659,9 @@ export default defineComponent({
const fitBounds = (): void => { const fitBounds = (): void => {
if (!props.flights.length) return if (!props.flights.length) return
const padding = props.compact ? 40 : 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])
@@ -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>
@@ -25,5 +25,4 @@ defineProps<{
background-size: cover; background-size: cover;
min-width: 300px; min-width: 300px;
} }
</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 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 -->
@@ -16,7 +16,7 @@ const store = useNotificationStore();
max-width="360" max-width="360"
> >
<v-card-text class="d-flex align-center ga-3"> <v-card-text class="d-flex align-center ga-3">
<v-icon icon="mdi-trophy" color="amber" size="32" /> <v-icon :icon="notification.is_achievement ? 'mdi-trophy' : 'mdi-bell-ring'" color="amber" size="32" />
<div> <div>
<div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div> <div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div> <div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
+83
View File
@@ -0,0 +1,83 @@
<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
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) },
], ],
} }
+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">
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Achievement, 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 AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{
achievement: Achievement
user: User
followStatus: string
majorAlliances: Alliance[]
flights: Flight[]
}>()
interface AllianceEntry {
alliance: Alliance
flights: Flight[]
}
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)
}
return entries
})
const rows = computed(() => alliances.value.map(e => e.alliance.internal_name))
function entryFor(key: string): AllianceEntry {
return alliances.value.find(e => e.alliance.internal_name === key)!
}
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}`
}
</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>
</div>
</Panel>
<!-- Alliances table -->
<Panel>
<div class="table-toolbar">
<PanelHeader>Alliances</PanelHeader>
</div>
<BadgeTable
:rows="rows"
:rowKey="key => key"
:hasItems="key => entryFor(key).flights.length > 0"
labelWidth="14em"
>
<template #label="{ row: key }">
<div class="airline-label">
<AllianceLogo :alliance="entryFor(key).alliance" size="24" />
<span>{{ entryFor(key).alliance.name }}</span>
</div>
</template>
<template #items="{ row: key }">
<FlightBadge
v-for="flight in entryFor(key).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 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.
</p>
<p>
Codeshare flights do not count, the operating carrier must be a member of the
alliance.
</p>
</Panel>
</template>
<style scoped>
.alliance-header {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.logo-row {
display: flex;
gap: 0.75rem;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.airline-label {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.25rem;
}
</style>
+10 -1
View File
@@ -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 })
@@ -26,6 +27,7 @@ const props = defineProps<{
airlines: Airline[] airlines: Airline[]
continents: Continent[] continents: Continent[]
aircraft_families: Record<string, string[]> aircraft_families: Record<string, string[]>
majorAlliances: Alliance[]
achievementCount: number achievementCount: number
canView: boolean canView: boolean
}>() }>()
@@ -63,7 +65,7 @@ const unlocked = computed(() => {
<VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser"> <VAlert type="info" v-if="loggedInUser?.id !== user.id && loggedInUser">
You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress, please You are viewing {{user.name}}'s progress in this achievement. If you would like to see your progress, please
<Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})"><VBtn variant="flat">click here.</VBtn></Link> <Link :href="route('profile.achievement', {user: loggedInUser.name, achievement: achievement.internal_name})"><u>click here.</u></Link>
</VAlert> </VAlert>
@@ -116,6 +118,13 @@ const unlocked = computed(() => {
:alliance="alliance" :alliance="alliance"
:continents="continents" :continents="continents"
:families="aircraft_families" :families="aircraft_families"
:major-alliances="majorAlliances"
/>
<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');