Added achievement data

This commit is contained in:
2026-05-10 02:28:30 +10:00
parent f2b2eaaabe
commit 217a971360
23 changed files with 479 additions and 470 deletions
@@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Models\UserFlight;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
#[Signature('app:update-departed-flights')]
#[Description('Command description')]
class UpdateDepartedFlights extends Command
{
/**
* Execute the console command.
*/
public function handle()
{
$now = now()->utc();
$oneHourAgo = $now->copy()->subHours(1);
$userFlights = UserFlight::whereBetween('arrival_date', [
$oneHourAgo->toDateTimeString(),
$now->toDateTimeString(),
])
->where('auto_update', true)
->whereNotNull('flight_number')
->get();
$this->info("Found {$userFlights->count()} flights.");
foreach ($userFlights as $flight) {
// Split "QF22" into ["QF", "22"]
preg_match('/^([A-Z]{2,3})(\d+)$/i', $flight->flight_number, $matches);
if (empty($matches)) {
$this->warn("Could not parse flight number: {$flight->flight_number}");
continue;
}
$airlineCode = strtoupper($matches[1]);
$flightNumber = $matches[2];
$arrivalDate = $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone);
$year = $arrivalDate->year;
$month = $arrivalDate->month;
$day = $arrivalDate->day;
$url = "https://www.flightstats.com/v2/api-next/flight-tracker/{$airlineCode}/{$flightNumber}/{$year}/{$month}/{$day}";
$response = Http::get($url);
if (!$response->successful()) {
$this->warn("Failed to fetch data for {$flight->flight_number}: HTTP {$response->status()}");
continue;
}
$data = $response->json();
$flightData = $data['data'] ?? [];
if (empty($flightData)) {
$this->warn("No flight data returned for {$airlineCode}{$flightNumber}");
continue;
}
$tailNumber = $flightData['positional']['flexTrack']['tailNumber'] ?? null;
$estimatedDepartureUtc = $flightData['schedule']['estimatedActualDepartureUTC'] ?? null;
$estimatedArrivalUtc = $flightData['schedule']['estimatedActualArrivalUTC'] ?? null;
$equipmentCode = $flightData['additionalFlightInfo']['equipment']['iata'] ?? null;
$this->info("Flight {$airlineCode}{$flightNumber} — Tail: {$tailNumber}, Equipment: {$equipmentCode}");
$this->info("Departure: {$estimatedDepartureUtc} | Arrival: {$estimatedArrivalUtc}");
}
}
}
+1 -1
View File
@@ -210,7 +210,7 @@ class FlightController extends Controller
{ {
$this->authorize('delete', $flight); $this->authorize('delete', $flight);
$snapshot = $this->flightSnapshot($flight->id); $snapshot = $flight->snapshot($flight->id);
if(now()->utc()->isBefore($flight->departure_date)){ if(now()->utc()->isBefore($flight->departure_date)){
$action = 'flight_deleted'; $action = 'flight_deleted';
+2 -2
View File
@@ -145,7 +145,7 @@ class UserFlight extends Model
protected function distance(): Attribute protected function distance(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn() => $this->calculateGreatCircleDistance() get: fn() => round($this->calculateGreatCircleDistance())
); );
} }
@@ -224,7 +224,7 @@ class UserFlight extends Model
public function liveryUrl(): Attribute{ public function liveryUrl(): Attribute{
return Attribute::make( return Attribute::make(
get: function () { get: function () {
if($this->airline) { if($this->airline && $this->aircraft) {
$fileName = "{$this->airline->internal_name}_{$this->aircraft->designator}.png"; $fileName = "{$this->airline->internal_name}_{$this->aircraft->designator}.png";
$file = public_path("img/liveries/generated/$fileName"); $file = public_path("img/liveries/generated/$fileName");
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
php artisan config:clear
php artisan config:cache
php artisan migrate --force
crond -f -l 8 &
php-fpm -D
nginx -g 'daemon off;'
+8 -4
View File
@@ -1,10 +1,8 @@
FROM php:8.4-fpm-alpine FROM php:8.4-fpm-alpine
# Install dependencies RUN apk add --no-cache nginx curl zip unzip git postgresql-dev nodejs npm dcron \
RUN apk add --no-cache nginx curl zip unzip git postgresql-dev nodejs npm \
&& docker-php-ext-install pdo pdo_pgsql pgsql opcache && docker-php-ext-install pdo pdo_pgsql pgsql opcache
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www WORKDIR /var/www
@@ -19,6 +17,12 @@ COPY docker/nginx.conf /etc/nginx/nginx.conf
RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
RUN echo "* * * * * cd /var/www && php artisan schedule:run" \
> /etc/crontabs/root
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80 EXPOSE 80
CMD ["sh", "-c", "php-fpm -D && nginx -g 'daemon off;'"] CMD ["/entrypoint.sh"]
@@ -2,21 +2,14 @@
import { Aircraft } from "@/Types/types"; import { Aircraft } from "@/Types/types";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import WakeTurbulence from "@/Components/FlightsGoneBy/WakeTurbulence.vue";
defineProps<{ defineProps<{
aircraft: Aircraft aircraft: Aircraft
showTooltips?: boolean showTooltips?: boolean
}>() }>()
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 { function formatEngineType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
@@ -41,7 +34,7 @@ function formatEngineType(type: string): string {
{{aircraft.designator }} {{aircraft.designator }}
</InlineBadge> </InlineBadge>
<InlineBadge variant="generic"> <InlineBadge variant="generic">
{{ formatWtc(aircraft.wtc) }} <WakeTurbulence :value="aircraft.wtc" />
</InlineBadge> </InlineBadge>
</div> </div>
@@ -17,7 +17,7 @@ const size = computed(() => props.size ? props.size + 'px' : '30px');
</script> </script>
<template> <template>
<span class="alliance-logo"></span> <span :title="alliance.name" class="alliance-logo"></span>
</template> </template>
<style scoped> <style scoped>
@@ -2,7 +2,7 @@
import FlightClassBadge from "@/Components/FlightsGoneBy/FlightClassBadge.vue"; import FlightClassBadge from "@/Components/FlightsGoneBy/FlightClassBadge.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue"; import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import {Flight} from "@/Types/types"; import {Flight, User} from "@/Types/types";
import { computed, ref, watch, nextTick } from "vue"; import { computed, ref, watch, nextTick } from "vue";
import type { DataTableSortItem } from 'vuetify'; import type { DataTableSortItem } from 'vuetify';
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
@@ -21,11 +21,17 @@ const props = defineProps<{
flightId?: number | null flightId?: number | null
}>() }>()
console.log(props.user)
function editRoute(id: number) { function editRoute(id: number) {
return route('flights.edit', { flight: id }) return route('flights.edit', { flight: id })
} }
const showDeleteDialog = ref(false) const flightToDelete = ref<Flight | null>(null)
const showDeleteDialog = computed({
get: () => flightToDelete.value !== null,
set: (val) => { if (!val) flightToDelete.value = null }
})
const ITEMS_PER_PAGE = 25 const ITEMS_PER_PAGE = 25
@@ -263,7 +269,6 @@ watch(
</td> </td>
<td class="v-data-table__td actions-cell"> <td class="v-data-table__td actions-cell">
<template>
<v-menu> <v-menu>
<template #activator="{ props: menuProps }"> <template #activator="{ props: menuProps }">
<v-btn <v-btn
@@ -275,8 +280,8 @@ watch(
/> />
</template> </template>
<v-list density="compact" bg-color="#1a1e2e"> <v-list density="compact" bg-color="#1a1e2e">
<v-list-item v-if="canEdit" <v-list-item
prepend-icon="mdi-pencil-outline" prepend-icon="mdi-magnify"
title="View Details" title="View Details"
:href="`/u/${user.name}/flight/${(item as Flight).id}`" :href="`/u/${user.name}/flight/${(item as Flight).id}`"
/> />
@@ -286,30 +291,32 @@ watch(
:href="editRoute((item as Flight).id)" :href="editRoute((item as Flight).id)"
/> />
<v-list-item v-if="canEdit" <v-list-item v-if="canEdit"
prepend-icon="mdi-trash-can-outline" prepend-icon="mdi-trash-can-outline"
title="Delete" title="Delete"
@click="showDeleteDialog = true" @click="flightToDelete = (item as Flight)"
/> />
</v-list> </v-list>
</v-menu> </v-menu>
<v-dialog v-model="showDeleteDialog" max-width="400"> <v-dialog v-if="canEdit" v-model="showDeleteDialog" max-width="400">
<v-card title="Delete Flight"> <v-card title="Delete Flight">
<v-card-text>Are you sure you want to delete this flight?</v-card-text> <v-card-text>Are you sure you want to delete this flight?</v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer /> <v-spacer />
<v-btn v-if="!deleting" @click="showDeleteDialog = false">Cancel</v-btn> <v-btn v-if="!deleting" @click="flightToDelete = null">Cancel</v-btn>
<v-btn <v-btn
color="error" color="error"
:loading="deleting" :loading="deleting"
@click="deleting = true; router.delete(route('flights.delete', { flight: (item as Flight).id }), { onFinish: () => deleting = false })" @click="deleting = true; router.delete(
route('flights.delete', { flight: flightToDelete!.id }),
{ onFinish: () => { deleting = false; flightToDelete = null } }
)"
> >
Delete Delete
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
</template>
</td> </td>
</tr> </tr>
@@ -31,7 +31,6 @@ defineProps<{
.glass-tooltip { .glass-tooltip {
background: var(--surface); background: var(--surface);
border: 1px solid var(--table-border); border: 1px solid var(--table-border);
border-radius: 8px;
padding: 10px 14px; padding: 10px 14px;
min-width: 180px; min-width: 180px;
display: flex; display: flex;
@@ -26,7 +26,6 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<header class="glass"> <header class="glass">
<Link href="/" class="brand">FlightsGoneBy</Link> <Link href="/" class="brand">FlightsGoneBy</Link>
<!-- Notification icon (always visible) -->
<button v-if="props.auth.user" class="notif-btn" aria-label="Notifications"> <button v-if="props.auth.user" class="notif-btn" aria-label="Notifications">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
@@ -47,9 +46,8 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<Link :href="route('profile.view', { user: props.auth.user.name })" class="nav-link">Profile</Link> <Link :href="route('profile.view', { user: props.auth.user.name })" class="nav-link">Profile</Link>
<Link href="/feed" class="nav-link">Feed</Link> <Link href="/feed" class="nav-link">Feed</Link>
<!-- User dropdown -->
<div class="dropdown" ref="dropdownRef"> <div class="dropdown" ref="dropdownRef">
<button class="nav-link dropdown-trigger" @click="dropdownOpen = !dropdownOpen"> <button class="nav-link dropdown-trigger" @click.stop="dropdownOpen = !dropdownOpen">
{{ props.auth.user.name }} {{ props.auth.user.name }}
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -66,23 +64,23 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</nav> </nav>
<!-- Hamburger (mobile only) --> <!-- Hamburger (mobile only) -->
<button class="hamburger" :class="{ open: menuOpen }" @click="menuOpen = !menuOpen" aria-label="Toggle menu"> <button class="hamburger" :class="{ open: menuOpen }" @click.stop="menuOpen = !menuOpen" aria-label="Toggle menu">
<span /><span /><span /> <span /><span /><span />
</button> </button>
<!-- Mobile drawer --> <!-- Mobile drawer -->
<Transition name="slide"> <Transition name="slide">
<nav v-if="menuOpen" class="nav-mobile" @click="menuOpen = false"> <nav v-if="menuOpen" class="nav-mobile" @click.stop>
<template v-if="!props.auth.user"> <template v-if="!props.auth.user">
<Link :href="route('login')" class="nav-link">Log In</Link> <Link :href="route('login')" class="nav-link" @click="menuOpen = false">Log In</Link>
<Link :href="route('register')" class="nav-link nav-link--highlight">Register</Link> <Link :href="route('register')" class="nav-link nav-link--highlight" @click="menuOpen = false">Register</Link>
</template> </template>
<template v-else> <template v-else>
<span class="nav-greeting">Welcome, {{ props.auth.user.name }}</span> <span class="nav-greeting">Welcome, {{ props.auth.user.name }}</span>
<Link :href="route('flights.add')" class="nav-link">Add Flight</Link> <Link :href="route('flights.add')" class="nav-link" @click="menuOpen = false">Add Flight</Link>
<Link :href="route('profile.view', { username: props.auth.user.name })" class="nav-link">Profile</Link> <Link :href="route('profile.view', { user: props.auth.user.name })" class="nav-link" @click="menuOpen = false">Profile</Link>
<Link href="/feed" class="nav-link nav-link--highlight">Feed</Link> <Link href="/feed" class="nav-link nav-link--highlight" @click="menuOpen = false">Feed</Link>
<Link :href="route('import.fr24')" class="nav-link">Import from FR24</Link> <Link :href="route('import.fr24')" class="nav-link" @click="menuOpen = false">Import from FR24</Link>
<div class="dropdown-divider" /> <div class="dropdown-divider" />
<button class="nav-link nav-link--danger" @click="logout">Log Out</button> <button class="nav-link nav-link--danger" @click="logout">Log Out</button>
</template> </template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
import DetailRow from "@/Components/FlightsGoneBy/Panels/DetailRow.vue";
import type {Flight} from "@/Types/types";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import PanelSubHeader from "@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue";
defineProps<{
flight: Flight
}>()
</script>
<template>
<Panel label="Aircraft">
<div class="livery" v-if="flight.livery_url" :style="{ backgroundImage: `url('${flight.livery_url}')` }">
</div>
<PanelHeader>{{ flight.aircraft?.display_name_short }}</PanelHeader>
<PanelSubHeader v-if="flight.aircraft?.manufacturer_code">{{ flight.aircraft.manufacturer_code }}</PanelSubHeader>
<DetailRows>
<DetailRow v-if="flight.aircraft?.designator" label="Designator" :value="flight.aircraft.designator" variant="Badge" />
<DetailRow v-if="flight.aircraft_registration" label="Registration" :value="flight.aircraft_registration" />
<DetailRow class="detail-row" v-if="flight.aircraft?.engine_type" label="Engine Type" :value="flight.aircraft?.engine_type" />
<DetailRow class="detail-row" v-if="flight.aircraft?.engine_count" label="No. of Engines" :value="flight.aircraft?.engine_count.toString()" />
<DetailRow class="detail-row" v-if="flight.aircraft?.wtc" label="Wake Turbulence Category" variant="WTC" :value="flight.aircraft?.wtc" />
</DetailRows>
</Panel>
</template>
<style scoped>
.livery{
width: 100%;
aspect-ratio: 16/9;
background-size: cover;
}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import {Airline} from "@/Types/types";
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
import DetailRow from "@/Components/FlightsGoneBy/Panels/DetailRow.vue";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
import PanelSubHeader from "@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue";
defineProps<{
airline: Airline
}>()
</script>
<template>
<Panel label="Airline">
<div class="airline-header">
<AirlineLogo :airline="airline" class="airline-logo" />
<div>
<PanelHeader>{{ airline.name }}</PanelHeader>
<PanelSubHeader v-if="airline.country?.name"><span class="fi" :class="`fi-${airline.country.code.toLowerCase()}`"></span> {{ airline.country.name }}</PanelSubHeader>
</div>
</div>
<DetailRows>
<DetailRow v-if="airline.alliance?.name" label="Alliance" :value="airline.alliance.name" variant="Alliance" :alliance="airline.alliance" />
<DetailRow label="IATA" v-if="airline.IATA_code" :value="airline.IATA_code" variant="Badge" />
<DetailRow label="ICAO" v-if="airline.IATA_code" :value="airline.ICAO_code" variant="Badge" />
</DetailRows>
</Panel>
</template>
<style scoped>
.airline-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.airline-logo-placeholder {
width: 42px;
height: 42px;
border-radius: 8px;
background: var(--accent-glow);
border: 1px solid rgba(56, 189, 248, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-family: 'Share Tech Mono', monospace;
color: var(--accent);
flex-shrink: 0;
}
.airline-name {
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import { Airport } from "@/Types/types";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
import DetailRow from "@/Components/FlightsGoneBy/Panels/DetailRow.vue";
import PanelSubHeader from "@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue";
import PanelHeader from "@/Components/FlightsGoneBy/Panels/PanelHeader.vue";
defineProps<{
label?: string
airport: Airport
}>()
</script>
<template>
<Panel :label="label">
<PanelHeader class="airport-name">{{ airport.name }}</PanelHeader>
<PanelSubHeader>
{{ airport.municipality }},
{{ airport.region?.country?.name }} <span :class="`fi fi-${airport.region?.country.code.toLowerCase()}`"></span>
</PanelSubHeader>
<DetailRows>
<DetailRow v-if="airport.iata_code" label="IATA" variant="Badge" :value="airport.iata_code" />
<DetailRow v-if="airport.icao_code" label="ICAO" variant="Badge" :value="airport.icao_code" />
<DetailRow label="City Served" :value="airport.municipality" />
<DetailRow label="Region" v-if="airport.region?.name" :value="airport.region.name" />
<DetailRow label="Country" v-if="airport.region?.country" variant="Country" :country="airport.region.country" :value="airport.region.country.name" />
</DetailRows>
</Panel>
</template>
<style scoped>
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import {Alliance, Country} from "@/Types/types";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue";
import WakeTurbulence from "@/Components/FlightsGoneBy/WakeTurbulence.vue";
defineProps<{
label: string
value: string | null
variant?: "Country" | "Badge" | "Alliance" | "WTC"
country?: Country
alliance?: Alliance
}>()
</script>
<template>
<div class="detail-row">
<span class="detail-key">{{label}}</span>
<span class="detail-val" v-if="!variant">{{value ?? ''}}</span>
<span class="detail-val" v-if="variant == 'Badge'"><InlineBadge variant="generic">{{value ?? ''}}</InlineBadge></span>
<span class="detail-val" v-if="variant == 'Country' && country">{{country.name}} <span :class="`fi fi-${country.code.toLowerCase()}`"></span></span>
<span class="detail-val" v-if="variant == 'Alliance' && alliance">{{alliance.name}} <AllianceLogo :alliance="alliance"/> </span>
<span class="detail-val" v-if="variant == 'WTC'"><WakeTurbulence :value="value" /> </span>
</div>
</template>
<style scoped>
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
}
.detail-key {
color: var(--muted);
}
.detail-val {
color: var(--text);
text-align: right;
display:inline-flex;
align-items: center;
justify-content: center;
gap: 0.5em;
}
</style>
@@ -0,0 +1,20 @@
<script setup lang="ts">
</script>
<template>
<div class="detail-rows">
<slot />
</div>
</template>
<style scoped>
.detail-rows {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.5rem;
border-top: 1px solid var(--border);
padding-top: 0.75rem;
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
defineProps<{
label?: string
}>()
</script>
<template>
<div class="panel glass glass-border">
<div v-if="label" class="panel-label">{{label}}</div>
<slot />
</div>
</template>
<style scoped>
/* Panels */
.panel {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.panel-label {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.25rem;
opacity: 0.8;
}
</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
</script>
<template>
<div class="panel-header"><slot /></div>
</template>
<style scoped>
.panel-header {
font-size: 1rem;
font-weight: 600;
color: var(--text);
line-height: 1.3;
}
</style>
@@ -0,0 +1,15 @@
<script setup lang="ts">
</script>
<template>
<div class="panel-sub-header"><slot/></div>
</template>
<style scoped>
.panel-sub-header {
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 0.5rem;
}
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import {Flight} from "@/Types/types";
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
import DetailRow from "@/Components/FlightsGoneBy/Panels/DetailRow.vue";
defineProps<{
flight: Flight
}>()
</script>
<template>
<Panel label="Route">
<div class="map-placeholder">
<FlightMap :flights="[flight]" />
</div>
<DetailRows>
<DetailRow label="Distance" :value="flight.distance + 'km'" />
</DetailRows>
</Panel>
</template>
<style scoped>
</style>
@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
value: string | null
}>()
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
}
}
</script>
<template>
{{formatWtc(value ?? '')}}
</template>
<style scoped>
</style>
+24 -427
View File
@@ -4,9 +4,12 @@ import {Head} from "@inertiajs/vue3";
import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue"; import ProfileLayout from "@/Components/FlightsGoneBy/ProfileLayout.vue";
import {Achievement, Flight, User, UserAchievement} from "@/Types/types"; import {Achievement, Flight, User, UserAchievement} from "@/Types/types";
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue"; import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"; import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue"; import AirportPanel from "@/Components/FlightsGoneBy/Panels/AirportPanel.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue"; import AirlinePanel from "@/Components/FlightsGoneBy/Panels/AirlinePanel.vue";
import AircraftPanel from "@/Components/FlightsGoneBy/Panels/AircraftPanel.vue";
import RoutePanel from "@/Components/FlightsGoneBy/Panels/RoutePanel.vue";
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
defineOptions({ layout: MainLayout }) defineOptions({ layout: MainLayout })
@@ -19,466 +22,60 @@ const props = defineProps<{
</script> </script>
<template> <template>
<Head :title="`${flight.flight_number ?? user.name + '\'s Flight'}`" />
<ProfileLayout <ProfileLayout
:user="user" :user="user"
:is-following="isFollowing" :is-following="isFollowing"
:flight-count="flightCount" :flight-count="flightCount"
:loading="false"> :loading="false">
<Head :title="`${flight.flight_number ?? user.name + '\'s Flight'}`" />
<div class="flight-profile"> <div class="flight-profile">
<!-- Header -->
<div class="flight-header glass glass-border">
<BoardingPass :showToolTips="false" style="width:100%;max-width:600px" :flight="flight"/>
</div>
<!-- Main grid --> <!-- Main grid -->
<div class="profile-grid"> <div class="profile-grid">
<RoutePanel :flight="flight" />
<Panel label="Flight Details">
<BoardingPass :showToolTips="false" style="width:100%;max-width:600px" :flight="flight"/>
<DetailRows>
<!-- Departure --> </DetailRows>
<div class="panel glass glass-border"> </Panel>
<div class="panel-label">Departure</div> <AircraftPanel :flight="flight"/>
<div class="airport-name">{{ flight.departure_airport?.name }}</div> <AirportPanel :airport="flight.departure_airport" label="Departure" />
<div class="airport-location"> <AirportPanel :airport="flight.arrival_airport" label="Arrival" />
{{ flight.departure_airport?.municipality }}, <AirlinePanel :airline="flight.airline" v-if="flight.airline" />
{{ flight.departure_airport?.region?.country?.name }}
</div>
<div class="detail-rows">
<div class="detail-row" v-if="flight.departure_airport?.iata_code">
<span class="detail-key">IATA</span>
<span class="detail-val mono">{{ flight.departure_airport.iata_code }}</span>
</div>
<div class="detail-row" v-if="flight.departure_airport?.icao_code">
<span class="detail-key">ICAO</span>
<span class="detail-val mono">{{ flight.departure_airport.icao_code }}</span>
</div>
<div class="detail-row" v-if="flight.departure_airport?.region?.name">
<span class="detail-key">Region</span>
<span class="detail-val">{{ flight.departure_airport.region.name }}</span>
</div>
<div class="detail-row" v-if="flight.departure_date">
<span class="detail-key">Time</span>
<span class="detail-val mono">{{ flight.departure_time_display }}</span>
</div>
</div>
</div>
<!-- Arrival -->
<div class="panel glass glass-border">
<div class="panel-label">Arrival</div>
<div class="airport-name">{{ flight.arrival_airport?.name }}</div>
<div class="airport-location">
{{ flight.arrival_airport?.municipality }},
{{ flight.arrival_airport?.region?.country?.name }}
</div>
<div class="detail-rows">
<div class="detail-row" v-if="flight.arrival_airport?.iata_code">
<span class="detail-key">IATA</span>
<span class="detail-val mono">{{ flight.arrival_airport.iata_code }}</span>
</div>
<div class="detail-row" v-if="flight.arrival_airport?.icao_code">
<span class="detail-key">ICAO</span>
<span class="detail-val mono">{{ flight.arrival_airport.icao_code }}</span>
</div>
<div class="detail-row" v-if="flight.arrival_airport?.region?.name">
<span class="detail-key">Region</span>
<span class="detail-val">{{ flight.arrival_airport.region.name }}</span>
</div>
<div class="detail-row" v-if="flight.arrival_date">
<span class="detail-key">Time</span>
<span class="detail-val mono">{{ flight.arrival_time_display }}</span>
</div>
</div>
</div>
<!-- Route Map -->
<div class="panel glass glass-border panel-map">
<div class="panel-label">Route</div>
<div class="map-placeholder">
<FlightMap :flights="[flight]" />
</div>
<!-- Slot for a real map library integration -->
<!-- <MapboxMap :departure="departureCoords" :arrival="arrivalCoords" /> -->
</div>
<!-- Airline -->
<div class="panel glass glass-border" v-if="flight.airline">
<div class="panel-label">Airline</div>
<div class="airline-header">
<AirlineLogo :airline="flight.airline" class="airline-logo" />
<div>
<div class="airline-name">{{ flight.airline.name }}</div>
<div class="airport-location" v-if="flight.airline?.country?.name"><span class="fi" :class="`fi-${flight.airline.country.code.toLowerCase()}`"></span> {{ flight.airline.country.name }}</div>
</div>
</div>
<div class="detail-rows">
<div class="detail-row" v-if="flight.airline?.alliance?.name">
<span class="detail-key">Alliance</span>
<span class="detail-val"><AllianceLogo :alliance="flight.airline.alliance" />{{ flight.airline.alliance.name }}</span>
</div>
<div class="detail-row" v-if="flight.airline?.IATA_code">
<span class="detail-key">IATA</span>
<span class="detail-val mono">{{ flight.airline.IATA_code }}</span>
</div>
<div class="detail-row" v-if="flight.airline?.ICAO_code">
<span class="detail-key">ICAO</span>
<span class="detail-val mono">{{ flight.airline.ICAO_code }}</span>
</div>
</div>
</div>
<!-- Aircraft -->
<div v-if="flight.aircraft" class="panel glass glass-border">
<div class="panel-label">Aircraft</div>
<div class="livery" v-if="flight.livery_url" :style="{ backgroundImage: `url('${flight.livery_url}')` }">
</div>
<div class="airport-name">{{ flight.aircraft?.display_name_short }}</div>
<div class="airport-location" v-if="flight.aircraft?.manufacturer_code">{{ flight.aircraft.manufacturer_code }}</div>
<div class="detail-rows">
<div class="detail-row" v-if="flight.aircraft?.designator">
<span class="detail-key">Designator</span>
<span class="detail-val mono">{{ flight.aircraft?.designator }}</span>
</div>
<div class="detail-row" v-if="flight.aircraft_registration">
<span class="detail-key">Tail No.</span>
<span class="detail-val mono">{{ flight.aircraft_registration }}</span>
</div>
<div class="detail-row" v-if="flight.aircraft?.engine_type">
<span class="detail-key">Engines</span>
<span class="detail-val">{{ flight.aircraft.engine_type }}</span>
</div>
</div>
</div>
<!-- Flight Details -->
<div class="panel glass glass-border panel-details">
<div class="panel-label">Flight Details</div>
<div class="details-grid">
<div class="detail-card" v-if="flight.flight_class">
<div class="detail-card-label">Class</div>
<div class="detail-card-value"
:class="flight.flight_class?.internal_name ? `class-${flight.flight_class.internal_name}-global` : ''"
style="padding: 0.25rem 0.6rem; border-radius: 4px; display: inline-block;">
{{ flight.flight_class?.name }}
</div>
</div>
<div class="detail-card" v-if="flight.seat_type">
<div class="detail-card-label">Seat Type</div>
<div class="detail-card-value">{{ flight.seat_type?.name }}</div>
</div>
<div class="detail-card" v-if="flight.seat_number">
<div class="detail-card-label">Seat</div>
<div class="detail-card-value mono">{{ flight.seat_number }}</div>
</div>
<div class="detail-card" v-if="flight.flight_reason">
<div class="detail-card-label">Reason</div>
<div class="detail-card-value">{{ flight.flight_reason?.name }}</div>
</div>
<div class="detail-card" v-if="flight.crew_type">
<div class="detail-card-label">Travelled As</div>
<div class="detail-card-value">{{ flight.crew_type?.name }}</div>
</div>
<div class="detail-card" v-if="flight.duration">
<div class="detail-card-label">Duration</div>
<div class="detail-card-value mono">{{ flight.duration_display }}</div>
</div>
<div class="detail-card" v-if="flight.distance">
<div class="detail-card-label">Distance</div>
<div class="detail-card-value mono">{{ flight.distance.toLocaleString() }} km</div>
</div>
<div class="detail-card" v-if="flight.note">
<div class="detail-card-label">Notes</div>
<div class="detail-card-value notes-text">{{ flight.note }}</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</ProfileLayout> </ProfileLayout>
</template> </template>
<style scoped> <style scoped>
.livery{
width: 100%;
aspect-ratio: 16/9;
background-size: cover;
}
.flight-profile { .flight-profile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 1.5rem; padding: 0; /* removed padding */
font-family: 'Barlow', sans-serif; font-family: 'Barlow', sans-serif;
color: var(--text); color: var(--text);
} }
/* Header */
.flight-header {
padding: 1.5rem 2rem;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.route-display {
display: flex;
align-items: center;
gap: 1.25rem;
}
.airport-code {
font-family: 'Barlow Condensed', sans-serif;
font-size: 2.75rem;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--text);
line-height: 1;
}
.route-line {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--accent);
flex: 1;
min-width: 120px;
}
.route-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
.route-dashes {
flex: 1;
border-top: 1.5px dashed rgba(56, 189, 248, 0.4);
}
.plane-icon {
width: 22px;
height: 22px;
color: var(--accent);
flex-shrink: 0;
}
.flight-meta {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.875rem;
color: var(--muted);
}
.meta-sep {
opacity: 0.4;
}
.flight-number {
font-family: 'Share Tech Mono', monospace;
color: var(--accent);
}
/* Grid */ /* Grid */
.profile-grid { .profile-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: 1fr; /* single column by default (mobile) */
gap: 1rem; gap: 1rem;
} }
.panel-map { @media (min-width: 600px) {
grid-column: span 2; .profile-grid {
} grid-template-columns: repeat(2, 1fr);
}
.panel-details {
grid-column: span 2;
} }
@media (min-width: 900px) { @media (min-width: 900px) {
.profile-grid { .profile-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.panel-map {
grid-column: span 1;
}
.panel-details {
grid-column: span 3;
}
} }
/* Panels */
.panel {
border-radius: 12px;
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.panel-label {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.25rem;
opacity: 0.8;
}
.airport-name {
font-size: 1rem;
font-weight: 600;
color: var(--text);
line-height: 1.3;
}
.airport-location {
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 0.5rem;
}
/* Detail rows */
.detail-rows {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.5rem;
border-top: 1px solid var(--border);
padding-top: 0.75rem;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
}
.detail-key {
color: var(--muted);
}
.detail-val {
color: var(--text);
text-align: right;
}
/* Map placeholder */
.map-placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1.5rem 0;
min-height: 160px;
opacity: 0.6;
}
.map-bg-icon {
width: 64px;
height: 64px;
color: var(--accent);
opacity: 0.3;
}
.map-route-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 500;
color: var(--text);
}
.map-arrow {
color: var(--accent);
}
.map-distance {
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
color: var(--muted);
}
/* Airline */
.airline-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.25rem;
}
.airline-logo-placeholder {
width: 42px;
height: 42px;
border-radius: 8px;
background: var(--accent-glow);
border: 1px solid rgba(56, 189, 248, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-family: 'Share Tech Mono', monospace;
color: var(--accent);
flex-shrink: 0;
}
.airline-name {
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
/* Details grid */
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
margin-top: 0.5rem;
}
.detail-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.75rem;
}
.detail-card-label {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.3rem;
}
.detail-card-value {
font-size: 0.9rem;
font-weight: 500;
color: var(--text);
}
.notes-text {
font-size: 0.8rem;
font-weight: 400;
color: var(--muted);
line-height: 1.5;
}
/* Utilities */
.mono {
font-family: 'Share Tech Mono', monospace;
}
</style> </style>
+1 -1
View File
@@ -135,7 +135,7 @@ function switchView(view: ProfileView) {
<Head :title="`${user.name}'s Flights`" /> <Head :title="`${user.name}'s Flights`" />
<ProfileLayout :is-following="isFollowing" :flightCount="flights.length" :user="user" :loading="flightsLoading"> <ProfileLayout :is-following="isFollowing" :flightCount="flights.length" :user="user" :loading="flightsLoading">
<ProfileViewSwitcher :user="user" :active-view="activeView" @update:active-view="switchView" /> <ProfileViewSwitcher :user="user" :active-view="activeView" @update:active-view="switchView" />
<DepartureBoard v-if="activeView === 'board'" :flight-id="localSelectedFlightId" :flight-stats="stats" :canEdit="canEdit" /> <DepartureBoard v-if="activeView === 'board'" :flight-id="localSelectedFlightId" :flight-stats="stats" :canEdit="canEdit" :user="user" />
<BoardingPasses v-if="activeView === 'passes'" :flight-stats="stats" :canEdit="canEdit" /> <BoardingPasses v-if="activeView === 'passes'" :flight-stats="stats" :canEdit="canEdit" />
<FlightMapAndCharts <FlightMapAndCharts
v-if="activeView === 'map'" v-if="activeView === 'map'"
+2
View File
@@ -6,3 +6,5 @@ use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('app:update-departed-flights')->hourly()->runInBackground();