Add more airlines and fix edit bugs

This commit is contained in:
2026-04-20 09:23:26 +10:00
parent 4244b8835d
commit 8d7d8f02d3
66 changed files with 877 additions and 614 deletions
+52
View File
@@ -15,6 +15,58 @@
}
/* Share Tech Mono */
@font-face {
font-family: 'Share Tech Mono';
src: url('/fonts/share-tech-mono-v16-latin-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Barlow */
@font-face {
font-family: 'Barlow';
src: url('/fonts/barlow-v13-latin_latin-ext-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Barlow';
src: url('/fonts/barlow-v13-latin_latin-ext-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Barlow';
src: url('/fonts/barlow-v13-latin_latin-ext-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Barlow Condensed */
@font-face {
font-family: 'Barlow Condensed';
src: url('/fonts/barlow-condensed-v13-latin-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Barlow Condensed';
src: url('/fonts/barlow-condensed-v13-latin-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
a {
cursor: pointer;
text-decoration: none;
@@ -5,6 +5,7 @@ import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
const props = defineProps<{
flights: Flight[]
canEdit: boolean
}>()
const today = new Date()
@@ -1,20 +1,18 @@
<template>
<div class="chart-wrap">
<div class="chart-title">Continents visited</div>
<apexchart
<FlightChart
title="Continents"
v-if="series.length"
type="donut"
height="280"
:options="chartOptions"
:series="seriesData"
/>
<div v-else class="chart-empty">No continent data available</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -4,7 +4,7 @@
<div v-if="series.length" class="chart-outer">
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<apexchart
<UnstyledFlightChart
type="bar"
:height="chartHeight"
:options="chartOptions"
@@ -36,6 +36,7 @@ import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import { useChartTooltip} from "@/Composables/useChartTooltip";
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -1,20 +1,18 @@
<template>
<div class="chart-wrap">
<div class="chart-title">Flight classes</div>
<apexchart
<FlightChart
title="Flight Classes"
v-if="series.length"
type="donut"
height="280"
:options="chartOptions"
:series="seriesData"
/>
<div v-else class="chart-empty">No flight class data available</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -87,26 +85,5 @@ const chartOptions = computed(() => ({
</script>
<style scoped>
.chart-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-title {
font-size: 13px;
font-weight: 500;
color: #556677;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chart-empty {
height: 280px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #445566;
}
</style>
@@ -1,20 +1,18 @@
<template>
<div class="chart-wrap">
<div class="chart-title">Flight reasons</div>
<apexchart
<FlightChart
title="Flight Reasons"
v-if="series.length"
type="donut"
height="280"
:options="chartOptions"
:series="seriesData"
/>
<div v-else class="chart-empty">No flight reason data available</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -1,20 +1,18 @@
<template>
<div class="chart-wrap">
<div class="chart-title">International vs Domestic</div>
<apexchart
<FlightChart
title="International vs Domestic"
v-if="series.length"
type="donut"
height="280"
:options="chartOptions"
:series="seriesData"
/>
<div v-else class="chart-empty">No flight data available</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -90,37 +88,4 @@ const chartOptions = computed(() => ({
</script>
<style scoped>
.chart-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-title {
font-size: 13px;
font-weight: 500;
color: #556677;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chart-empty {
height: 280px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #445566;
}
.charts-row {
display: flex;
gap: 24px;
padding: 16px;
}
.charts-row > * {
flex: 1;
min-width: 0;
}
</style>
@@ -1,18 +1,17 @@
<template>
<div class="chart-wrap">
<div class="chart-title">Flights per day of week</div>
<apexchart
<FlightChart
title="Flights per day of week"
type="bar"
height="220"
:options="chartOptions"
:series="series"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -97,17 +96,4 @@ const chartOptions = computed(() => ({
</script>
<style scoped>
.chart-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-title {
font-size: 13px;
font-weight: 500;
color: #556677;
text-transform: uppercase;
letter-spacing: 0.08em;
}
</style>
@@ -1,18 +1,17 @@
<template>
<div class="chart-wrap">
<div class="chart-title">Flights per month</div>
<apexchart
type="bar"
height="220"
:options="chartOptions"
:series="series"
/>
</div>
<FlightChart
title="Flights per month"
type="bar"
height="220"
:options="chartOptions"
:series="series"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -93,17 +92,4 @@ const chartOptions = computed(() => ({
</script>
<style scoped>
.chart-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-title {
font-size: 13px;
font-weight: 500;
color: #556677;
text-transform: uppercase;
letter-spacing: 0.08em;
}
</style>
@@ -1,18 +1,17 @@
<template>
<div class="chart-wrap">
<div class="chart-title">Flights per year</div>
<apexchart
<FlightChart
title="Flights per year"
type="bar"
height="220"
:options="chartOptions"
:series="series"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -107,17 +106,5 @@ const chartOptions = computed(() => ({
</script>
<style scoped>
.chart-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-title {
font-size: 13px;
font-weight: 500;
color: #556677;
text-transform: uppercase;
letter-spacing: 0.08em;
}
</style>
@@ -1,20 +1,18 @@
<template>
<div class="chart-wrap">
<div class="chart-title">Seat types</div>
<apexchart
v-if="series.length"
type="donut"
height="280"
:options="chartOptions"
:series="seriesData"
/>
<div v-else class="chart-empty">No seat type data available</div>
</div>
<FlightChart
title="Seat Types"
v-if="series.length"
type="donut"
height="280"
:options="chartOptions"
:series="seriesData"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Flight } from '@/Types/types'
import FlightChart from "@/Components/FlightsGoneBy/FlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -87,26 +85,4 @@ const chartOptions = computed(() => ({
</script>
<style scoped>
.chart-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-title {
font-size: 13px;
font-weight: 500;
color: #556677;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chart-empty {
height: 280px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #445566;
}
</style>
@@ -4,7 +4,7 @@
<div v-if="series.length" class="chart-outer">
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<apexchart
<UnstyledFlightChart
type="bar"
:height="chartHeight"
:options="chartOptions"
@@ -34,6 +34,7 @@ import type {Flight, SharedProps} from '@/Types/types'
import { useChartTooltip } from '@/Composables/useChartTooltip'
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
import {usePage} from "@inertiajs/vue3";
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
const page = usePage<SharedProps>().props
@@ -4,7 +4,7 @@
<div v-if="series.length" class="chart-outer">
<div class="chart-scroll" :style="{ height: scrollHeight }" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<apexchart
<UnstyledFlightChart
type="bar"
:height="chartHeight"
:options="chartOptions"
@@ -44,6 +44,7 @@ import { computed } from 'vue'
import type { Airport, Flight } from '@/Types/types'
import { useChartTooltip } from '@/Composables/useChartTooltip'
import ChartTooltip from '@/Components/FlightsGoneBy/Charts/ChartTooltip.vue'
import UnstyledFlightChart from "@/Components/FlightsGoneBy/UnstyledFlightChart.vue";
const props = defineProps<{
flights: Flight[]
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
import VueApexCharts from 'vue3-apexcharts'
import {ChartType} from "@/Types/types";
defineProps<{
type: ChartType
title: string
height: number | string
options: object
series: unknown[]
}>()
const ready = ref(false)
</script>
<template>
<div class="chart-wrap">
<div class="chart-title">{{title}}</div>
<PlaneLoader v-if="!ready" />
<VueApexCharts
:type="type"
:height="height"
:options="options"
:series="series"
:class="{ 'chart-hidden': !ready }"
@mounted="ready = true"
/>
</div>
</template>
<style scoped>
.chart-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-title {
font-size: 13px;
font-weight: 500;
color: #556677;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chart-empty {
height: 280px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #445566;
}
</style>
@@ -1,170 +1,45 @@
<template>
<FlightMap :page="page.props" :flights="mappedFlights" class="profile-map__map" />
<div class="profile-map__toolbar">
<v-select
v-model="selectedYears"
:items="availableYears"
label="Year"
multiple
clearable
hide-details
density="compact"
variant="outlined"
>
<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>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedYears.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedAirlines"
:items="availableAirlines"
item-title="name"
item-value="id"
label="Airline"
multiple
clearable
hide-details
density="compact"
variant="outlined"
>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend="{ isSelected }">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
</template>
<template #title>
<img
:src="airlineLogoUrl((item as any).id)"
width="32"
height="32"
style="object-fit: contain; margin-right: 8px; vertical-align: middle;"
alt=""
/>
{{ (item as any).name }}
</template>
</v-list-item>
</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>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirlines.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedCountries"
:items="availableCountries"
item-title="name"
item-value="code"
label="Country"
multiple
clearable
hide-details
density="compact"
variant="outlined"
>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend="{ isSelected }">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
</template>
<template #title>
<span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" />
{{ (item as any).name }}
</template>
</v-list-item>
</template>
<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>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCountries.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedContinents"
:items="availableContinents"
item-title="name"
item-value="code"
label="Continent"
multiple
clearable
hide-details
density="compact"
variant="outlined"
>
<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>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedContinents.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedFlightClasses"
:items="availableFlightClasses"
item-title="name"
item-value="id"
label="Flight class"
multiple
clearable
hide-details
density="compact"
variant="outlined"
>
<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>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span>
</template>
</v-select>
</div>
<FlightStatsBar :flights="pastFlights" :upcoming-flights="upcomingFlights" />
<FlightCharts :flights="pastFlights" :upcoming-flights="upcomingFlights" />
</template>
<script setup lang="ts">
import type { Flight } from '@/Types/types'
import { computed, ref } from 'vue'
import { usePage } from '@inertiajs/vue3'
import FlightMap from '@/Components/FlightsGoneBy/FlightMap.vue'
import type { Flight, SharedProps } from '@/Types/types'
import FlightStatsBar from '@/Components/FlightsGoneBy/FlightStatsBar.vue'
import FlightCharts from '@/Components/FlightsGoneBy/FlightCharts.vue'
const props = defineProps<{
flights: Flight[]
}>()
const page = usePage<SharedProps>()
const now = new Date()
const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([])
const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([])
// Helpers
const emit = defineEmits<{
change: [filters: {
years: number[]
airlines: number[]
countries: string[]
continents: string[]
flightClasses: number[]
}]
}>()
function emitFilters() {
emit('change', {
years: selectedYears.value,
airlines: selectedAirlines.value,
countries: selectedCountries.value,
continents: selectedContinents.value,
flightClasses: selectedFlightClasses.value,
})
}
const page = usePage()
const airlineLogoUrl = (id: number) =>
`${page.props.logo_api_url}/airlines/logos/tail/id/${id}`
const countryFlagClass = (code: string) =>
`fi fi-${code.toLowerCase()}`
// Available filter options
const availableYears = computed(() => {
const years = new Set<number>()
props.flights.forEach(f => years.add(new Date(f.departure_date).getFullYear()))
@@ -174,9 +49,8 @@ const availableYears = computed(() => {
const availableAirlines = computed((): { id: number; name: string }[] => {
const map = new Map<number, { id: number; name: string }>()
props.flights.forEach(f => {
if (f.airline?.id && f.airline?.name) {
if (f.airline?.id && f.airline?.name)
map.set(f.airline.id, { id: f.airline.id, name: f.airline.name })
}
})
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
})
@@ -206,63 +80,138 @@ const availableContinents = computed((): { code: string; name: string }[] => {
const availableFlightClasses = computed((): { id: number; name: string }[] => {
const map = new Map<number, { id: number; name: string }>()
props.flights.forEach(f => {
if (f.flight_class?.id && f.flight_class?.name) {
if (f.flight_class?.id && f.flight_class?.name)
map.set(f.flight_class.id, { id: f.flight_class.id, name: f.flight_class.name })
}
})
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
})
// Filter helper
function matchesFilters(f: Flight, date: Date): boolean {
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
if (selectedAirlines.value.length && !selectedAirlines.value.includes(f.airline?.id ?? -1)) return false
if (selectedCountries.value.length) {
const depCode = f.departure_airport.region?.country?.code
const arrCode = f.arrival_airport.region?.country?.code
if (!selectedCountries.value.includes(depCode ?? '') && !selectedCountries.value.includes(arrCode ?? '')) return false
}
if (selectedContinents.value.length) {
const depCode = f.departure_airport.region?.continent?.code
const arrCode = f.arrival_airport.region?.continent?.code
if (!selectedContinents.value.includes(depCode ?? '') && !selectedContinents.value.includes(arrCode ?? '')) return false
}
if (selectedFlightClasses.value.length && !selectedFlightClasses.value.includes(f.flight_class?.id ?? -1)) return false
return true
}
// Filtered flights
const pastFlights = computed(() =>
props.flights.filter(f => {
const date = new Date(f.departure_date)
return date <= now && matchesFilters(f, date)
})
)
const upcomingFlights = computed(() =>
props.flights.filter(f => {
const date = new Date(f.departure_date)
return date > now && matchesFilters(f, date)
})
)
const mappedFlights = computed(() => [
...pastFlights.value,
...upcomingFlights.value,
])
</script>
<template>
<div class="flight-filters">
<v-select
v-model="selectedYears"
:items="availableYears"
label="Year"
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(selectedYears.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedYears.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedAirlines"
:items="availableAirlines"
item-title="name" item-value="id"
label="Airline"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend="{ isSelected }">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
</template>
<template #title>
<img
:src="airlineLogoUrl((item as any).id)"
width="32" height="32"
style="object-fit: contain; margin-right: 8px; vertical-align: middle;"
alt=""
/>
{{ (item as any).name }}
</template>
</v-list-item>
</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>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirlines.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedCountries"
:items="availableCountries"
item-title="name" item-value="code"
label="Country"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend="{ isSelected }">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
</template>
<template #title>
<span :class="countryFlagClass((item as any).code)" style="margin-right: 8px; font-size: 1.1em;" />
{{ (item as any).name }}
</template>
</v-list-item>
</template>
<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>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCountries.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedContinents"
:items="availableContinents"
item-title="name" item-value="code"
label="Continent"
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(selectedContinents.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedContinents.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedFlightClasses"
:items="availableFlightClasses"
item-title="name" item-value="id"
label="Flight class"
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(selectedFlightClasses.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightClasses.length - 2 }}</span>
</template>
</v-select>
</div>
</template>
<style scoped>
.profile-map__toolbar {
.flight-filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 12px;
}
.profile-map__toolbar .v-select {
.flight-filters .v-select {
flex: 1 1 160px;
}
</style>
@@ -1,6 +1,7 @@
<template>
<div class="flight-map-wrapper">
<div ref="mapContainer" class="map-container" />
<PlaneLoader v-if="!mapReady" class="map-loader" />
<div ref="mapContainer" class="map-container" :class="{ 'map-hidden': !mapReady }" />
<div v-if="!flights.length" class="empty-state">
<span class="mdi mdi-earth-off" />
<p>No flight data available</p>
@@ -15,6 +16,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import { SharedProps } from '@/Types/types'
import { usePage } from '@inertiajs/vue3'
import { Flight, Airport } from '@/Types/types'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
interface RouteFlightBucket {
historical: Flight[]
@@ -39,8 +41,12 @@ interface RoutesGeoJSON {
future: GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProperties>
}
const logoApiUrl = usePage<SharedProps>().props.logo_api_url
function greatCircleGeoJSON(from: LngLat, to: LngLat, steps?: number): LngLat[] {
const dist = Math.sqrt((to[0] - from[0]) ** 2 + (to[1] - from[1]) ** 2)
const s = steps ?? (dist > 60 ? 64 : dist > 20 ? 32 : 16)
function greatCircleGeoJSON(from: LngLat, to: LngLat, steps = 64): LngLat[] {
const toRad = (d: number): number => d * Math.PI / 180
const toDeg = (r: number): number => r * 180 / Math.PI
const lat1 = toRad(from[1]), lng1 = toRad(from[0])
@@ -51,8 +57,8 @@ function greatCircleGeoJSON(from: LngLat, to: LngLat, steps = 64): LngLat[] {
))
if (d === 0) return [[from[0], from[1]], [to[0], to[1]]]
const points: LngLat[] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
for (let i = 0; i <= s; i++) {
const f = i / s
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
@@ -96,7 +102,6 @@ function airportPopupHTML(airport: Airport): string {
}
function routePopupHTML(historical: Flight[], future: Flight[]): string {
const logoApiUrl = usePage<SharedProps>().props.logo_api_url
interface DirectionGroup {
label: string
airlines: string[]
@@ -140,6 +145,7 @@ function routePopupHTML(historical: Flight[], future: Flight[]): string {
export default defineComponent({
name: 'FlightMap',
components: {PlaneLoader},
props: {
flights: {
@@ -158,6 +164,7 @@ export default defineComponent({
const TOUCH_RADIUS = 20
const airportById = new Map<number, Airport>()
const routeFlights = new Map<string, RouteFlightBucket>()
const arcCache = new Map<string, LngLat[]>()
let selectedAirportId: number | null = null
const isTouchDevice = (): boolean => window.matchMedia('(pointer: coarse)').matches
@@ -170,22 +177,23 @@ export default defineComponent({
const applyFilter = (): void => {
if (!map || !map.isStyleLoaded()) return
const id = selectedAirportId
const filter: maplibregl.FilterSpecification | null = id
const routeFilter: maplibregl.FilterSpecification | null = id
? ['any', ['==', ['get', 'depId'], id], ['==', ['get', 'arrId'], id]]
: null
map.setFilter('routes-line', filter)
map.setFilter('routes-hit', filter)
map.setFilter('routes-future-line', filter)
map.setFilter('routes-future-hit', filter)
map.setFilter('routes-line', routeFilter)
map.setFilter('routes-hit', routeFilter)
map.setFilter('routes-future-line', routeFilter)
map.setFilter('routes-future-hit', routeFilter)
// Use setFilter on airport layers — GPU-side, no style recompilation
for (let i = 0; i < PULSE_PHASES; i++) {
map.setPaintProperty(`airports-dot-${i}`, 'circle-opacity',
id ? ['case', ['==', ['get', 'id'], id], 1, 0.25] : 1)
map.setPaintProperty(`airports-dot-${i}`, 'circle-stroke-opacity',
id ? ['case', ['==', ['get', 'id'], id], 1, 0.25] : 1)
map.setPaintProperty(`airports-pulse-${i}`, 'circle-stroke-opacity',
id ? ['case', ['==', ['get', 'id'], id], 0.6, 0] : 0.6)
const airportFilter: maplibregl.FilterSpecification | null = id
? ['==', ['get', 'id'], id]
: null
map.setFilter(`airports-dot-${i}`, airportFilter)
map.setFilter(`airports-pulse-${i}`, airportFilter)
}
}
@@ -200,6 +208,18 @@ export default defineComponent({
})
}
// ── Arc cache helper ──────────────────────────────────────────────────
const getArc = (flight: Flight): LngLat[] => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
if (!arcCache.has(key)) {
arcCache.set(key, greatCircleGeoJSON(
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
))
}
return arcCache.get(key)!
}
// ── GeoJSON builders ──────────────────────────────────────────────────
const buildRoutesGeoJSON = (): RoutesGeoJSON => {
buildRouteFlights()
@@ -219,51 +239,45 @@ export default defineComponent({
return '#a150d5'
}
const historicalFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] =
props.flights
.filter(f => new Date(f.departure_date) <= now)
.map((flight) => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
const count = routeCounts.get(key) ?? 1
return {
type: 'Feature',
properties: {
color: routeColor(count), routeKey: key,
depId: flight.departure_airport.id,
arrId: flight.arrival_airport.id,
},
geometry: {
type: 'LineString',
coordinates: greatCircleGeoJSON(
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
),
},
}
const seenHistorical = new Set<string>()
const historicalFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] = []
props.flights
.filter(f => new Date(f.departure_date) <= now)
.forEach((flight) => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
if (seenHistorical.has(key)) return
seenHistorical.add(key)
const count = routeCounts.get(key) ?? 1
historicalFeatures.push({
type: 'Feature',
properties: {
color: routeColor(count), routeKey: key,
depId: flight.departure_airport.id,
arrId: flight.arrival_airport.id,
},
geometry: { type: 'LineString', coordinates: getArc(flight) },
})
})
const historicalKeys = new Set(routeCounts.keys())
const futureFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] =
props.flights
.filter((flight) => {
if (new Date(flight.departure_date) <= now) return false
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
return !historicalKeys.has(key)
})
.map((flight) => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
return {
type: 'Feature',
properties: { routeKey: key, depId: flight.departure_airport.id, arrId: flight.arrival_airport.id },
geometry: {
type: 'LineString',
coordinates: greatCircleGeoJSON(
[flight.departure_airport.longitude_deg, flight.departure_airport.latitude_deg],
[flight.arrival_airport.longitude_deg, flight.arrival_airport.latitude_deg],
),
},
}
const seenFuture = new Set<string>()
const futureFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProperties>[] = []
props.flights
.filter((flight) => {
if (new Date(flight.departure_date) <= now) return false
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
return !historicalKeys.has(key)
})
.forEach((flight) => {
const key = [flight.departure_airport.id, flight.arrival_airport.id].sort().join('-')
if (seenFuture.has(key)) return
seenFuture.add(key)
futureFeatures.push({
type: 'Feature',
properties: { routeKey: key, depId: flight.departure_airport.id, arrId: flight.arrival_airport.id },
geometry: { type: 'LineString', coordinates: getArc(flight) },
})
})
return {
historical: { type: 'FeatureCollection', features: historicalFeatures },
@@ -384,9 +398,8 @@ export default defineComponent({
})
}
// ── Unified click handler — airports take priority over routes ────
// ── Unified click handler ─────────────────────────────────────────
map!.on('click', (e) => {
// Query airports with a larger bounding box on touch for easier tapping
const airportQuery = isTouch
? [[e.point.x - TOUCH_RADIUS, e.point.y - TOUCH_RADIUS], [e.point.x + TOUCH_RADIUS, e.point.y + TOUCH_RADIUS]] as [maplibregl.PointLike, maplibregl.PointLike]
: e.point as maplibregl.PointLike
@@ -408,7 +421,6 @@ export default defineComponent({
return
}
// Then check routes
const routeFeatures = map!.queryRenderedFeatures(e.point, {
layers: ['routes-hit', 'routes-future-hit'],
})
@@ -419,7 +431,6 @@ export default defineComponent({
return
}
// Empty map click — deselect everything
selectedAirportId = null
applyFilter()
popup!.remove()
@@ -443,12 +454,14 @@ export default defineComponent({
const fitBounds = (): void => {
if (!props.flights.length) return
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])
map!.fitBounds(
[[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]],
{ padding: 60, duration: 0 },
)
let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity
props.flights.forEach(f => {
minLng = Math.min(minLng, f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg)
maxLng = Math.max(maxLng, f.departure_airport.longitude_deg, f.arrival_airport.longitude_deg)
minLat = Math.min(minLat, f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg)
maxLat = Math.max(maxLat, f.departure_airport.latitude_deg, f.arrival_airport.latitude_deg)
})
map!.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, duration: 0 })
}
// ── Map init ──────────────────────────────────────────────────────────
@@ -479,13 +492,18 @@ export default defineComponent({
renderWorldCopies: true,
})
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
map.on('load', () => { addLayers(); fitBounds() })
map.on('load', () => {
addLayers()
fitBounds()
mapReady.value = true // ← add this
})
})
}
// ── Data updates ──────────────────────────────────────────────────────
const updateData = (): void => {
if (!map || !map.isStyleLoaded()) return
arcCache.clear()
airportById.clear()
props.flights.forEach(({ departure_airport: dep, arrival_airport: arr }) => {
airportById.set(dep.id, dep)
@@ -508,14 +526,16 @@ export default defineComponent({
initMap()
})
watch(() => props.flights, updateData, { deep: true })
watch(() => props.flights, updateData)
onBeforeUnmount(() => {
if (pulseFrame !== null) cancelAnimationFrame(pulseFrame)
if (map) { map.remove(); map = null }
})
return { mapContainer }
const mapReady = ref(false)
return { mapContainer, mapReady }
},
})
</script>
@@ -525,11 +545,22 @@ export default defineComponent({
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
display: flex;
align-items: center;
justify-content: center;
}
.map-container {
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
.map-loader {
z-index: 10;
}
.map-hidden {
visibility: hidden;
}
.empty-state {
@@ -547,6 +578,8 @@ export default defineComponent({
.empty-state .mdi { font-size: 48px; }
.empty-state p { font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; margin: 0; }
</style>
<style>
@@ -610,4 +643,7 @@ export default defineComponent({
.maplibregl-ctrl-group button:hover { background: rgba(77,166,255,0.15) !important; color: #4da6ff !important; }
.maplibregl-ctrl-attrib { background: rgba(10,14,22,0.7) !important; color: #666 !important; }
.maplibregl-ctrl-attrib a { color: #666 !important; }
</style>
@@ -0,0 +1,85 @@
<template>
<FlightMap :page="page.props" :flights="mappedFlights" class="profile-map__map" />
<FlightFillter :flights="flights" @change="onFiltersChange" />
<FlightStatsBar :flights="pastFlights" :upcoming-flights="upcomingFlights" />
<FlightCharts :flights="pastFlights" :upcoming-flights="upcomingFlights" />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { usePage } from '@inertiajs/vue3'
import type { Flight, SharedProps } from '@/Types/types'
import FlightStatsBar from '@/Components/FlightsGoneBy/FlightStatsBar.vue'
import FlightFillter from "@/Components/FlightsGoneBy/FlightFillter.vue";
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue";
const props = defineProps<{
flights: Flight[]
}>()
const page = usePage<SharedProps>()
const now = new Date()
const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([])
const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([])
function onFiltersChange(filters: {
years: number[]
airlines: number[]
countries: string[]
continents: string[]
flightClasses: number[]
}) {
selectedYears.value = filters.years
selectedAirlines.value = filters.airlines
selectedCountries.value = filters.countries
selectedContinents.value = filters.continents
selectedFlightClasses.value = filters.flightClasses
}
function matchesFilters(f: Flight, date: Date): boolean {
if (selectedYears.value.length && !selectedYears.value.includes(date.getFullYear())) return false
if (selectedAirlines.value.length && !selectedAirlines.value.includes(f.airline?.id ?? -1)) return false
if (selectedCountries.value.length) {
const depCode = f.departure_airport.region?.country?.code
const arrCode = f.arrival_airport.region?.country?.code
if (!selectedCountries.value.includes(depCode ?? '') && !selectedCountries.value.includes(arrCode ?? '')) return false
}
if (selectedContinents.value.length) {
const depCode = f.departure_airport.region?.continent?.code
const arrCode = f.arrival_airport.region?.continent?.code
if (!selectedContinents.value.includes(depCode ?? '') && !selectedContinents.value.includes(arrCode ?? '')) return false
}
if (selectedFlightClasses.value.length && !selectedFlightClasses.value.includes(f.flight_class?.id ?? -1)) return false
return true
}
// ── Filtered flights ──────────────────────────────────────────────────────────
const pastFlights = computed(() =>
props.flights.filter(f => {
const date = new Date(f.departure_date)
return date <= now && matchesFilters(f, date)
})
)
const upcomingFlights = computed(() =>
props.flights.filter(f => {
const date = new Date(f.departure_date)
return date > now && matchesFilters(f, date)
})
)
const mappedFlights = computed(() => [
...pastFlights.value,
...upcomingFlights.value,
])
</script>
<style scoped>
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
<circle class="ring-spin" cx="60" cy="60" r="54" fill="none" stroke="#ffc107" stroke-width="1" stroke-dasharray="4 6" opacity="0.6"/>
<!-- Static inner ring -->
<circle cx="60" cy="60" r="46" fill="none" stroke="#ffc107" stroke-width="0.5" opacity="0.3"/>
<!-- Pulsing dot markers on inner ring -->
<circle class="ring-pulse" cx="60" cy="14" r="2" fill="#ffc107"/>
<circle class="ring-pulse" cx="106" cy="60" r="2" fill="#ffc107" style="animation-delay: 0.5s"/>
<circle class="ring-pulse" cx="60" cy="106" r="2" fill="#ffc107" style="animation-delay: 1s"/>
<circle class="ring-pulse" cx="14" cy="60" r="2" fill="#ffc107" style="animation-delay: 1.5s"/>
<text x="60" y="63" text-anchor="middle" font-size="6" letter-spacing="2.5" fill="#ffc107" font-family="monospace" opacity="0.7">Loading</text>
</svg>
</template>
<style scoped>
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%,100% { opacity: 0.3; }
50% { opacity: 1; }
}
.ring-spin { animation: spin 8s linear infinite; transform-origin: 60px 60px; }
.ring-pulse { animation: pulse 2s ease-in-out infinite; }
</style>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import {Flight, User} from "@/Types/types";
defineProps<{
user: User
flights: Flight[]
}>()
</script>
<template>
<div class="board-header">
<div class="board-title-group">
<span class="board-eyebrow">FLIGHT HISTORY</span>
<h1 class="board-title">{{ user.name }}</h1>
</div>
<div class="board-count">
<span class="count-number">{{ flights.length }}</span>
<span class="count-label">FLIGHTS</span>
</div>
</div>
</template>
<style scoped>
/* ── Header ── */
.board-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(255,193,7,0.2);
padding-bottom: 1.25rem;
}
.board-eyebrow {
display: block;
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.2em;
color: #ffc107;
margin-bottom: 0.25rem;
}
.board-title {
font-family: 'Barlow Condensed', sans-serif;
font-size: 2.2rem;
font-weight: 700;
color: #f0f2f5;
letter-spacing: 0.04em;
line-height: 1;
margin: 0;
}
.board-count {
text-align: right;
line-height: 1;
}
.count-number {
display: block;
font-family: 'Share Tech Mono', monospace;
font-size: 2rem;
color: #ffc107;
}
.count-label {
font-family: 'Share Tech Mono', monospace;
font-size: 0.65rem;
letter-spacing: 0.18em;
color: #556;
}
</style>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import {Flight, User} from "@/Types/types";
import ProfileHeader from "@/Components/FlightsGoneBy/ProfileHeader.vue";
defineProps<{
user: User
flights: Flight[]
}>()
</script>
<template>
<div class="board-wrapper">
<ProfileHeader :user="user" :flights="flights" />
<slot />
</div>
</template>
<style scoped>
.board-wrapper {
min-height: 100dvh;
padding: 2.5rem 2rem;
font-family: 'Barlow', sans-serif;
width: 100%;
}
</style>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import {ProfileView} from "@/Types/types";
defineProps<{
activeView: string;
}>()
const emit = defineEmits<{
'update:activeView': [view: ProfileView]
}>()
</script>
<template>
<div class="view-toolbar">
<button
class="view-btn"
:class="{ active: activeView === 'map' }"
@click="emit('update:activeView', 'map')"
>
<span class="view-btn-icon mdi mdi-earth"></span>
<span class="view-btn-label">MAP</span>
</button>
<button
class="view-btn"
:class="{ active: activeView === 'board' }"
@click="emit('update:activeView', 'board')"
>
<span class="view-btn-icon mdi mdi-table"></span>
<span class="view-btn-label">DEPARTURE BOARD</span>
</button>
<button
class="view-btn"
:class="{ active: activeView === 'passes' }"
@click="emit('update:activeView', 'passes')"
>
<span class="view-btn-icon mdi mdi-ticket"></span>
<span class="view-btn-label">BOARDING PASSES</span>
</button>
</div>
</template>
<style scoped>
/* ── View toolbar ── */
.view-toolbar {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border: 1px solid rgba(255,193,7,0.15);
border-radius: 3px;
width: fit-content;
overflow: hidden;
}
.view-btn {
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
background: transparent;
border: none;
border-right: 1px solid rgba(255,193,7,0.15);
color: #556;
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.15em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.view-btn:last-child {
border-right: none;
}
.view-btn:hover {
background: rgba(255,193,7,0.05);
color: #9aa;
}
.view-btn.active {
background: rgba(255,193,7,0.08);
color: #ffc107;
}
.view-btn-icon {
font-size: 0.8rem;
opacity: 0.8;
}
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { ref } from 'vue'
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
import {ChartType} from "@/Types/types";
import VueApexCharts from 'vue3-apexcharts'
defineProps<{
type: ChartType
height: number | string
options: object
series: unknown[]
}>()
const ready = ref(false)
</script>
<template>
<PlaneLoader v-if="!ready" />
<VueApexCharts
:type="type"
:height="height"
:options="options"
:series="series"
:class="{ 'chart-hidden': !ready }"
@mounted="ready = true"
/>
</template>
<style scoped>
</style>
-181
View File
@@ -1,181 +0,0 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3';
import { Flight } from "@/Types/types";
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue";
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
import { ref } from "vue";
import ProfileMap from "@/Components/FlightsGoneBy/ProfileMap.vue";
defineOptions({
layout: MainLayout
})
defineProps<{
user: {
id: number
name: string
email: string
}
flights: Flight[]
canEdit: boolean
}>()
type View = 'board' | 'passes' | 'map'
const activeView = ref<View>('map')
</script>
<template>
<Head :title="`${user.name}'s Flights`" />
<div class="board-wrapper">
<div class="board-header">
<div class="board-title-group">
<span class="board-eyebrow">FLIGHT HISTORY</span>
<h1 class="board-title">{{ user.name }}</h1>
</div>
<div class="board-count">
<span class="count-number">{{ flights.length }}</span>
<span class="count-label">FLIGHTS</span>
</div>
</div>
<!-- View switcher toolbar -->
<div class="view-toolbar">
<button
class="view-btn"
:class="{ active: activeView === 'map' }"
@click="activeView = 'map'"
>
<span class="view-btn-icon mdi mdi-earth"></span>
<span class="view-btn-label">Map</span>
</button>
<button
class="view-btn"
:class="{ active: activeView === 'board' }"
@click="activeView = 'board'"
>
<span class="view-btn-icon mdi mdi-table"></span>
<span class="view-btn-label">DEPARTURE BOARD</span>
</button>
<button
class="view-btn"
:class="{ active: activeView === 'passes' }"
@click="activeView = 'passes'"
>
<span class="view-btn-icon mdi mdi-ticket"></span>
<span class="view-btn-label">BOARDING PASSES</span>
</button>
</div>
<DepartureBoard v-if="activeView === 'board'" :flights="flights" :canEdit="canEdit" />
<BoardingPasses v-else-if="activeView === 'passes'" :flights="flights" />
<ProfileMap v-else-if="activeView === 'map'" :flights="flights" />
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow:wght@400;500;600&family=Barlow+Condensed:wght@500;700&display=swap');
/* ── Wrapper ── */
.board-wrapper {
min-height: 100dvh;
padding: 2.5rem 2rem;
font-family: 'Barlow', sans-serif;
width: 100%;
}
/* ── Header ── */
.board-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 2rem;
border-bottom: 1px solid rgba(255,193,7,0.2);
padding-bottom: 1.25rem;
}
.board-eyebrow {
display: block;
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.2em;
color: #ffc107;
margin-bottom: 0.25rem;
}
.board-title {
font-family: 'Barlow Condensed', sans-serif;
font-size: 2.2rem;
font-weight: 700;
color: #f0f2f5;
letter-spacing: 0.04em;
line-height: 1;
margin: 0;
}
.board-count {
text-align: right;
line-height: 1;
}
.count-number {
display: block;
font-family: 'Share Tech Mono', monospace;
font-size: 2rem;
color: #ffc107;
}
.count-label {
font-family: 'Share Tech Mono', monospace;
font-size: 0.65rem;
letter-spacing: 0.18em;
color: #556;
}
/* ── View toolbar ── */
.view-toolbar {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border: 1px solid rgba(255,193,7,0.15);
border-radius: 3px;
width: fit-content;
overflow: hidden;
}
.view-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
background: transparent;
border: none;
border-right: 1px solid rgba(255,193,7,0.15);
color: #556;
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.15em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.view-btn:last-child {
border-right: none;
}
.view-btn:hover {
background: rgba(255,193,7,0.05);
color: #9aa;
}
.view-btn.active {
background: rgba(255,193,7,0.08);
color: #ffc107;
}
.view-btn-icon {
font-size: 0.8rem;
opacity: 0.8;
}
</style>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3';
import {Flight, User} from "@/Types/types";
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
defineOptions({
layout: MainLayout
})
defineProps<{
user: User
flights: Flight[]
canEdit: boolean
}>()
</script>
<template>
<Head :title="`${user.name}'s Flights`" />
<ProfileLayout :flights="flights" :user="user">
<ProfileViewSwitcher :user="user" active-view="passes" />
<BoardingPasses :flights="flights" :canEdit="canEdit" />
</ProfileLayout>
</template>
<style scoped>
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3';
import {Flight, User} from "@/Types/types";
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import FlightMapAndCharts from "@/Components/FlightsGoneBy/FlightMapAndCharts.vue";
defineOptions({
layout: MainLayout
})
defineProps<{
user: User
flights: Flight[]
canEdit: boolean
}>()
</script>
<template>
<Head :title="`${user.name}'s Flights`" />
<ProfileLayout :flights="flights" :user="user">
<ProfileViewSwitcher active-view="map" :user="user" />
<FlightMapAndCharts :flights="flights" :canEdit="canEdit" />
</ProfileLayout>
</template>
<style scoped>
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3';
import { ref, defineAsyncComponent } from 'vue'
import {Flight, ProfileView, User} from "@/Types/types";
import { router } from '@inertiajs/vue3'
import ProfileViewSwitcher from "@/Components/FlightsGoneBy/ProfileViewSwitcher.vue";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue";
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
const FlightMapAndCharts = defineAsyncComponent(
() => import('@/Components/FlightsGoneBy/FlightMapAndCharts.vue')
)
const BoardingPasses = defineAsyncComponent(
() => import('@/Components/FlightsGoneBy/BoardingPasses.vue')
)
defineOptions({ layout: MainLayout })
const props = defineProps<{
user: User
flights: Flight[]
canEdit: boolean
initialView?: ProfileView
}>()
const activeView = ref<ProfileView>(props.initialView ?? 'board')
const routeNames = {
map: 'profile.map',
board: 'profile.departure-board',
passes: 'profile.boarding-passes',
} as const
function switchView(view: 'map' | 'board' | 'passes') {
activeView.value = view
window.history.replaceState(
window.history.state,
'',
route(routeNames[view], { username: props.user.name })
)
}
</script>
<template>
<Head :title="`${user.name}'s Flights`" />
<ProfileLayout :flights="flights" :user="user">
<ProfileViewSwitcher :active-view="activeView" @update:active-view="switchView" />
<DepartureBoard v-if="activeView === 'board'" :flights="flights" :canEdit="canEdit" />
<BoardingPasses v-else-if="activeView === 'passes'" :flights="flights" :canEdit="canEdit" />
<FlightMapAndCharts v-else-if="activeView === 'map'" :flights="flights" :canEdit="canEdit" />
</ProfileLayout>
</template>
+3
View File
@@ -8,6 +8,9 @@ declare module '@vue/runtime-core' {
}
}
export type ProfileView = 'map' | 'board' | 'passes';
export type ChartType = "line" | "area" | "bar" | "pie" | "donut" | "radialBar" | "scatter" | "bubble" | "heatmap" | "candlestick" | "boxPlot" | "radar" | "polarArea" | "rangeBar" | "rangeArea" | "treemap" | undefined
export interface User {
id: number
name: string