v1 Release

This commit is contained in:
2026-07-11 18:44:40 +10:00
parent 85eeba982f
commit 5ac580bb06
15 changed files with 171 additions and 72 deletions
@@ -129,10 +129,12 @@ class GetAircraftInfoForArrivedFlights extends Command
if ($data->estimated_departure_utc?->ne($originalDeparture)) {
$updates['departure_date'] = $data->estimated_departure_utc;
$updates['departure_processed_at'] = $data->estimated_departure_utc;
}
if ($data->estimated_arrival_utc?->ne($originalArrival)) {
$updates['arrival_date'] = $data->estimated_arrival_utc;
$updates['arrival_processed_at'] = $data->estimated_arrival_utc;
}
if ($data->equipment_iata && $originalAircraft?->iata_code !== $data->equipment_iata) {
@@ -93,7 +93,6 @@ class UserProfileController extends Controller
'canView' => Gate::allows('viewProfileData', $user),
'user' => $user,
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
'otherUsersOnFlight' => $userFlight->otherUsersOnFlight(),
]);
}
+34 -17
View File
@@ -55,11 +55,12 @@ class UserFlight extends Model
'duration',
'duration_display',
'distance',
'livery_url',
'livery',
'scope',
'range',
'region_range',
'class_seat_combined'
'class_seat_combined',
'other_users_on_flight'
];
protected static function booted(): void
@@ -207,19 +208,23 @@ class UserFlight extends Model
return !$this->isDomestic();
}
public function otherUsersOnFlight(): Collection
protected function otherUsersOnFlight(): Attribute
{
return UserFlight::where('user_id', '!=', $this->user_id)
->where('departure_airport_id', $this->departure_airport_id)
->where('arrival_airport_id', $this->arrival_airport_id)
->where('flight_number', $this->flight_number)
->where('airline_id', $this->airline_id)
->whereRaw('DATE(departure_date AT TIME ZONE \'UTC\') = ?', [
Carbon::parse($this->departure_date)->utc()->toDateString()
])
->with('user')
->get()
->pluck('user.name');
return Attribute::make(
get: function () {
return once(fn() => UserFlight::where('user_id', '!=', $this->user_id)
->where('departure_airport_id', $this->departure_airport_id)
->where('arrival_airport_id', $this->arrival_airport_id)
->where('flight_number', $this->flight_number)
->where('airline_id', $this->airline_id)
->whereRaw('DATE(departure_date AT TIME ZONE \'UTC\') = ?', [
Carbon::parse($this->departure_date)->utc()->toDateString()
])
->with('user')
->get()
->pluck('user.name'));
}
);
}
public static function snapshot($userFlightId): array
@@ -286,16 +291,19 @@ class UserFlight extends Model
return $this->belongsTo(FlightReason::class);
}
public function liveryUrl(): Attribute{
public function livery(): Attribute{
return Attribute::make(
get: function () {
$user = auth()?->user();
$useAi = !$user || $user->getSetting('ai_liveries');
$apiUrl = config('app.logo_api_url');
$attribution = '';
$attributionUrl = '';
$isAi = false;
if (!$this->aircraft) {
return null;
}
@@ -305,6 +313,8 @@ class UserFlight extends Model
$path = "images/liveries/{$this->airline->internal_name}_{$this->aircraft->designator}.png";
if (Storage::disk('local')->exists($path)) {
$finalPath = $apiUrl."/airline/{$this->airline->internal_name}/livery/{$this->aircraft->designator}";
$attribution = 'This image is AI generated and may not be an accurate representation of the aircraft.';
$isAi = true;
}
}
@@ -312,6 +322,8 @@ class UserFlight extends Model
$path = "images/livery_templates/{$this->aircraft->designator}.png";
if (Storage::disk('local')->exists($path)) {
$finalPath = $apiUrl."/aircraft/{$this->aircraft->designator}/livery";
$attribution = '© NoRebbo Blank Aircraft Templates';
$attributionUrl = 'https://www.norebbo.com/category/aircraft-templates/';
}
}
@@ -319,7 +331,12 @@ class UserFlight extends Model
return null;
}
return $finalPath;
return [
'image_url' => $finalPath,
'attribution' => $attribution,
'attribution_url' => $attributionUrl,
'is_ai' => $isAi,
];
}
);
}
@@ -3,7 +3,7 @@ import {Aircraft, Airline, Flight} from "@/Types/types";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import WakeTurbulence from "@/Components/FlightsGoneBy/WakeTurbulence.vue";
import LiveryImage from "@/Components/FlightsGoneBy/LiveryImage.vue";
import LiveryImage from "@/Components/FlightsGoneBy/Liveries/LiveryImage.vue";
import ToolTipDivider from "@/Components/FlightsGoneBy/Tooltips/ToolTipDivider.vue";
import ToolTipRows from "@/Components/FlightsGoneBy/Tooltips/ToolTipRows.vue";
import ToolTipRow from "@/Components/FlightsGoneBy/Tooltips/ToolTipRow.vue";
@@ -50,8 +50,8 @@ function formatEngineType(type: string): string {
<ToolTipRow label="Engines" :value="aircraft.engine_count + 'x ' + formatEngineType(aircraft.engine_type)" />
</ToolTipRows>
<ToolTipDivider v-if="flight?.livery_url" />
<ToolTipRows v-if="flight?.livery_url">
<ToolTipDivider v-if="flight?.livery" />
<ToolTipRows v-if="flight?.livery">
<LiveryImage :flight="flight" />
</ToolTipRows>
<ToolTipDivider v-if="flight?.aircraft_registration" />
@@ -24,6 +24,7 @@ const emit = defineEmits<{
manufacturers: string[]
aircraftModels: number[]
airportRegions: number[]
otherUsers: string[]
}]
}>()
@@ -62,10 +63,11 @@ function buildOptions(flights: Flight[]) {
const manufacturers = new Map<string, { name: string }>()
const aircraftModels = new Map<number, { id: number; name: string }>()
const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>()
const otherUsers = new Set<string>()
flights.forEach(f => {
years.add(new Date(f.departure_date).getFullYear())
f.other_users_on_flight?.forEach(name => otherUsers.add(name))
if (f.airline) {
airlines.set(f.airline.id, { id: f.airline.id, name: f.airline.name, airline: f.airline })
@@ -143,6 +145,7 @@ function buildOptions(flights: Flight[]) {
manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)),
aircraftModels: [...aircraftModels.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)),
}
}
@@ -165,6 +168,7 @@ 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.
@@ -194,6 +198,7 @@ function emitFilters() {
manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value,
airportRegions: selectedAirportRegions.value,
otherUsers: selectedOtherUsers.value,
})
}
@@ -508,7 +513,22 @@ const countryFlagClass = (code: string) =>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCrewTypes.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.otherUsers.length > 0"
v-model="selectedOtherUsers"
: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(selectedOtherUsers.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedOtherUsers.length - 2 }}</span>
</template>
</v-select>
</div>
</template>
@@ -1,43 +1,46 @@
<template>
<div class="flight-map-wrapper">
<PlaneLoader v-if="!mapReady" class="map-loader" />
<div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" />
<button @click="exportMapBasic()" class="export-btn" title="Download as Image">
<span class="mdi mdi-download" />
</button>
<button class="projection-btn" @click="toggleProjection" :title="isGlobe ? 'Switch to flat map' : 'Switch to globe'">
<span class="mdi" :class="isGlobe ? 'mdi-map' : 'mdi-earth'"/>
</button>
<div v-if="!flights.length" class="empty-state">
<span class="mdi mdi-earth-off" />
<p>No flight data available</p>
</div>
<div v-if="showLegend" class="map-legend" :class="{ 'map-legend--open': legendOpen }">
<button class="map-legend__toggle" @click="toggleLegend">
<span class="mdi mdi-format-list-bulleted" />
<span class="map-legend__toggle-label">Legend</span>
<span class="mdi" :class="legendOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'" />
<div>
<div class="flight-map-wrapper">
<PlaneLoader v-if="!mapReady" class="map-loader" />
<div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" />
<button @click="exportMapBasic()" class="export-btn" title="Download as Image">
<span class="mdi mdi-download" />
</button>
<div class="map-legend__body">
<div class="map-legend__row" v-for="item in legendItems" :key="item.label">
<svg width="28" height="10" class="map-legend__swatch">
<line
x1="2" y1="5" x2="26" y2="5"
:stroke="item.color"
:stroke-dasharray="item.dashed ? '4 3' : undefined"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span class="map-legend__label">{{ item.label }}</span>
<button class="projection-btn" @click="toggleProjection" :title="isGlobe ? 'Switch to flat map' : 'Switch to globe'">
<span class="mdi" :class="isGlobe ? 'mdi-map' : 'mdi-earth'"/>
</button>
<div v-if="!flights.length" class="empty-state">
<span class="mdi mdi-earth-off" />
<p>No flight data available</p>
</div>
<div v-if="showLegend" class="map-legend" :class="{ 'map-legend--open': legendOpen }">
<button class="map-legend__toggle" @click="toggleLegend">
<span class="mdi mdi-format-list-bulleted" />
<span class="map-legend__toggle-label">Legend</span>
<span class="mdi" :class="legendOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'" />
</button>
<div class="map-legend__body">
<div class="map-legend__row" v-for="item in legendItems" :key="item.label">
<svg width="28" height="10" class="map-legend__swatch">
<line
x1="2" y1="5" x2="26" y2="5"
:stroke="item.color"
:stroke-dasharray="item.dashed ? '4 3' : undefined"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span class="map-legend__label">{{ item.label }}</span>
</div>
</div>
</div>
</div>
<small class="attribution" v-if="compact" v-html="cartoAttribution" />
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick, PropType } from 'vue'
import {defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick, PropType, computed} from 'vue'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { Flight, Airport, SharedProps } from '@/Types/types'
@@ -72,6 +75,7 @@ interface RoutesGeoJSON {
const cartoAttribution = computed(() => `&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>`)
// ── Constants ─────────────────────────────────────────────────────────────────
const PULSE_PHASES = 6
@@ -688,7 +692,7 @@ export default defineComponent({
map = new maplibregl.Map({
container: mapContainer.value!,
cooperativeGestures: true,
attributionControl: false,
attributionControl: props.compact ? false : {compact: true},
center: [0, 20],
zoom: 2,
renderWorldCopies: true,
@@ -700,6 +704,7 @@ export default defineComponent({
type: 'raster',
tileSize: 256,
maxzoom: 19,
attribution: cartoAttribution.value,
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
@@ -767,7 +772,7 @@ export default defineComponent({
if (map) { map.remove(); map = null }
})
return { mapContainer, mapReady, exportMapBasic, legendOpen, legendItems, isGlobe, toggleProjection, toggleLegend }
return { mapContainer, mapReady, exportMapBasic, legendOpen, legendItems, isGlobe, toggleProjection, toggleLegend, cartoAttribution }
},
})
@@ -816,6 +821,19 @@ export default defineComponent({
}
}
small.attribution{
font-size: 0.7em;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
gap: 2px;
}
small.attribution * {
color: var(--muted)
}
.map-container { position: absolute; inset: 0; }
.map-loader { z-index: 10; }
.map-hidden { visibility: hidden; }
@@ -991,4 +1009,9 @@ export default defineComponent({
}
.projection-btn:hover { background: rgba(77,166,255,0.15); color: #4da6ff; }
.projection-btn.globe-active { color: #4da6ff; border-color: rgba(77,166,255,0.35); }
.maplibregl-ctrl-bottom-right {
left: 50%;
right: auto;
transform: translateX(-50%);
}
</style>
@@ -3,7 +3,7 @@ import { Flight } from "@/Types/types";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import LiveryImage from "@/Components/FlightsGoneBy/LiveryImage.vue";
import LiveryImage from "@/Components/FlightsGoneBy/Liveries/LiveryImage.vue";
import ToolTipDivider from "@/Components/FlightsGoneBy/Tooltips/ToolTipDivider.vue";
import ToolTipRow from "@/Components/FlightsGoneBy/Tooltips/ToolTipRow.vue";
import ToolTipRows from "@/Components/FlightsGoneBy/Tooltips/ToolTipRows.vue";
@@ -52,8 +52,8 @@ defineProps<{
<ToolTipRow label="Aircraft" :value="flight.aircraft?.display_name_short" v-if="flight.aircraft" />
<ToolTipRow label="Date" :value="flight.departure_date_display" />
</ToolTipRows>
<ToolTipDivider v-if="flight.livery_url" />
<ToolTipRows v-if="flight.livery_url">
<ToolTipDivider v-if="flight.livery" />
<ToolTipRows v-if="flight.livery">
<LiveryImage :flight="flight" />
</ToolTipRows>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import {computed} from "vue";
import {Link} from "@inertiajs/vue3";
import {Livery} from "@/Types/types";
defineProps<{
livery: Livery
}>()
</script>
<template>
<small v-if="livery.is_ai">{{livery.attribution}}</small>
<Link v-else :href="livery.attribution_url" target="_blank"><small>{{livery.attribution}}</small></Link>
</template>
<style scoped>
small{
font-size: 0.7em;
color: var(--muted);
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import {Flight} from "@/Types/types";
import Mono from "@/Components/FlightsGoneBy/Mono.vue";
import LiveryAttribution from "@/Components/FlightsGoneBy/Liveries/LiveryAttribution.vue";
defineProps<{
flight: Flight
@@ -8,8 +9,8 @@ defineProps<{
</script>
<template>
<div class="livery" v-if="flight.livery_url" :style="{ backgroundImage: `url('${flight.livery_url ?? ''}')` }">
<div class="livery" v-if="flight.livery?.image_url" :style="{ backgroundImage: `url('${flight.livery.image_url ?? ''}')` }">
<LiveryAttribution :livery="flight.livery" />
</div>
</template>
@@ -18,9 +19,11 @@ defineProps<{
display:flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
width: 100%;
aspect-ratio: 16/9;
background-size: cover;
min-width: 300px;
}
</style>
@@ -5,7 +5,7 @@ import DetailRow from "@/Components/FlightsGoneBy/Panels/DetailRow.vue";
import type {Flight} from "@/Types/types";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import PanelSubHeader from "@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue";
import LiveryImage from "@/Components/FlightsGoneBy/LiveryImage.vue";
import LiveryImage from "@/Components/FlightsGoneBy/Liveries/LiveryImage.vue";
defineProps<{
flight: Flight
@@ -13,7 +13,7 @@ defineProps<{
<template>
<Panel label="Route">
<div class="map-placeholder">
<FlightMap :flights="[flight]" :showLegend="false" />
<FlightMap :flights="[flight]" :showLegend="false" compact />
</div>
<DetailRows>
<DetailRow label="Distance" :value="flight.distance + 'km'" />
@@ -148,11 +148,10 @@ const copied = ref(false)
to places you might never have otherwise considered.
</p>
<p>
Most letters are possible to visit somewhat organically if you fly enough, but some will be difficult. <b>Q</b> is probably the most difficult letter to obtain -
there's no rules against having a code starting with Q, but due to many aviation terms starting with Q (such as QNH), very few airports use it.
Most letters are possible to visit somewhat organically if you fly enough, but some will be difficult. Based on our airport database, it is actually possible to obtain every letter except <b>V</b> without leaving China.
</p>
<p>
Based on our airport database, it is actually possible to obtain every letter except <b>V</b> without leaving China.
<b>Q</b> is probably the most difficult letter to obtain - there's no rules against having a code starting with Q, but due to many aviation terms starting with Q (such as QNH), very few airports use it.
</p>
<p>
At time of writing, there are just 2 major airports that have daily scheduled service by major airlines, and 4 airports with any commercial service at all.
@@ -162,7 +161,7 @@ const copied = ref(false)
<li><b>QRO</b> in Mexico - a fantastic place to visit and with flights from around Mexico and the US.</li>
<li><b>QSZ</b> in China is also a very interesting place to visit and is served well from Urumqi and Xi'an.</li>
<li><b>QBC</b> is a small airport in Canada and has daily runs on commuter aircraft from Vancouver.</li>
<li><b>QSR</b> is probably most promising recently - it did not have commercial service for 10 years and is having good growth now with European low cost carriers.</li>
<li><b>QSR</b> in Italy is probably most promising recently - it did not have commercial service for 10 years and is having good growth now with European low cost carriers.</li>
</ul>
<p>
Other potential candidates do not seem so promising
+2 -3
View File
@@ -22,7 +22,6 @@ const props = defineProps<{
followStatus: string
canEdit: boolean
canView: boolean
otherUsersOnFlight: string[]
}>()
</script>
@@ -44,11 +43,11 @@ const props = defineProps<{
<RoutePanel :flight="flight" />
<Panel label="Flight Details">
<BoardingPass :user="user" :showToolTips="false" style="width:100%;max-width:600px; margin:0 auto" :flight="flight" :canEdit="canEdit" />
<DetailRows v-if="otherUsersOnFlight.length > 0">
<DetailRows v-if="flight.other_users_on_flight.length > 0">
<PanelLabel>Other Users On This Flight</PanelLabel>
<div class="d-flex flex-wrap ga-2">
<v-chip
v-for="userName in otherUsersOnFlight"
v-for="userName in flight.other_users_on_flight"
:key="userName"
:href="route('profile.view', userName)"
color="primary"
+9 -1
View File
@@ -57,6 +57,7 @@ const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
const selectedOtherUsers = ref<string[]>([])
const activeFilterCount = computed(() =>
selectedYears.value.length +
@@ -73,7 +74,8 @@ const activeFilterCount = computed(() =>
selectedSeatTypes.value.length +
selectedManufacturers.value.length +
selectedAircraftModels.value.length +
selectedAirportRegions.value.length
selectedAirportRegions.value.length +
selectedOtherUsers.value.length
)
function onFiltersChange(filters: {
@@ -92,6 +94,7 @@ function onFiltersChange(filters: {
manufacturers: string[]
aircraftModels: number[]
airportRegions: number[]
otherUsers: string[]
}) {
localSelectedFlightId.value = null
selectedYears.value = filters.years
@@ -109,6 +112,7 @@ function onFiltersChange(filters: {
selectedManufacturers.value = filters.manufacturers
selectedAircraftModels.value = filters.aircraftModels
selectedAirportRegions.value = filters.airportRegions
selectedOtherUsers.value = filters.otherUsers
}
// Filtering
@@ -143,6 +147,10 @@ function matchesFilters(f: Flight): boolean {
if (!selectedAirportRegions.value.includes(depRegion ?? -1) &&
!selectedAirportRegions.value.includes(arrRegion ?? -1)) return false
}
if (selectedOtherUsers.value.length) {
const names = f.other_users_on_flight ?? []
if (!selectedOtherUsers.value.some(name => names.includes(name))) return false
}
return true
}
+9 -1
View File
@@ -278,6 +278,13 @@ export type RegionRange = "intraregional" | "interregional"
export type FlightScope = 'domestic' | 'international'
export type FlightRange = 'intracontinental' | 'intercontinental'
export interface Livery{
image_url: string,
is_ai: boolean,
attribution: string,
attribution_url: string,
}
export interface Flight {
id: number
flight_number: string | null
@@ -305,9 +312,10 @@ export interface Flight {
scope: FlightScope
range: FlightRange
region_range: RegionRange
livery_url?: string
livery: Livery | null
user?: User | null
class_seat_combined: number
other_users_on_flight: string[]
}