Compare commits

...
8 Commits
Author SHA1 Message Date
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
18 changed files with 765 additions and 413 deletions
+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();
}
}
@@ -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'),
@@ -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"
/>
@@ -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,55 +213,87 @@ function buildOptions(flights: Flight[]) {
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
// belong to any of the selected countries.
watch(selectedCountries, (countries) => {
watch(() => filters.country, (countries) => {
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)
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() {
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()
})
// ── 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 ───────────────────────────────────────────────────────────────────
@@ -209,10 +303,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 +317,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 +351,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 +381,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 +411,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 +430,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 +449,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 +468,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 +486,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 +505,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 +523,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 +542,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 +561,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 +580,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 +599,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 +656,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 +674,25 @@ 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 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>
</template>
@@ -543,4 +707,8 @@ const countryFlagClass = (code: string) =>
.flight-filters .v-select {
flex: 1 1 160px;
}
.flight-filters-actions {
margin-top: 12px;
}
</style>
@@ -659,7 +659,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])
@@ -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;
min-width: 300px;
}
</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 density="compact" style="max-width:400px; width: 400px;" />
<NotificationMenu v-if="page.props.auth?.user" :unread-count="page.props.unread_notification_count" />
<!-- Desktop nav -->
+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 = {
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) },
],
}
+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">
@@ -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 })
@@ -119,6 +120,12 @@ const unlocked = computed(() => {
: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>
</ProfileLayout>
</template>
+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');