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
@@ -10,8 +10,7 @@ use Inertia\Inertia;
class FlightProfileController extends Controller
{
public function view(string $username)
{
public function profileData(string $username, string $view) : array {
$user = User::whereRaw(DB::raw('LOWER(name) = ?'), [strtolower($username)])->firstOrFail();
$flights = UserFlight::where('user_id', $user->id)
@@ -29,10 +28,31 @@ class FlightProfileController extends Controller
->orderBy('departure_date', 'desc')
->get();
return Inertia::render('FlightProfile', [
return [
'user' => $user,
'canEdit' => auth()->check() && auth()->id() === $user->id,
'flights' => UserFlightResource::collection($flights)->resolve(),
]);
'initialView' => $view,
];
}
public function departureBoard(string $username){
$profileData = $this->profileData($username, 'board');
return Inertia::render('UserProfile', $profileData);
}
public function map(string $username){
$profileData = $this->profileData($username, 'map');
return Inertia::render('UserProfile', $profileData);
}
public function boardingPasses(string $username){
$profileData = $this->profileData($username, 'passes');
return Inertia::render('UserProfile', $profileData);
}
public function view(string $username)
{
return $this->departureBoard($username);
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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
+3
View File
@@ -72,6 +72,9 @@ Route::domain(config('app.domain'))->group(
Route::get('/u/{username}', [FlightProfileController::class, 'view'])->name('profile.view');
Route::get('/u/{username}/map', [FlightProfileController::class, 'map'])->name('profile.map');
Route::get('/u/{username}/departure-board', [FlightProfileController::class, 'departureBoard'])->name('profile.departure-board');
Route::get('/u/{username}/boarding-passes', [FlightProfileController::class, 'boardingPasses'])->name('profile.boarding-passes');
require __DIR__.'/auth.php';