Corrected Korea

This commit is contained in:
2026-04-09 11:20:16 +10:00
parent 43f5c8ac3e
commit 7a07616f03
19 changed files with 1530 additions and 399 deletions
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\User; use App\Models\User;
use App\Models\UserFlight; use App\Models\UserFlight;
use App\Http\Resources\UserFlightResource;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
@@ -15,20 +16,20 @@ class FlightProfileController extends Controller
$flights = UserFlight::where('user_id', $user->id) $flights = UserFlight::where('user_id', $user->id)
->with([ ->with([
'departureAirport', 'departureAirport.region.country',
'arrivalAirport', 'arrivalAirport.region.country',
'airline.country', 'airline.country',
'aircraft', 'aircraft',
'seatType', 'seatType',
'flightReason', 'flightReason',
'flightClass', 'flightClass',
]) ])
->orderBy('departure_date') ->orderBy('departure_date', 'desc')
->get(); ->get();
return Inertia::render('FlightProfile', [ return Inertia::render('FlightProfile', [
'user' => $user, 'user' => $user,
'flights' => $flights, 'flights' => UserFlightResource::collection($flights)->resolve(),
]); ]);
} }
} }
+1 -1
View File
@@ -21,7 +21,7 @@ class LogoController extends Controller
]); ]);
} }
public function getLogoById(int $id){ public function getLogoById($id){
$airline = Airline::where('id', $id) $airline = Airline::where('id', $id)
->first(); ->first();
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Resources;
use App\Models\UserFlight;
use Carbon\Carbon;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin UserFlight */
class UserFlightResource extends JsonResource
{
public function toArray($request): array
{
$departureTz = $this->departureAirport->timezone;
$arrivalTz = $this->arrivalAirport->timezone;
$duration = $this->departure_date->diffInMinutes($this->arrival_date);
$hours = intdiv($duration, 60);
$minutes = $duration % 60;
$durationDisplay = $hours . 'h ' . str_pad($minutes, 2, '0', STR_PAD_LEFT) . 'm';
$departureLocal = $this->departure_date->copy()->setTimezone($departureTz);
$arrivalLocal = $this->arrival_date?->copy()->setTimezone($arrivalTz);
$distance = $this->calculateGreatCircleDistance();
$dayDifference = (int) abs(Carbon::parse($arrivalLocal->toDateString())
->diffInDays(Carbon::parse($departureLocal->toDateString())));
return [
...$this->resource->toArray(),
'departure_date_display' => $departureLocal->format('j M Y'),
'departure_time_display' => $departureLocal->format('g:iA'),
'arrival_date_display' => $arrivalLocal?->format('j M Y'),
'arrival_time_display' => $arrivalLocal?->format('g:iA'),
'arrival_day_difference' => $dayDifference,
'duration' => $duration,
'duration_display' => $durationDisplay,
'distance' => $distance,
];
}
}
+18
View File
@@ -31,6 +31,24 @@ class UserFlight extends Model
'arrival_date' => 'immutable_datetime', 'arrival_date' => 'immutable_datetime',
]; ];
public function calculateGreatCircleDistance(): float{
$earthRadiusKm = 6371;
[$depLat, $depLong] = [$this->departureAirport->latitude_deg, $this->departureAirport->longitude_deg];
[$arrLat, $arrLong] = [$this->arrivalAirport->latitude_deg, $this->arrivalAirport->longitude_deg];
$latDelta = deg2rad($arrLat - $depLat);
$longDelta = deg2rad($arrLong - $depLong);
$a = sin($latDelta / 2) * sin($latDelta / 2)
+ cos(deg2rad($depLat)) * cos(deg2rad($arrLat))
* sin($longDelta / 2) * sin($longDelta / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadiusKm * $c;
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
@@ -0,0 +1,30 @@
<?php
use App\Models\Airline;
use App\Models\Country;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$northKorea = Country::where('code', 'KP')->first();
Airline::where('internal_name', 'air-koryo')->update(['country_id' => $northKorea->id]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$southKorea = Country::where('code', 'KR')->first();
Airline::where('internal_name', 'air-koryo')->update(['country_id' => $southKorea->id]);
}
};
+97
View File
@@ -39,3 +39,100 @@ body {
radial-gradient(ellipse at 20% 50%, rgba(56, 189, 248, 0.06) 0%, transparent 60%), radial-gradient(ellipse at 20% 50%, rgba(56, 189, 248, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(14, 165, 233, 0.05) 0%, transparent 50%); radial-gradient(ellipse at 80% 20%, rgba(14, 165, 233, 0.05) 0%, transparent 50%);
} }
/*
* flight-classes
* Apply .class-{name}-global to any element to get the themed background, border, and colour.
*/
.class-economy-global {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #778;
}
.class-premium-global {
background: rgba(75, 32, 137, 0.35);
border: 1px solid rgba(180, 130, 255, 0.25);
color: #c49dff;
}
/* ── Business: Gold ── */
.class-business-global {
background: rgba(184, 134, 11, 0.15);
border: 1px solid rgba(255, 193, 7, 0.3);
color: #ffc107;
position: relative;
overflow: hidden;
}
/* ── First: Platinum ── */
.class-first-global {
background: linear-gradient(
135deg,
rgba(160, 175, 190, 0.15) 0%,
rgba(210, 225, 240, 0.22) 45%,
rgba(150, 168, 185, 0.12) 100%
);
border: 1px solid rgba(210, 225, 240, 0.45);
color: #ddeeff;
text-shadow: 0 0 10px rgba(200, 220, 245, 0.6);
position: relative;
overflow: hidden;
}
/* ── Private: Platinum (more intense) ── */
.class-private-global {
background: linear-gradient(
135deg,
rgba(160, 175, 190, 0.15) 0%,
rgba(210, 225, 240, 0.22) 45%,
rgba(150, 168, 185, 0.12) 100%
);
border: 1px solid rgba(210, 225, 240, 0.45);
color: #ddeeff;
text-shadow: 0 0 10px rgba(200, 220, 245, 0.6);
position: relative;
overflow: hidden;
}
.class-generic-global{
background: var(--accent-glow, rgba(255,193,7,0.1));
border: 1px solid var(--accent-soft, rgba(255,193,7,0.25));
color: var(--accent, #ffc107);
}
.class-private-global::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
105deg,
transparent 30%,
rgba(230, 242, 255, 0.6) 50%,
transparent 70%
);
background-size: 200% 100%;
animation: class-shimmer 1.8s ease-in-out infinite;
pointer-events: none;
}
.class-unspecified-global {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.06);
color: #445566;
}
@keyframes class-shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
.class-unspecified-global {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.06);
color: #445566;
}
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { Aircraft } from "@/Types/types";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
defineProps<{
aircraft: Aircraft
}>()
function formatWtc(wtc: string): string {
switch (wtc.toUpperCase()) {
case 'L': return 'Light'
case 'M': return 'Medium'
case 'H': return 'Heavy'
case 'J': return 'Super'
default: return wtc
}
}
function formatEngineType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
</script>
<template>
<GlassTooltip>
<template #activator="{ props: tooltipProps }">
<div style="cursor:pointer" v-bind="tooltipProps">
<slot />
</div>
</template>
<div class="tooltip-header">
<span class="designator">{{ aircraft.manufacturer_code }} {{ aircraft.model_full_name }}</span>
</div>
<div class="tooltip-name">
<InlineBadge variant="generic">
{{aircraft.designator }}
</InlineBadge>
<InlineBadge variant="generic">
{{ formatWtc(aircraft.wtc) }}
</InlineBadge>
</div>
<div class="tooltip-divider" />
<div class="tooltip-rows">
<div class="tooltip-row">
<span class="tooltip-label">Engines</span>
<span class="tooltip-value">{{ aircraft.engine_count }}x {{ formatEngineType(aircraft.engine_type) }}</span>
</div>
</div>
</GlassTooltip>
</template>
<style scoped>
.tooltip-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.designator {
color: #e8eaf0;
letter-spacing: 0.06em;
line-height: 1;
}
.badges {
display: flex;
gap: 0.3rem;
}
.tooltip-name {
display:flex;
gap: 4px;
margin-top: 0.2rem;
line-height: 1.3;
}
.tooltip-divider {
height: 1px;
background: var(--table-border, rgba(255,255,255,0.08));
}
.tooltip-rows {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.tooltip-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.tooltip-label {
font-family: 'Share Tech Mono', monospace;
font-size: 0.6rem;
letter-spacing: 0.15em;
color: var(--muted, #445566);
flex-shrink: 0;
}
.tooltip-value {
font-size: 0.78rem;
color: var(--text, #c8cdd8);
text-align: right;
}
</style>
@@ -2,6 +2,8 @@
import {Airline, SharedProps} from "@/Types/types"; import {Airline, SharedProps} from "@/Types/types";
import {computed} from "vue"; import {computed} from "vue";
import {usePage} from "@inertiajs/vue3"; import {usePage} from "@inertiajs/vue3";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
const props = defineProps<{ const props = defineProps<{
airline: Airline | null; airline: Airline | null;
@@ -22,44 +24,36 @@ const size = computed(() => props.size ? props.size + 'px' : '30px');
</script> </script>
<template> <template>
<v-tooltip location="top" :open-delay="200"> <GlassTooltip>
<template #activator="{ props: tooltipProps }"> <template #activator="{ props: tooltipProps }">
<span class="airline-logo" v-bind="tooltipProps"></span> <span class="airline-logo" v-bind="tooltipProps"></span>
</template> </template>
<template #default> <div v-if="airline" class="airline-tooltip-content">
<div v-if="airline" class="airline-tooltip glass glass-border"> <div class="tooltip-header">
<div class="tooltip-header"> <div class="logo-title">
<span class="airline-logo-tooltip" :style="logoStyle"></span> <span class="airline-logo-tooltip" :style="logoStyle"></span>
<span class="airline-name">{{ airline.name }}</span> <span class="airline-name">{{ airline.name }}</span>
<span v-if="airline.IATA_code || airline.ICAO_code" class="codes">
<span v-if="airline.IATA_code" class="code-badge">{{ airline.IATA_code }}</span>
<span v-if="airline.ICAO_code" class="code-badge">{{ airline.ICAO_code }}</span>
</span>
</div> </div>
<div class="tooltip-divider"></div> <div v-if="airline.IATA_code || airline.ICAO_code" class="codes">
<div class="tooltip-meta"> <InlineBadge v-if="airline.IATA_code" variant="generic">{{ airline.IATA_code }}</InlineBadge>
<span <InlineBadge v-if="airline.ICAO_code" variant="generic">{{ airline.ICAO_code }}</InlineBadge>
v-if="airline.country"
:class="`fi fi-${airline.country.code.toLowerCase()}`"
class="country-flag"
></span>
<span v-if="airline.country" class="country-name">{{ airline.country.name }}</span>
</div> </div>
</div> </div>
<span v-else class="airline-tooltip muted">Unknown airline</span> <div class="tooltip-divider"></div>
</template> <div class="tooltip-meta">
</v-tooltip> <span
v-if="airline.country"
:class="`fi fi-${airline.country.code.toLowerCase()}`"
class="country-flag"
></span>
<span v-if="airline.country" class="country-name">{{ airline.country.name }}</span>
</div>
</div>
</GlassTooltip>
</template> </template>
<style scoped> <style scoped>
:deep(.v-overlay__content) {
background: transparent !important;
box-shadow: none !important;
filter: none !important;
}
span.airline-logo { span.airline-logo {
width: v-bind(size); width: v-bind(size);
height: v-bind(size); height: v-bind(size);
@@ -69,28 +63,26 @@ span.airline-logo {
display: inline-block; display: inline-block;
} }
.airline-tooltip { .airline-tooltip-content {
background: var(--surface); display: contents;
border: 1px solid var(--table-border);
border-radius: 8px;
padding: 10px 14px;
min-width: 180px;
display: flex;
flex-direction: column;
gap: 8px;
color: var(--text);
font-size: 0.85rem;
} }
.tooltip-header { .logo-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.codes{
width:100%;
display: flex;
gap: 4px;
padding-top:1em;
justify-content:flex-start;
}
.airline-name { .airline-name {
font-weight: 600;
color: var(--muted);
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
@@ -121,18 +113,4 @@ span.airline-logo {
margin-left: auto; margin-left: auto;
} }
.code-badge {
background: var(--accent-glow);
border: 1px solid var(--accent-soft);
color: var(--accent);
border-radius: 4px;
padding: 1px 6px;
font-size: 0.72rem;
font-family: monospace;
letter-spacing: 0.05em;
}
.muted {
color: var(--muted);
}
</style> </style>
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { Airport } from "@/Types/types";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
defineProps<{
airport: Airport
}>()
function formatElevation(ft: number | null): string | null {
if (ft === null) return null
return `${ft.toLocaleString()} ft`
}
function formatType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
</script>
<template>
<GlassTooltip>
<template #activator="{ props: tooltipProps }">
<div v-bind="tooltipProps" style="cursor:pointer">
<slot />
</div>
</template>
<div class="airport-tooltip">
<!-- Header -->
<div class="tooltip-header">
<div class="tooltip-codes">
<div><span class="iata">{{ airport.name }}</span></div>
<div class="code-badges">
<InlineBadge v-if="airport.iata_code" variant="generic">{{ airport.iata_code }}</InlineBadge>
<InlineBadge v-if="airport.icao_code" variant="generic">{{ airport.icao_code }}</InlineBadge>
</div>
</div>
</div>
<div class="tooltip-divider" />
<!-- Details -->
<div class="tooltip-rows">
<div v-if="airport.municipality" class="tooltip-row">
<span class="tooltip-label">City</span>
<span class="tooltip-value">{{ airport.municipality }}</span>
</div>
<div v-if="airport.municipality" class="tooltip-row">
<span class="tooltip-label">Country</span>
<span class="tooltip-value" v-if="airport.region?.country">
<span>{{airport.region.country.name}}&nbsp;</span>
<span class="fi" :class="`fi-${airport.region.country.code.toLowerCase()}`"></span>
</span>
</div>
<div v-if="airport.elevation_ft !== null" class="tooltip-row">
<span class="tooltip-label">Elevation</span>
<span class="tooltip-value">{{ formatElevation(airport.elevation_ft) }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">Timezone</span>
<span class="tooltip-value tz">{{ airport.timezone }}</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">Coordinates</span>
<span class="tooltip-value coords">
{{ airport.latitude_deg.toFixed(4) }}, {{ airport.longitude_deg.toFixed(4) }}
</span>
</div>
</div>
</div>
</GlassTooltip>
</template>
<style scoped>
.airport-tooltip {
display: contents;
}
.fi{
padding:0.25em;
}
.tooltip-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.iata{
font-size:1.2em;
}
.tooltip-codes {
display: flex;
flex-direction: column;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.code-badges {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.tooltip-divider {
height: 1px;
background: var(--table-border, rgba(255,255,255,0.08));
}
.tooltip-rows {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.tooltip-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.tooltip-label {
font-family: 'Share Tech Mono', monospace;
font-size: 0.6rem;
letter-spacing: 0.15em;
color: var(--muted, #445566);
flex-shrink: 0;
}
.tooltip-value {
font-size: 0.78rem;
color: var(--text, #c8cdd8);
text-align: right;
}
.tz {
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.03em;
}
.coords {
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
color: var(--muted, #556677);
letter-spacing: 0.03em;
}
</style>
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { Flight } from "@/Types/types";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
defineProps<{
flight: Flight
}>()
function classKey(flight: Flight): string {
const n = flight.flight_class?.name?.toLowerCase() ?? ''
if (n.includes('private')) return 'private'
if (n.includes('first')) return 'first'
if (n.includes('business')) return 'business'
if (n.includes('premium')) return 'premium'
return 'economy'
}
</script>
<template>
<div class="boarding-pass">
<div class="pass-header" :class="`class-${classKey(flight)}-global`">
<span v-if="flight.flight_class?.name !== 'Unspecified'" class="pass-header-class">
{{ flight.flight_class?.name }}
</span>
</div>
<div class="pass-body">
<div class="pass-route">
<div class="pass-endpoint">
<AirportToolTip :airport="flight.departure_airport">
<div class="pass-iata">{{ flight.departure_airport.iata_code }}</div>
</AirportToolTip>
<div class="pass-endpoint-city">{{ flight.departure_airport.municipality }}</div>
<div class="pass-endpoint-date">{{ flight.departure_date_display }}</div>
<div class="pass-endpoint-time">{{ flight.departure_time_display }}</div>
</div>
<div class="pass-centre">
<div class="pass-plane-icon"></div>
<AirlineLogo :airline="flight.airline" size="44" class="pass-logo" />
<div class="pass-flight-number">{{ flight.flight_number }}</div>
<div class="pass-airline-name">{{ flight.airline?.name }}</div>
<AircraftToolTip v-if="flight.aircraft?.designator" :aircraft="flight.aircraft">
<div v-if="flight.aircraft?.designator" class="pass-aircraft">{{ flight.aircraft.manufacturer_code}} {{flight.aircraft.model_full_name}}</div>
</AircraftToolTip>
</div>
<div class="pass-endpoint pass-endpoint--right">
<AirportToolTip :airport="flight.arrival_airport">
<div class="pass-iata">{{ flight.arrival_airport.iata_code }}</div>
</AirportToolTip>
<div class="pass-endpoint-city">{{ flight.arrival_airport.municipality }}</div>
<div class="pass-endpoint-date">{{ flight.arrival_date_display ?? flight.departure_date_display }}</div>
<div class="pass-endpoint-time">
{{ flight.arrival_time_display }}
<span v-if="flight.arrival_day_difference" class="pass-daydiff">+{{ flight.arrival_day_difference }}</span>
</div>
</div>
</div>
<div v-if="flight.seat_number" class="pass-meta-row">
<span class="pass-meta-pill pass-meta-pill--seat">SEAT {{ flight.seat_number }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.boarding-pass {
background: #181b24;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.06);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.pass-header {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.5rem 0.85rem;
min-height: 2rem;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.pass-header-class {
font-family: 'Share Tech Mono', monospace;
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.pass-body {
padding: 1.25rem 1.25rem 1rem;
}
.pass-route {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 0.5rem;
}
.pass-endpoint {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.pass-endpoint--right {
text-align: right;
align-items: flex-end;
}
.pass-iata {
font-family: 'Barlow Condensed', sans-serif;
font-size: 3rem;
font-weight: 700;
color: #f0f2f5;
letter-spacing: 0.02em;
line-height: 1;
}
.pass-endpoint-city {
font-family: 'Barlow', sans-serif;
font-size: 0.8rem;
color: #778899;
font-weight: 500;
}
.pass-endpoint-date {
font-family: 'Share Tech Mono', monospace;
font-size: 0.7rem;
color: #445566;
letter-spacing: 0.04em;
}
.pass-endpoint-time {
font-family: 'Share Tech Mono', monospace;
font-size: 1rem;
color: #c8cdd8;
letter-spacing: 0.06em;
}
.pass-centre {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0 0.5rem;
}
.pass-plane-icon {
font-size: 0.9rem;
color: #445566;
margin-bottom: 0.1rem;
}
.pass-logo { opacity: 0.9; }
.pass-flight-number {
font-family: 'Share Tech Mono', monospace;
font-size: 0.85rem;
font-weight: 700;
color: #e8eaf0;
letter-spacing: 0.08em;
}
.pass-airline-name {
font-family: 'Barlow', sans-serif;
font-size: 0.72rem;
color: #778899;
text-align: center;
white-space: nowrap;
}
.pass-aircraft {
font-size: 0.65rem;
color: #445566;
letter-spacing: 0.06em;
white-space: nowrap
}
.pass-daydiff {
font-size: 0.65rem;
color: #ffc107;
margin-left: 0.15rem;
vertical-align: super;
}
.pass-meta-row {
display: flex;
gap: 0.4rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.pass-meta-pill {
font-family: 'Share Tech Mono', monospace;
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border-radius: 2px;
border: 1px solid rgba(255,193,7,0.25);
background: rgba(255,193,7,0.07);
color: #ffc107;
}
.pass-meta-pill--seat {
border-color: rgba(200,205,216,0.2);
background: rgba(200,205,216,0.06);
color: #c8cdd8;
}
</style>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed } from "vue";
import { Flight } from "@/Types/types";
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
const props = defineProps<{
flights: Flight[]
}>()
const today = new Date()
today.setHours(0, 0, 0, 0)
const upcomingFlights = computed(() =>
props.flights
.filter(f => new Date(f.departure_date) >= today)
.sort((a, b) => new Date(a.departure_date).getTime() - new Date(b.departure_date).getTime())
)
const departedFlights = computed(() =>
props.flights
.filter(f => new Date(f.departure_date) < today)
.sort((a, b) => new Date(b.departure_date).getTime() - new Date(a.departure_date).getTime())
)
const allFlights = computed(() => [...upcomingFlights.value, ...departedFlights.value])
function isFirstDeparted(flight: Flight, pageItems: Flight[]): boolean {
const firstDeparted = pageItems.find(f => departedFlights.value.includes(f))
return flight === firstDeparted
}
function isUpcoming(flight: Flight): boolean {
return upcomingFlights.value.includes(flight)
}
</script>
<template>
<v-data-iterator :items="allFlights" :items-per-page="25">
<template #default="{ items }">
<div class="passes-root">
<template v-if="items.some(i => isUpcoming(i.raw))">
<div class="section-header upcoming">
<span class="section-icon"></span>
<span class="section-label">UPCOMING FLIGHTS</span>
<span class="section-count">{{ upcomingFlights.length }} scheduled</span>
<div class="section-line" />
</div>
</template>
<div class="passes-grid">
<template v-for="{ raw: flight } in items" :key="flight.id">
<template v-if="isFirstDeparted(flight, items.map(i => i.raw))">
<div class="section-header departed grid-span-full">
<span class="section-icon"></span>
<span class="section-label">DEPARTED FLIGHTS</span>
<span class="section-count">{{ departedFlights.length }} logged</span>
<div class="section-line" />
</div>
</template>
<BoardingPass :flight="flight" />
</template>
</div>
<template v-if="!items.some(i => isUpcoming(i.raw)) && items[0]?.raw === departedFlights[0]">
<div class="section-header departed departed-only">
<span class="section-icon"></span>
<span class="section-label">DEPARTED FLIGHTS</span>
<span class="section-count">{{ departedFlights.length }} logged</span>
<div class="section-line" />
</div>
</template>
</div>
</template>
<template #footer="{ page, pageCount, prevPage, nextPage }">
<div class="iterator-footer">
<span class="footer-info">Page {{ page }} of {{ pageCount }}</span>
<div class="footer-nav">
<button class="nav-btn" :disabled="page === 1" @click="prevPage"></button>
<button class="nav-btn" :disabled="page === pageCount" @click="nextPage"></button>
</div>
</div>
</template>
</v-data-iterator>
</template>
<style scoped>
.passes-root {
display: flex;
flex-direction: column;
}
.departed-only {
order: -1;
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 0 0.75rem;
}
.section-icon { font-size: 0.7rem; flex-shrink: 0; }
.section-label {
font-family: 'Share Tech Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.22em;
font-weight: 400;
flex-shrink: 0;
text-transform: uppercase;
}
.section-count {
font-family: 'Share Tech Mono', monospace;
font-size: 0.65rem;
letter-spacing: 0.1em;
color: #445566;
flex-shrink: 0;
text-transform: uppercase;
}
.section-line { flex: 1; height: 1px; min-width: 2rem; }
.section-header.upcoming .section-icon,
.section-header.upcoming .section-label { color: #ffc107; }
.section-header.upcoming .section-line { background: linear-gradient(to right, rgba(255,193,7,0.4), transparent); }
.section-header.departed .section-icon,
.section-header.departed .section-label { color: #778899; }
.section-header.departed .section-line { background: linear-gradient(to right, rgba(119,136,153,0.35), transparent); }
.passes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 1.25rem;
padding-bottom: 1.5rem;
}
.grid-span-full {
grid-column: 1 / -1;
padding: 0.75rem 0 0;
}
.iterator-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
padding: 1rem 0 2rem;
border-top: 1px solid rgba(255,255,255,0.06);
}
.footer-info {
font-family: 'Share Tech Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.08em;
color: #445566;
}
.footer-nav {
display: flex;
gap: 0.25rem;
}
.nav-btn {
width: 2rem;
height: 2rem;
background: transparent;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 2px;
color: #778;
font-size: 1rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.nav-btn:hover:not(:disabled) {
background: rgba(255,193,7,0.05);
border-color: rgba(255,193,7,0.2);
color: #ffc107;
}
.nav-btn:disabled {
opacity: 0.25;
cursor: default;
}
</style>
@@ -0,0 +1,451 @@
<script setup lang="ts">
import FlightClassBadge from "@/Components/FlightsGoneBy/FlightClassBadge.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import {Flight} from "@/Types/types";
import { computed, ref } from "vue";
import type { DataTableSortItem } from 'vuetify';
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
const props = defineProps<{
flights: Flight[]
}>()
const headers = [
{ title: '', key: 'airline', sortable: true },
{ title: 'FLIGHT', key: 'flight_number', sortable: true },
{ title: 'FROM', key: 'departure_airport', sortable: true },
{ title: 'TO', key: 'arrival_airport', sortable: true },
{ title: 'DATE', key: 'departure_date', sortable: true },
{ title: 'DEPART', key: 'departure_time_display', sortable: false },
{ title: 'ARRIVE', key: 'arrival_time_display', sortable: false },
{ title: 'AIRCRAFT', key: 'aircraft.designator', sortable: true },
{ title: 'CLASS', key: 'flight_class', sortable: true },
]
const CLASS_ORDER: Record<string, number> = {
'Unspecified': 0,
'Economy': 1,
'Premium Economy': 2,
'Business': 3,
'First': 4,
'Private': 5
}
const customKeySort = {
flight_class: (a: Flight['flight_class'], b: Flight['flight_class']) => {
return (CLASS_ORDER[a?.name ?? ''] ?? -1) - (CLASS_ORDER[b?.name ?? ''] ?? -1)
},
airline: (a: Flight['airline'], b: Flight['airline']) => {
return (a?.IATA_code ?? '').localeCompare(b?.IATA_code ?? '')
}
}
// Track active sort state
const sortBy = ref<DataTableSortItem[]>([])
const today = new Date()
today.setHours(0, 0, 0, 0)
const isSorting = computed(() => sortBy.value.length > 0)
// Split flights into upcoming vs departed, sorted by date within each group
const upcomingFlights = computed(() =>
props.flights
.filter(f => new Date(f.departure_date) >= today)
.sort((a, b) => new Date(a.departure_date).getTime() - new Date(b.departure_date).getTime())
)
const departedFlights = computed(() =>
props.flights
.filter(f => new Date(f.departure_date) < today)
.sort((a, b) => new Date(b.departure_date).getTime() - new Date(a.departure_date).getTime())
)
// Flat ordered list with group markers injected — used only when not sorting
type GroupedFlight = Flight & { _group?: 'upcoming' | 'departed'; _groupHeader?: boolean; _groupLabel?: string }
const groupedItems = computed<GroupedFlight[]>(() => {
const result: GroupedFlight[] = []
if (upcomingFlights.value.length > 0) {
result.push({ _groupHeader: true, _groupLabel: 'UPCOMING FLIGHTS', _group: 'upcoming' } as GroupedFlight)
upcomingFlights.value.forEach(f => result.push({ ...f, _group: 'upcoming' }))
}
if (departedFlights.value.length > 0) {
result.push({ _groupHeader: true, _groupLabel: 'DEPARTED FLIGHTS', _group: 'departed' } as GroupedFlight)
departedFlights.value.forEach(f => result.push({ ...f, _group: 'departed' }))
}
return result
})
// When sorting, pass all flights flat; when not sorting, pass grouped (headers will be rendered via #item slot trick)
const tableItems = computed(() =>
isSorting.value ? props.flights : groupedItems.value
)
</script>
<template>
<v-data-table
:headers="headers"
:custom-key-sort="customKeySort"
:items="tableItems"
:items-per-page="25"
v-model:sort-by="sortBy"
class="departures-table"
hover
>
<!-- GROUP HEADER ROW (injected as a fake item) -->
<template #item="{ item, columns }">
<!-- Section divider row -->
<template v-if="(item as any)._groupHeader && !isSorting">
<tr class="section-header-row">
<td :colspan="columns.length" class="section-header-cell">
<div class="section-header-inner" :class="(item as any)._group">
<span class="section-header-icon">
{{ (item as any)._group === 'upcoming' ? '◈' : '◉' }}
</span>
<span class="section-header-label">{{ (item as any)._groupLabel }}</span>
<span class="section-header-count">
{{
(item as any)._group === 'upcoming'
? upcomingFlights.length
: departedFlights.length
}} {{ (item as any)._group === 'upcoming' ? 'scheduled' : 'logged' }}
</span>
<div class="section-header-line" />
</div>
</td>
</tr>
</template>
<!-- Normal data row -->
<template v-else-if="!(item as any)._groupHeader">
<tr
class="v-data-table__tr"
:class="(item as any)._group && !isSorting ? `group-row--${(item as any)._group}` : ''"
>
<!-- Airline logo -->
<td class="v-data-table__td airline-logo-cell">
<AirlineLogo size="32" :airline="(item as any).airline" class="airline-logo-img" />
</td>
<!-- Flight number -->
<td class="v-data-table__td flight-number-cell">
<div class="flight-cell">
<span class="flight-number">{{ (item as any).flight_number }}</span>
</div>
</td>
<!-- Departure airport -->
<td class="v-data-table__td">
<AirportToolTip :airport="(item as Flight).departure_airport">
<span class="iata">{{ (item as Flight).departure_airport.iata_code }}</span><br/>
</AirportToolTip>
<span class="city-name">{{ (item as Flight).departure_airport.municipality }}</span>
</td>
<!-- Arrival airport -->
<td class="v-data-table__td">
<AirportToolTip :airport="(item as Flight).arrival_airport">
<span class="iata">{{ (item as Flight).arrival_airport.iata_code }}</span><br/>
</AirportToolTip>
<span class="city-name">{{ (item as Flight).arrival_airport.municipality }}</span>
</td>
<!-- Departure date -->
<td class="v-data-table__td">
<span class="date-cell">{{ (item as Flight).departure_date_display }}</span>
</td>
<!-- Departure time -->
<td class="v-data-table__td">
<span class="time-cell">{{ (item as Flight).departure_time_display }}</span>
</td>
<!-- Arrival time -->
<td class="v-data-table__td">
<span class="time-cell">{{ (item as Flight).arrival_time_display }}</span>
<sup v-if="(item as Flight).arrival_day_difference" class="day-diff">
+{{ (item as Flight).arrival_day_difference }}
</sup>
</td>
<!-- Aircraft -->
<td class="v-data-table__td">
<AircraftToolTip v-if="(item as any).aircraft" :aircraft="(item as any).aircraft">
<span class="mono-tag">{{ (item as any).aircraft?.designator }}</span>
</AircraftToolTip>
</td>
<!-- Class -->
<td class="v-data-table__td ">
<span class="class-cell">
<FlightClassBadge :flight="(item as Flight)" />
<InlineBadge v-if="(item as Flight).seat_number" variant="economy">{{(item as Flight).seat_number}}</InlineBadge>
<InlineBadge v-if="(item as Flight).seat_type?.name && (item as Flight).seat_type?.name !== 'Unspecified'" variant="economy">{{(item as Flight).seat_type?.name}}</InlineBadge>
</span>
</td>
</tr>
</template>
</template>
<!-- Empty state -->
<template #no-data>
<div class="no-data">
<span>NO FLIGHTS ON RECORD</span>
</div>
</template>
</v-data-table>
</template>
<style scoped>
/* ── Vuetify table overrides ── */
.departures-table {
background: transparent !important;
color: #c8cdd8 !important;
font-family: 'Barlow', sans-serif !important;
}
/* Header row */
:deep(.v-data-table-header__content) {
font-family: 'Share Tech Mono', monospace !important;
font-size: 0.68rem !important;
letter-spacing: 0.14em !important;
color: #ffc107 !important;
font-weight: 400 !important;
}
:deep(.v-data-table__th) {
background: transparent !important;
border-bottom: 1px solid rgba(255,193,7,0.15) !important;
padding: 0.75rem 1rem !important;
}
/* Body rows — alternating, no borders */
:deep(.v-data-table__tr:nth-child(odd)) {
background: rgba(255,255,255,0.025) !important;
}
:deep(.v-data-table__tr:nth-child(even)) {
background: transparent !important;
}
:deep(.v-data-table__tr:hover td) {
background: rgba(255,193,7,0.05) !important;
}
:deep(.v-data-table__td) {
border: none !important;
padding: 0.7rem 1rem !important;
font-size: 0.88rem !important;
color: #c8cdd8 !important;
}
/* Footer / pagination */
:deep(.v-data-table-footer) {
background: transparent !important;
border-top: 1px solid rgba(255,255,255,0.06) !important;
color: #556 !important;
font-family: 'Share Tech Mono', monospace !important;
font-size: 0.75rem !important;
}
:deep(.v-data-table-footer .v-btn) {
color: #778 !important;
}
:deep(.v-data-table-footer .v-btn--active) {
color: #ffc107 !important;
}
/* Sort icon colour */
:deep(.v-data-table-header__sort-icon) {
color: #ffc107 !important;
opacity: 0.5;
}
/* ── Section header rows ── */
.section-header-row {
background: transparent !important;
}
.section-header-cell {
padding: 0 !important;
border: none !important;
}
.section-header-inner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 1rem 0.6rem;
position: relative;
}
/* Upcoming = amber accent */
.section-header-inner.upcoming .section-header-icon,
.section-header-inner.upcoming .section-header-label {
color: #ffc107;
}
.section-header-inner.upcoming .section-header-line {
background: linear-gradient(to right, rgba(255,193,7,0.4), transparent);
}
/* Departed = muted slate */
.section-header-inner.departed .section-header-icon,
.section-header-inner.departed .section-header-label {
color: #778899;
}
.section-header-inner.departed .section-header-line {
background: linear-gradient(to right, rgba(119,136,153,0.35), transparent);
}
.section-header-icon {
font-size: 0.7rem;
flex-shrink: 0;
}
.section-header-label {
font-family: 'Share Tech Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.22em;
font-weight: 400;
flex-shrink: 0;
text-transform: uppercase;
}
.section-header-count {
font-family: 'Share Tech Mono', monospace;
font-size: 0.65rem;
letter-spacing: 0.1em;
color: #445566;
flex-shrink: 0;
text-transform: uppercase;
}
.section-header-line {
flex: 1;
height: 1px;
min-width: 2rem;
}
/* ── Airline + flight number fused cells ── */
:deep(.v-data-table__th:nth-child(1)) {
width: 50px !important;
min-width: 50px !important;
max-width: 50px !important;
padding-right: 0 !important;
}
:deep(.v-data-table__th:nth-child(2)) {
padding-left: 0.5rem !important;
}
.airline-logo-cell {
width: 50px !important;
min-width: 50px !important;
max-width: 50px !important;
padding-right: 0 !important;
}
.flight-number-cell {
padding-left: 0.5rem !important;
}
/* ── Cell styles ── */
.flight-cell {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.airline-logo-img {
opacity: 0.85;
flex-shrink: 0;
}
.flight-number {
font-family: 'Share Tech Mono', monospace;
font-size: 0.9rem;
color: #e8eaf0;
letter-spacing: 0.06em;
}
.iata {
display: inline-block;
font-family: 'Share Tech Mono', monospace;
font-size: 1rem;
font-weight: 600;
color: #e8eaf0;
letter-spacing: 0.08em;
margin-right: 0.35rem;
}
.city-name {
font-size: 0.75rem;
color: #556;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.date-cell {
font-family: 'Share Tech Mono', monospace;
font-size: 0.82rem;
color: #9aa;
letter-spacing: 0.04em;
}
.time-cell {
font-family: 'Share Tech Mono', monospace;
font-size: 0.82rem;
color: #c8cdd8;
letter-spacing: 0.04em;
}
.day-diff {
font-family: 'Share Tech Mono', monospace;
font-size: 0.65rem;
color: #ffc107;
letter-spacing: 0.04em;
margin-left: 0.15rem;
}
.mono-tag {
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
color: #778899;
letter-spacing: 0.06em;
}
.seat-cell {
font-family: 'Share Tech Mono', monospace;
font-size: 0.85rem;
color: #c8cdd8;
}
.class-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* ── Empty state ── */
.no-data {
padding: 3rem;
text-align: center;
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
letter-spacing: 0.2em;
color: #445;
}
</style>
@@ -1,103 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import {FlightClass} from "@/Types/types"; import {Flight} from "@/Types/types";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
defineProps<{ const props = defineProps<{
flightClass: FlightClass | null flight: Flight
}>(); }>();
type BadgeVariant = 'first' | 'business' | 'premium' | 'economy' | 'private' | 'unspecified';
function classVariant(name?: string | null): BadgeVariant {
const n = name?.toLowerCase() ?? '';
if (n.includes('private')) return 'private';
if (n.includes('first')) return 'first';
if (n.includes('business')) return 'business';
if (n.includes('premium')) return 'premium';
if (n.includes('economy')) return 'economy';
return 'unspecified';
}
</script> </script>
<template> <template>
<span <InlineBadge :variant="classVariant(flight.flight_class?.name)">
class="class-badge" {{ flight.flight_class?.name }}
:class="{ </InlineBadge>
'class-first': flightClass?.name?.toLowerCase().includes('first'),
'class-business': flightClass?.name?.toLowerCase().includes('business'),
'class-premium': flightClass?.name?.toLowerCase().includes('premium'),
'class-economy': flightClass?.name?.toLowerCase().includes('economy') && !flightClass?.name?.toLowerCase().includes('premium'),
'class-private': flightClass?.name?.toLowerCase().includes('private'),
'class-unspecified': flightClass?.name?.toLowerCase().includes('unspecified'),
}"
>
{{ flightClass?.name ?? '—' }}
</span>
</template> </template>
<style scoped>
.class-badge {
display: inline-block;
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.1em;
padding: 0.18rem 0.55rem;
border-radius: 2px;
text-transform: uppercase;
}
.class-first {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
border: 1px solid rgba(255, 193, 7, 0.3);
}
.class-business {
background: rgba(100, 180, 255, 0.1);
color: #64b4ff;
border: 1px solid rgba(100, 180, 255, 0.25);
}
.class-premium {
background: rgb(75, 32, 137, 0.1);
color: #c49dff;
border: 1px solid rgba(180, 130, 255, 0.25);
}
.class-economy {
background: rgba(255, 255, 255, 0.05);
color: #778;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.class-unspecified {
display: none;
background: transparent;
color: #778;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.class-private {
background: linear-gradient(
135deg,
rgba(255, 160, 0, 0.2) 0%,
rgba(255, 220, 80, 0.3) 45%,
rgba(255, 160, 0, 0.15) 100%
);
color: #ffc107;
border: 1px solid rgba(255, 193, 7, 0.4);
text-shadow: 0 0 8px rgba(255, 193, 7, 0.6);
position: relative;
overflow: hidden;
}
.class-private::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
105deg,
transparent 40%,
rgba(255, 220, 100, 0.45) 50%,
transparent 60%
);
background-size: 200% 100%;
animation: shimmer 2.8s ease-in-out infinite;
}
@keyframes shimmer {
0% {
background-position: 200% center;
}
100% {
background-position: -200% center;
}
}
</style>
@@ -1,238 +0,0 @@
<script setup lang="ts">
import FlightClassBadge from "@/Components/FlightsGoneBy/FlightClassBadge.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import {Flight} from "@/Types/types";
const props = defineProps<{
flights: Flight[]
}>()
const headers = [
{ title: 'FLIGHT', key: 'flight_number', sortable: true },
{ title: 'FROM', key: 'departure_airport', sortable: true },
{ title: 'TO', key: 'arrival_airport', sortable: true },
{ title: 'DATE', key: 'departure_date', sortable: true },
{ title: 'AIRLINE', key: 'airline', sortable: true },
{ title: 'AIRCRAFT', key: 'aircraft.designator', sortable: true },
{ title: 'CLASS', key: 'flight_class', sortable: true },
{ title: 'SEAT', key: 'seat_number', sortable: false },
]
const CLASS_ORDER: Record<string, number> = {
'Unspecified': 0,
'Economy': 1,
'Premium Economy': 2,
'Business': 3,
'First': 4,
'Private': 5
}
const customKeySort = {
flight_class: (a: Flight['flight_class'], b: Flight['flight_class']) => {
return (CLASS_ORDER[a?.name ?? ''] ?? -1) - (CLASS_ORDER[b?.name ?? ''] ?? -1)
}
}
</script>
<template>
<v-data-table
:headers="headers"
:custom-key-sort="customKeySort"
:items="flights"
:items-per-page="25"
class="departures-table"
hover
>
<!-- Flight number + logo -->
<template #item.flight_number="{ item }">
<div class="flight-cell">
<AirlineLogo size="32" :airline="item.airline" class="airline-logo-img" />
<span class="flight-number">{{ item.flight_number}}</span>
</div>
</template>
<!-- Departure -->
<template #item.departure_airport="{ item }">
<span class="iata">{{ item.departure_airport.iata_code }}</span><br/>
<span class="city-name">{{ item.departure_airport.municipality }}</span>
</template>
<!-- Arrival -->
<template #item.arrival_airport="{ item }">
<span class="iata">{{ item.arrival_airport.iata_code }}</span><br/>
<span class="city-name">{{ item.arrival_airport.municipality }}</span>
</template>
<!-- Date -->
<template #item.departure_date="{ item }">
<span class="date-cell">{{ item.departure_date}}</span>
</template>
<!-- Airline -->
<template #item.airline="{ item }">
<span class="airline-name">{{ item.airline?.name }}</span>
</template>
<!-- Aircraft -->
<template #item.aircraft="{ item }">
<span class="mono-tag">{{ item.aircraft?.designator }}</span>
</template>
<!-- Class -->
<template #item.flight_class="{ item }">
<FlightClassBadge :flight-class="item.flight_class" />
</template>
<!-- Seat -->
<template #item.seat_number="{ item }">
<span class="seat-cell">{{ item.seat_number}}</span>
</template>
<!-- Empty state -->
<template #no-data>
<div class="no-data">
<span>NO FLIGHTS ON RECORD</span>
</div>
</template>
</v-data-table>
</template>
<style scoped>
/* ── Vuetify table overrides ── */
.departures-table {
background: transparent !important;
color: #c8cdd8 !important;
font-family: 'Barlow', sans-serif !important;
}
/* Header row */
:deep(.v-data-table-header__content) {
font-family: 'Share Tech Mono', monospace !important;
font-size: 0.68rem !important;
letter-spacing: 0.14em !important;
color: #ffc107 !important;
font-weight: 400 !important;
}
:deep(.v-data-table__th) {
background: transparent !important;
border-bottom: 1px solid rgba(255,193,7,0.15) !important;
padding: 0.75rem 1rem !important;
}
/* Body rows — alternating, no borders */
:deep(.v-data-table__tr:nth-child(odd)) {
background: rgba(255,255,255,0.025) !important;
}
:deep(.v-data-table__tr:nth-child(even)) {
background: transparent !important;
}
:deep(.v-data-table__tr:hover td) {
background: rgba(255,193,7,0.05) !important;
}
:deep(.v-data-table__td) {
border: none !important;
padding: 0.7rem 1rem !important;
font-size: 0.88rem !important;
color: #c8cdd8 !important;
}
/* Footer / pagination */
:deep(.v-data-table-footer) {
background: transparent !important;
border-top: 1px solid rgba(255,255,255,0.06) !important;
color: #556 !important;
font-family: 'Share Tech Mono', monospace !important;
font-size: 0.75rem !important;
}
:deep(.v-data-table-footer .v-btn) {
color: #778 !important;
}
:deep(.v-data-table-footer .v-btn--active) {
color: #ffc107 !important;
}
/* Sort icon colour */
:deep(.v-data-table-header__sort-icon) {
color: #ffc107 !important;
opacity: 0.5;
}
/* ── Cell styles ── */
.flight-cell {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer
}
.airline-logo-img {
opacity: 0.85;
flex-shrink: 0;
}
.flight-number {
font-family: 'Share Tech Mono', monospace;
font-size: 0.9rem;
color: #e8eaf0;
letter-spacing: 0.06em;
}
.iata {
display: inline-block;
font-family: 'Share Tech Mono', monospace;
font-size: 1rem;
font-weight: 600;
color: #e8eaf0;
letter-spacing: 0.08em;
margin-right: 0.35rem;
}
.city-name {
font-size: 0.75rem;
color: #556;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.date-cell {
font-family: 'Share Tech Mono', monospace;
font-size: 0.82rem;
color: #9aa;
letter-spacing: 0.04em;
}
.airline-name {
font-size: 0.85rem;
color: #b0b8c8;
}
.mono-tag {
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
color: #778899;
letter-spacing: 0.06em;
}
.seat-cell {
font-family: 'Share Tech Mono', monospace;
font-size: 0.85rem;
color: #c8cdd8;
}
/* ── Empty state ── */
.no-data {
padding: 3rem;
text-align: center;
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
letter-spacing: 0.2em;
color: #445;
}
</style>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import {Anchor} from "vuetify";
defineProps<{
location?: Anchor;
openDelay?: number;
}>();
</script>
<template>
<v-tooltip :location="location ?? 'top'" :open-delay="openDelay ?? 200">
<template #activator="activatorScope">
<slot name="activator" v-bind="activatorScope" />
</template>
<template #default>
<div class="glass-tooltip glass glass-border">
<slot />
</div>
</template>
</v-tooltip>
</template>
<style scoped>
:deep(.v-overlay__content) {
background: transparent !important;
box-shadow: none !important;
filter: none !important;
}
.glass-tooltip {
background: var(--surface);
border: 1px solid var(--table-border);
border-radius: 8px;
padding: 10px 14px;
min-width: 180px;
display: flex;
flex-direction: column;
gap: 8px;
color: var(--text);
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
variant?: 'first' | 'business' | 'premium' | 'economy' | 'private' | 'unspecified' | 'generic';
}>();
</script>
<template>
<span v-if="variant !== 'unspecified'" class="class-badge" :class="variant ? `class-${variant}-global` : 'class-economy-global'">
<slot />
</span>
</template>
<style scoped>
.class-badge {
display: inline-block;
font-family: 'Share Tech Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.1em;
padding: 0.18rem 0.55rem;
border-radius: 2px;
text-transform: uppercase;
cursor: pointer;
}
</style>
+72 -5
View File
@@ -2,9 +2,9 @@
import MainLayout from "@/Layouts/MainLayout.vue"; import MainLayout from "@/Layouts/MainLayout.vue";
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { Flight } from "@/Types/types"; import { Flight } from "@/Types/types";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue"; import DepartureBoard from "@/Components/FlightsGoneBy/DepartureBoard.vue";
import FlightClassBadge from "@/Components/FlightsGoneBy/FlightClassBadge.vue"; import BoardingPasses from "@/Components/FlightsGoneBy/BoardingPasses.vue";
import FlightListTable from "@/Components/FlightsGoneBy/FlightListTable.vue"; import { ref } from "vue";
defineOptions({ defineOptions({
layout: MainLayout layout: MainLayout
@@ -19,6 +19,8 @@ defineProps<{
flights: Flight[] flights: Flight[]
}>() }>()
type View = 'board' | 'passes'
const activeView = ref<View>('board')
</script> </script>
<template> <template>
@@ -36,7 +38,28 @@ defineProps<{
</div> </div>
</div> </div>
<FlightListTable :flights="flights" /> <!-- View switcher toolbar -->
<div class="view-toolbar">
<button
class="view-btn"
:class="{ active: activeView === 'board' }"
@click="activeView = 'board'"
>
<span class="view-btn-icon"></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"></span>
<span class="view-btn-label">BOARDING PASSES</span>
</button>
</div>
<DepartureBoard v-if="activeView === 'board'" :flights="flights" />
<BoardingPasses v-else :flights="flights" />
</div> </div>
</template> </template>
@@ -49,7 +72,7 @@ defineProps<{
background: #0d0f14; background: #0d0f14;
padding: 2.5rem 2rem; padding: 2.5rem 2rem;
font-family: 'Barlow', sans-serif; font-family: 'Barlow', sans-serif;
width:100%; width: 100%;
} }
/* ── Header ── */ /* ── Header ── */
@@ -100,5 +123,49 @@ defineProps<{
color: #556; 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> </style>
+21
View File
@@ -23,6 +23,18 @@ export type SharedProps = import('@inertiajs/core').PageProps & {
logo_api_url: string logo_api_url: string
} }
export interface Region {
id: number
name: string
code: string
local_code: string
country_id: number
continent_id: number
created_at: string | null
updated_at: string | null
country: Country
}
export interface Airport { export interface Airport {
id: number id: number
name: string name: string
@@ -30,6 +42,7 @@ export interface Airport {
longitude_deg: number longitude_deg: number
elevation_ft: number | null elevation_ft: number | null
region_id: number region_id: number
region?: Region
municipality: string | null municipality: string | null
icao_code: string | null icao_code: string | null
iata_code: string | null iata_code: string | null
@@ -109,11 +122,19 @@ export interface Flight {
note: string | null note: string | null
departure_airport: Airport departure_airport: Airport
arrival_airport: Airport arrival_airport: Airport
departure_date_display: string,
arrival_date_display: string,
departure_time_display: string,
arrival_time_display: string,
arrival_day_difference: number,
airline: Airline | null airline: Airline | null
aircraft: Aircraft | null aircraft: Aircraft | null
seat_type: SeatType | null seat_type: SeatType | null
flight_reason: FlightReason | null flight_reason: FlightReason | null
flight_class: FlightClass | null flight_class: FlightClass | null
duration_display: string
duration: number
distance: number
} }
declare module '@inertiajs/vue3' { declare module '@inertiajs/vue3' {
+1 -1
View File
@@ -79,5 +79,5 @@ Route::domain(config('app.api_domain'))->group(function () {
}); });
Route::get('airlines/logos/tail/{code}', [LogoController::class, 'getLogoByCode']) Route::get('airlines/logos/tail/{code}', [LogoController::class, 'getLogoByCode'])
->where('code', '[A-Za-z0-9]{2,3}'); ->where('code', '[A-Za-z0-9]{2,3}');
Route::get('airlines/logos/tail/id/{id}', [LogoController::class, 'getLogoById']); Route::get('airlines/logos/tail/id/{id}', [LogoController::class, 'getLogoById'])->where('id', '[0-9]+');
}); });