Updated logo API

This commit is contained in:
2026-04-25 22:57:18 +10:00
parent 678096b463
commit de183995b6
26 changed files with 1088 additions and 64 deletions
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Models\UserAction;
use Illuminate\Http\Request;
use Inertia\Inertia;
class FeedController extends Controller
{
public function view()
{
$user = auth()->user();
$followeeIds = $user->following()->pluck('followee_id');
$feed = UserAction::whereIn('user_id', $followeeIds)
->whereNotIn('type', ['flight_deleted'])
->with([
'user',
])
->latest()
->limit(50)
->get();
return Inertia::render('Feed', [
'user' => $user,
'feed' => $feed,
]);
}
}
+82 -20
View File
@@ -53,7 +53,9 @@ class FlightController extends Controller
$airlines = $code $airlines = $code
? Airline::where($codeColumn, $code) ? Airline::where($codeColumn, $code)
->get() ->get()
->map(fn ($airline) => ['value' => $airline->id, 'title' => $airline->display_name]) ->map(fn ($airline) => ['value' => $airline->id, 'title' => $airline->display_name, 'logo_url' => $airline->logo_url])
->values()
->toArray()
: collect()->toArray(); : collect()->toArray();
return response()->json([ return response()->json([
'airline_options' => $airlines, 'airline_options' => $airlines,
@@ -74,27 +76,23 @@ class FlightController extends Controller
]; ];
} }
private function recordChanges(UserFlight $flight): void private function recordChanges(UserFlight $flight, array $dirty, array $original, array $updated): void
{ {
$dirty = $flight->getDirty(); $changes = [];
foreach ($dirty as $field => $newValue) {
if (empty($dirty)) { $changes[] = $this->formatChange($field, $flight->getOriginal($field), $newValue);
return;
} }
$actions = []; UserAction::create([
foreach ($dirty as $field => $newValue) {
$original = $flight->getOriginal($field);
$actions[] = [
'user_id' => $flight->user_id, 'user_id' => $flight->user_id,
'user_flight_id' => $flight->id, 'user_flight_id' => $flight->id,
'message' => $this->formatChange($field, $original, $newValue), 'data' => [
'created_at' => now(), 'changes' => $changes,
'updated_at' => now(), 'original' => $original,
]; 'updated' => $updated,
} ],
'type' => 'flight_updated',
UserAction::insert($actions); ]);
} }
private array $labelCache = []; private array $labelCache = [];
@@ -129,13 +127,17 @@ class FlightController extends Controller
return $this->labelCache[$cacheKey] = $label; return $this->labelCache[$cacheKey] = $label;
} }
private function formatChange(string $field, mixed $from, mixed $to): string private function formatChange(string $field, mixed $from, mixed $to): array
{ {
$label = str($field)->replace('_id', '')->replace('_', ' ')->title(); $label = str($field)->replace('_id', '')->replace('_', ' ')->title();
$fromLabel = $this->resolveLabel($field, $from); $fromLabel = $this->resolveLabel($field, $from);
$toLabel = $this->resolveLabel($field, $to); $toLabel = $this->resolveLabel($field, $to);
return "{$label} changed from {$fromLabel} to {$toLabel}"; return [
'field' => $field,
'from' => $fromLabel,
'to' => $toLabel,
];
} }
private function flightPayload(array $validated): array private function flightPayload(array $validated): array
@@ -168,9 +170,35 @@ class FlightController extends Controller
$newFlight = auth()->user()->flights()->create($this->flightPayload($validated)); $newFlight = auth()->user()->flights()->create($this->flightPayload($validated));
UserAction::create([
'user_id' => $newFlight->user_id,
'type' => $newFlight->departure_date->isFuture() ? 'flight_booked' : 'flight_logged',
'data' => [
'flight' => $this->flightSnapshot($newFlight->id),
],
]);
return redirect()->route('profile.departure-board', [Auth::user()->name, $newFlight->id]); return redirect()->route('profile.departure-board', [Auth::user()->name, $newFlight->id]);
} }
private function flightSnapshot(int $id): array
{
return UserFlight::with([
'departureAirport',
'departureAirport.region.country',
'arrivalAirport',
'arrivalAirport.region.country',
'aircraft',
'airline',
'airline.country',
'flightClass',
'seatType',
'flightReason',
'crewType',
])->find($id)->toArray();
}
public function update(Request $request, UserFlight $flight) public function update(Request $request, UserFlight $flight)
{ {
$this->authorize('update', $flight); $this->authorize('update', $flight);
@@ -178,12 +206,46 @@ class FlightController extends Controller
$validated = $request->validate($this->rules()); $validated = $request->validate($this->rules());
$flight->fill($this->flightPayload($validated)); $flight->fill($this->flightPayload($validated));
$this->recordChanges($flight);
if (!$flight->isDirty()) {
return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]);
}
$dirty = $flight->getDirty();
$original = $this->flightSnapshot($flight->id);
$flight->save(); $flight->save();
$updated = $this->flightSnapshot($flight->id);
$this->recordChanges($flight, $dirty, $original, $updated);
return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]); return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]);
} }
public function delete(UserFlight $flight)
{
$this->authorize('delete', $flight);
$snapshot = $this->flightSnapshot($flight->id);
if(now()->utc()->isBefore($flight->departure_date)){
$action = 'flight_deleted';
} else {
$action = 'flight_cancelled';
}
UserAction::create([
'user_id' => $flight->user_id,
'type' => $action,
'data' => [
'flight' => $snapshot,
]
]);
$flight->delete();
return redirect()->route('profile.departure-board', [Auth::user()->name]);
}
public function staticData() : array { public function staticData() : array {
return [ return [
'seat_types' => SeatType::orderBy('id')->get()->toArray(), 'seat_types' => SeatType::orderBy('id')->get()->toArray(),
-29
View File
@@ -9,34 +9,5 @@ use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin UserFlight */ /** @mixin UserFlight */
class UserFlightResource extends JsonResource 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,
];
}
} }
+31 -4
View File
@@ -2,19 +2,46 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserAction extends Model class UserAction extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'user_id',
'user_flight_id', 'type',
'message', 'data'
]; ];
protected $casts = [ protected $casts = [
'user_flight_id' => 'integer',
'user_id' => 'integer', 'user_id' => 'integer',
'message' => 'string', 'data' => 'array',
]; ];
protected $appends = [
'display_type',
];
protected function displayType(): Attribute
{
return Attribute::make(
get: fn () => match ($this->type) {
'flight_booked' => 'Flight Booked',
'flight_cancelled' => 'Flight Cancelled',
'flight_updated' => 'Flight Updated',
'flight_imported' => 'Flight Imported',
'flight_logged' => 'Flight Logged',
'flight_deleted' => 'Flight Deleted',
default => 'Unknown Action'
}
);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
} }
+96
View File
@@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -34,6 +36,17 @@ class UserFlight extends Model
'auto_update' => 'boolean', 'auto_update' => 'boolean',
]; ];
protected $appends = [
'departure_date_display',
'departure_time_display',
'arrival_date_display',
'arrival_time_display',
'arrival_day_difference',
'duration',
'duration_display',
'distance',
];
public function calculateGreatCircleDistance(): float{ public function calculateGreatCircleDistance(): float{
$earthRadiusKm = 6371; $earthRadiusKm = 6371;
[$depLat, $depLong] = [$this->departureAirport->latitude_deg, $this->departureAirport->longitude_deg]; [$depLat, $depLong] = [$this->departureAirport->latitude_deg, $this->departureAirport->longitude_deg];
@@ -52,6 +65,89 @@ class UserFlight extends Model
return $earthRadiusKm * $c; return $earthRadiusKm * $c;
} }
protected function departureDateDisplay(): Attribute
{
return Attribute::make(
get: fn() => $this->departure_date
->toMutable()
->setTimezone($this->departureAirport->timezone)
->format('j M Y')
);
}
protected function departureTimeDisplay(): Attribute
{
return Attribute::make(
get: fn() => $this->departure_date
->toMutable()
->setTimezone($this->departureAirport->timezone)
->format('g:iA')
);
}
protected function arrivalDateDisplay(): Attribute
{
return Attribute::make(
get: fn() => $this->arrival_date
?->copy()
->setTimezone($this->arrivalAirport->timezone)
->format('j M Y')
);
}
protected function arrivalTimeDisplay(): Attribute
{
return Attribute::make(
get: fn() => $this->arrival_date
?->copy()
->setTimezone($this->arrivalAirport->timezone)
->format('g:iA')
);
}
protected function arrivalDayDifference(): Attribute
{
return Attribute::make(
get: function () {
if (!$this->arrival_date) return 0;
$departureLocal = $this->departure_date->copy()->setTimezone($this->departureAirport->timezone);
$arrivalLocal = $this->arrival_date->copy()->setTimezone($this->arrivalAirport->timezone);
return (int) abs(
Carbon::parse($arrivalLocal->toDateString())
->diffInDays(Carbon::parse($departureLocal->toDateString()))
);
}
);
}
protected function duration(): Attribute
{
return Attribute::make(
get: fn() => $this->departure_date->diffInMinutes($this->arrival_date)
);
}
protected function durationDisplay(): Attribute
{
return Attribute::make(
get: function () {
$hours = intdiv($this->duration, 60);
$minutes = $this->duration % 60;
return $hours . 'h ' . str_pad($minutes, 2, '0', STR_PAD_LEFT) . 'm';
}
);
}
protected function distance(): Attribute
{
return Attribute::make(
get: fn() => $this->calculateGreatCircleDistance()
);
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
+1 -1
View File
@@ -45,7 +45,7 @@ class UserFlightPolicy
*/ */
public function delete(User $user, UserFlight $userFlight): bool public function delete(User $user, UserFlight $userFlight): bool
{ {
return false; return $user->id === $userFlight->user_id;
} }
/** /**
@@ -0,0 +1,29 @@
<?php
use App\Models\Airport;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('user_actions', function (Blueprint $table) {
$table->dropColumn('message');
$table->string('type')->after('user_flight_id');
$table->dropColumn('user_flight_id');
$table->json('data')->after('type');
});
Airport::whereMunicipality('Fayetteville/Springdale/Rogers')->update(['municipality' => 'Fayetteville']);
}
public function down(): void
{
Schema::table('user_actions', function (Blueprint $table) {
$table->dropColumn(['type', 'data']);
$table->text('message')->after('user_flight_id');
});
}
};
+2 -1
View File
@@ -67,7 +67,8 @@
} }
a { a, a:visited {
color: var(--text);
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
} }
@@ -9,7 +9,6 @@ const props = defineProps<{
errorMessages?: string[] | string errorMessages?: string[] | string
}>() }>()
const page = usePage<SharedProps>().props
const model = defineModel<{ value: number, title: string, logo_url: string } | null>() const model = defineModel<{ value: number, title: string, logo_url: string } | null>()
@@ -58,6 +57,7 @@ const searchAirlines = async (query: string) => {
style="padding: 0.25em" style="padding: 0.25em"
width="40" width="40"
height="40" height="40"
:alt="`${model.title}`"
:src="`${model.logo_url}`" :src="`${model.logo_url}`"
/> />
</template> </template>
@@ -78,6 +78,18 @@ defineProps<{
</template> </template>
<style scoped> <style scoped>
.feed-boarding-pass{
max-width: 600px;
margin: 1em auto;
}
@media (max-width: 1200px) {
.boarding-pass{
margin: 0;
}
}
.pass-stats-row { .pass-stats-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -11,6 +11,7 @@ import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
import {FlightStats} from "@/Composables/useFlightStats"; import {FlightStats} from "@/Composables/useFlightStats";
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue"; import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue"; import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue";
import {Link, router} from "@inertiajs/vue3";
const props = defineProps<{ const props = defineProps<{
flightStats: FlightStats flightStats: FlightStats
@@ -22,6 +23,8 @@ function editRoute(id: number) {
return route('flights.edit', { flight: id }) return route('flights.edit', { flight: id })
} }
const showDeleteDialog = ref(false)
const ITEMS_PER_PAGE = 25 const ITEMS_PER_PAGE = 25
const headers = [ const headers = [
@@ -35,6 +38,7 @@ const headers = [
{ title: 'DURATION', key: 'duration', sortable: true }, { title: 'DURATION', key: 'duration', sortable: true },
{ title: 'DISTANCE', key: 'distance', sortable: true }, { title: 'DISTANCE', key: 'distance', sortable: true },
{ title: 'AIRCRAFT', key: 'aircraft.designator', sortable: true }, { title: 'AIRCRAFT', key: 'aircraft.designator', sortable: true },
{ title: 'REG', key: 'aircraft_registration', sortable: true },
{ title: 'CLASS', key: 'flight_class', sortable: true }, { title: 'CLASS', key: 'flight_class', sortable: true },
{ title: '', key: 'actions', sortable: false }, { title: '', key: 'actions', sortable: false },
] ]
@@ -64,6 +68,8 @@ const customKeySort = {
}, },
} }
const deleting = ref(false)
const sortBy = ref<DataTableSortItem[]>([]) const sortBy = ref<DataTableSortItem[]>([])
const currentPage = ref(1) const currentPage = ref(1)
@@ -236,6 +242,12 @@ watch(
</AircraftToolTip> </AircraftToolTip>
</td> </td>
<td class="v-data-table__td ">
<span class="mono-tag">
{{(item as Flight).aircraft_registration}}
</span>
</td>
<td class="v-data-table__td "> <td class="v-data-table__td ">
<span class="class-cell"> <span class="class-cell">
<CrewTooltip v-if="(item as Flight).flight_reason?.name == 'Crew'" :crew-type="(item as Flight).crew_type!"> <CrewTooltip v-if="(item as Flight).flight_reason?.name == 'Crew'" :crew-type="(item as Flight).crew_type!">
@@ -266,8 +278,30 @@ watch(
title="Edit" title="Edit"
:href="editRoute((item as Flight).id)" :href="editRoute((item as Flight).id)"
/> />
<v-list-item
prepend-icon="mdi-trash-can-outline"
title="Delete"
@click="showDeleteDialog = true"
/>
</v-list> </v-list>
</v-menu> </v-menu>
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card title="Delete Flight">
<v-card-text>Are you sure you want to delete this flight?</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn v-if="!deleting" @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn
color="error"
:loading="deleting"
@click="deleting = true; router.delete(route('flights.delete', { flight: (item as Flight).id }), { onFinish: () => deleting = false })"
>
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template> </template>
</td> </td>
@@ -277,7 +311,7 @@ watch(
<template #no-data> <template #no-data>
<div class="no-data"> <div class="no-data">
<span>NO FLIGHTS ON RECORD</span> <span>NO FLIGHTS DEPARTING</span>
</div> </div>
</template> </template>
</v-data-table> </v-data-table>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import {
UserAction,
UserActionFlightBookedData,
UserActionFlightCancelledData,
UserActionFlightUpdatedData
} from "@/Types/types";
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import FlightBookedFeedItem from "@/Components/FlightsGoneBy/Feed/FlightBookedFeedItem.vue";
import {computed} from "vue";
import {Link} from "@inertiajs/vue3";
import FlightUpdatedFeedItem from "@/Components/FlightsGoneBy/Feed/FlightUpdatedFeedItem.vue";
import FlightCancelledFeedItem from "@/Components/FlightsGoneBy/Feed/FlightCancelledFeedItem.vue";
const props = defineProps<{
action: UserAction
}>()
const flight = computed(() =>{
if (props.action.type === 'flight_booked' || props.action.type === 'flight_logged'){
return (props.action.data as UserActionFlightBookedData).flight
} else if (props.action.type === 'flight_updated'){
return (props.action.data as UserActionFlightUpdatedData).updated
} else {
return null
}
})
const badgeVariant = computed(() => {
switch (props.action.type) {
case 'flight_booked': return 'generic'
case 'flight_logged': return 'generic'
case 'flight_updated': return 'economy'
case 'flight_cancelled': return 'crew'
default: return 'economy'
}
})
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}
</script>
<template>
<div class="feed-item glass glass-border">
<div class="card-top">
<div class="avatar">
<Link :href="route('profile.view', { user: action.user?.name })">
{{ action.user?.name?.charAt(0).toUpperCase() ?? '?' }}
</Link>
</div>
<div class="meta">
<span class="name">
<Link :href="route('profile.view', { user: action.user?.name })">
{{ action.user?.name ?? 'Unknown' }}
</Link>
</span>
<span class="time">{{ timeAgo(action.created_at) }}</span>
</div>
<span class="type-badge">
<InlineBadge :variant="badgeVariant">{{ action.display_type }}</InlineBadge>
</span>
</div>
<div class="card-content">
<FlightBookedFeedItem v-if="action.type == 'flight_booked' || action.type == 'flight_logged'" :flight="(action.data as UserActionFlightBookedData).flight" ></FlightBookedFeedItem>
<FlightUpdatedFeedItem v-if="action.type == 'flight_updated'" :data="(action.data as UserActionFlightUpdatedData)" ></FlightUpdatedFeedItem>
<FlightCancelledFeedItem v-if="action.type == 'flight_cancelled'" :data="(action.data as UserActionFlightCancelledData)" flight=""></FlightCancelledFeedItem>
</div>
<div class="card-actions">
<Link v-if="flight" :href="`/u/${action.user?.name}/departure-board/${flight?.id}`" class="view-flight-link">
View Flight
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6"/>
</svg>
</Link>
</div>
</div>
</template>
<style scoped>
.feed-item {
padding: 0;
overflow: hidden;
}
.card-top {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem;
}
.card-content {
padding: 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
padding: 0.75rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.avatar {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: rgba(99, 102, 241, 0.2);
border: 1px solid rgba(99, 102, 241, 0.35);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
color: #818cf8;
flex-shrink: 0;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
}
.name {
font-weight: 600;
font-size: 0.9rem;
}
.time {
font-size: 0.75rem;
color: #9ca3af;
}
.changes {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 1rem;
margin: 0;
font-size: 0.82rem;
}
.changes dt {
color: #9ca3af;
text-transform: capitalize;
white-space: nowrap;
}
.changes dd {
margin: 0;
font-weight: 500;
word-break: break-word;
}
.view-flight-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
font-weight: 500;
color: #818cf8;
text-decoration: none;
padding: 0.35rem 0.75rem;
border: 1px solid rgba(99, 102, 241, 0.25);
background: rgba(99, 102, 241, 0.08);
transition: background 0.15s, border-color 0.15s;
}
.view-flight-link:hover {
background: rgba(99, 102, 241, 0.18);
border-color: rgba(99, 102, 241, 0.45);
}
.view-flight-link svg {
width: 0.85rem;
height: 0.85rem;
}
</style>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import {Flight, UserActionChange} from "@/Types/types";
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
import {computed} from "vue";
import GenericFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/GenericFieldChange.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
const props = defineProps<{
original: Flight
updated: Flight
}>()
</script>
<template>
<GenericFieldChange label="Aircraft">
<template #from>
<AircraftToolTip v-if="original.aircraft" :aircraft="original.aircraft">
{{ original.aircraft.display_name}}
</AircraftToolTip>
<span v-else>None</span>
</template>
<template #to>
<AircraftToolTip v-if="updated.aircraft" :aircraft="updated.aircraft">
{{ updated.aircraft.display_name}}
</AircraftToolTip>
<span v-else>None</span>
</template>
</GenericFieldChange>
</template>
<style scoped>
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import {Flight, UserActionChange} from "@/Types/types";
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
import {computed} from "vue";
import GenericFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/GenericFieldChange.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
const props = defineProps<{
original: Flight
updated: Flight
}>()
</script>
<template>
<GenericFieldChange label="Airline">
<template #from>
<span class="airline">
<AirlineLogo :airline="original.airline" />
{{ original.airline?.display_name ?? 'None' }}
</span>
</template>
<template #to>
<span class="airline">
<AirlineLogo :airline="updated.airline" />
{{ updated.airline?.display_name ?? 'None' }}
</span>
</template>
</GenericFieldChange>
</template>
<style scoped>
.airline {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.from .airline {
text-decoration: line-through;
}
</style>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import {Flight, UserActionChange} from "@/Types/types";
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
import {computed} from "vue";
import GenericFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/GenericFieldChange.vue";
const props = defineProps<{
change: UserActionChange
label: string
original: Flight
updated: Flight
}>()
const originalAirport = computed(() =>
props.change.field === 'departure_airport_id'
? props.original.departure_airport
: props.original.arrival_airport
)
const updatedAirport = computed(() =>
props.change.field === 'departure_airport_id'
? props.updated.departure_airport
: props.updated.arrival_airport
)
</script>
<template>
<GenericFieldChange :label="label">
<template #from>
<AirportToolTip :airport="originalAirport">
{{ originalAirport.municipality }} ({{originalAirport.display_code}})
</AirportToolTip>
</template>
<template #to>
<AirportToolTip :airport="updatedAirport">
{{ updatedAirport.municipality }} ({{updatedAirport.display_code}})
</AirportToolTip>
</template>
</GenericFieldChange>
</template>
<style scoped>
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import {Flight, UserActionChange} from "@/Types/types";
import GenericFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/GenericFieldChange.vue";
import {computed} from "vue";
const props = defineProps<{
change: UserActionChange
label: string
original: Flight
updated: Flight
}>()
const displayField = computed(() =>
props.change.field === 'departure_date' ? 'departure_date_display' : 'arrival_date_display'
)
const timeField = computed(() =>
props.change.field === 'departure_date' ? 'departure_time_display' : 'arrival_time_display'
)
</script>
<template>
<GenericFieldChange :label="label">
<template #from>{{ (original as any)[displayField] }} at {{ (original as any)[timeField] }}</template>
<template #to>{{ (updated as any)[displayField] }} at {{ (updated as any)[timeField] }}</template>
</GenericFieldChange>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import {Flight, UserActionChange} from "@/Types/types";
import GenericFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/GenericFieldChange.vue";
import FlightClassBadge from "@/Components/FlightsGoneBy/FlightClassBadge.vue";
const props = defineProps<{
original: Flight
updated: Flight
}>()
</script>
<template>
<GenericFieldChange label="Flight Class">
<template #from>
<span style="display:inline-flex"><FlightClassBadge style="text-decoration:line-through" :flight="original"/></span>
</template>
<template #to>
<span style="display:inline-flex"><FlightClassBadge :flight="updated"/></span>
</template>
</GenericFieldChange>
</template>
<style scoped>
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
defineProps<{
label: string
}>()
</script>
<template>
<div class="field-change">
<span class="field-label">{{ label }}</span>
<div class="field-values">
<span class="from">
<slot name="from" />
</span>
<svg class="arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M13 6l6 6-6 6"/>
</svg>
<span class="to">
<slot name="to" />
</span>
</div>
</div>
</template>
<style scoped>
.field-change {
display: grid;
grid-template-columns: 180px 1fr;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-radius: 6px;
}
.field-change:nth-child(odd) {
background: rgba(255, 255, 255, 0.03);
}
.field-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.field-values {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.85rem;
min-width: 0;
}
.from {
color: #6b7280;
text-decoration: line-through;
word-break: break-word;
min-width: 0;
}
.arrow {
width: 0.85rem;
height: 0.85rem;
color: #374151;
flex-shrink: 0;
}
.to {
color: #f9fafb;
font-weight: 500;
word-break: break-word;
min-width: 0;
}
@media (max-width: 600px) {
.field-change {
grid-template-columns: 1fr;
gap: 0.3rem;
}
.field-values {
display: grid;
grid-template-columns: 1fr 1rem 1fr;
align-items: center;
gap: 0.4rem;
}
.arrow {
justify-self: center;
}
.to {
font-size: 0.9rem;
}
}
</style>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import {Flight} from "@/Types/types";
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
const props = defineProps<{
flight: Flight
}>()
</script>
<template>
<div class="flight-booked">
<BoardingPass :class="`feed-boarding-pass`" :flight="flight" />
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import {Flight, UserActionFlightCancelledData} from "@/Types/types";
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import FlightClassBadge from "@/Components/FlightsGoneBy/FlightClassBadge.vue";
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPass.vue";
const props = defineProps<{
data: UserActionFlightCancelledData
}>()
</script>
<template>
<div class="flight-booked">
<div class="cancelled-flight">
<span v-if="data.flight.flight_number" class="flight-summary">
<AirlineLogo :airline="data.flight.airline" />
<span>Flight <strong>{{ data.flight.flight_number }}</strong> on {{ data.flight.departure_date_display }} at {{ data.flight.departure_time_display }}</span>
</span>
<span v-else class="flight-summary">
<AirlineLogo :airline="data.flight.airline" />
<span>Flight from {{ data.flight.departure_airport.municipality }} ({{ data.flight.departure_airport.display_code }}) {{ data.flight.arrival_airport.municipality }} ({{ data.flight.arrival_airport.display_code }}) on {{ data.flight.departure_date_display }} at {{ data.flight.departure_time_display }}</span>
</span>
</div>
</div>
</template>
<style scoped>
.cancelled-flight {
padding: 0.75rem 1.25rem;
text-decoration: line-through;
}
.flight-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #9ca3af;
}
.flight-summary strong {
color: #f9fafb;
font-weight: 600;
}
</style>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import {Flight, UserActionFlightUpdatedData} from "@/Types/types";
import AirportFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/AirportFieldChange.vue";
import AircraftFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/AircraftFieldChange.vue";
import AirlineFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/AirlineFieldChange.vue";
import GenericFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/GenericFieldChange.vue";
import DateFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/DateFieldChange.vue";
import FlightClassFieldChange from "@/Components/FlightsGoneBy/Feed/FieldChanges/FlightClassFieldChange.vue";
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import FlightClassBadge from "@/Components/FlightsGoneBy/FlightClassBadge.vue";
const props = defineProps<{
data: UserActionFlightUpdatedData
}>()
const fieldLabels: Record<string, string> = {
flight_number: 'Flight Number',
departure_date: 'Departure Date',
arrival_date: 'Arrival Date',
departure_airport_id: 'Departure Airport',
arrival_airport_id: 'Arrival Airport',
airline_id: 'Airline',
aircraft_id: 'Aircraft',
aircraft_registration: 'Aircraft Registration',
flight_class_id: 'Flight Class',
seat_number: 'Seat Number',
seat_type_id: 'Seat Type',
flight_reason_id: 'Flight Reason',
crew_type_id: 'Crew Type',
note: 'Note',
auto_update: 'Auto Update',
}
const relationMap: Record<string, { relation: string, display: string }> = {
flight_reason_id: { relation: 'flight_reason', display: 'name' },
seat_type_id: { relation: 'seat_type', display: 'name' },
flight_class_id: { relation: 'flight_class', display: 'name' },
crew_type_id: { relation: 'crew_type', display: 'name' },
}
function resolveValue(flight: Flight, field: string): string {
const mapping = relationMap[field]
if (mapping) {
return (flight as any)[mapping.relation]?.[mapping.display] ?? 'None'
}
return (flight as any)[field] ?? 'None'
}
</script>
<template>
<div class="updated-flight">
<span v-if="data.updated.flight_number" class="flight-summary">
<AirlineLogo :airline="data.updated.airline" />
<span>Flight <strong>{{ data.updated.flight_number }}</strong> on {{ data.updated.departure_date_display }} at {{ data.updated.departure_time_display }}</span>
</span>
<span v-else class="flight-summary">
<AirlineLogo :airline="data.updated.airline" />
<span>Flight from {{ data.updated.departure_airport.municipality }} ({{ data.updated.departure_airport.display_code }}) {{ data.updated.arrival_airport.municipality }} ({{ data.updated.arrival_airport.display_code }}) on {{ data.updated.departure_date_display }} at {{ data.updated.departure_time_display }}</span>
</span>
</div>
<div class="changes">
<template v-for="change in data.changes" :key="change.field">
<AirportFieldChange
v-if="change.field === 'departure_airport_id' || change.field === 'arrival_airport_id'"
:change="change"
:label="change.field === 'departure_airport_id' ? 'Departure Airport' : 'Arrival Airport'"
:original="data.original"
:updated="data.updated"
/>
<AircraftFieldChange
v-else-if="change.field === 'aircraft_id'"
:original="data.original"
:updated="data.updated"
/>
<AirlineFieldChange
v-else-if="change.field === 'airline_id'"
:original="data.original"
:updated="data.updated"
/>
<DateFieldChange
v-else-if="change.field === 'departure_date' || change.field === 'arrival_date'"
:change="change"
:label="change.field === 'departure_date' ? 'Departure Date' : 'Arrival Date'"
:original="data.original"
:updated="data.updated"
/>
<FlightClassFieldChange
v-else-if="change.field === 'flight_class_id'"
:original="data.original"
:updated="data.updated"
/>
<GenericFieldChange
v-else
:label="fieldLabels[change.field] ?? change.field"
>
<template #from>{{ resolveValue(data.original, change.field) }}</template>
<template #to>{{ resolveValue(data.updated, change.field) }}</template>
</GenericFieldChange>
</template>
</div>
</template>
<style scoped>
.changes {
display: flex;
flex-direction: column;
}
.updated-flight {
padding: 0.75rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.flight-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #9ca3af;
}
.flight-summary strong {
color: #f9fafb;
font-weight: 600;
}
</style>
@@ -12,6 +12,7 @@ const emit = defineEmits<{
<template> <template>
<div class="view-toolbar"> <div class="view-toolbar">
<button <button
class="view-btn" class="view-btn"
:class="{ active: activeView === 'map' }" :class="{ active: activeView === 'map' }"
+1 -2
View File
@@ -46,7 +46,7 @@ const lookupError = ref<string | null>(null)
const lookupComplete = ref(true) const lookupComplete = ref(true)
interface LookupResult { interface LookupResult {
airline_options: { value: number; title: string }[] airline_options: { value: number; title: string, logo_url: string }[]
from_options: { value: number; title: string; country_code: string }[] from_options: { value: number; title: string; country_code: string }[]
to_options: { value: number; title: string; country_code: string }[] to_options: { value: number; title: string; country_code: string }[]
aircraft_options: { value: number; title: string }[] aircraft_options: { value: number; title: string }[]
@@ -72,7 +72,6 @@ async function lookupFlight() {
} }
lookupResult.value = data lookupResult.value = data
lookupComplete.value = true lookupComplete.value = true
if (data.airline_options?.length) { if (data.airline_options?.length) {
airlineOptionsData.value = data.airline_options airlineOptionsData.value = data.airline_options
if (!form.airline) form.airline = data.airline_options[0] if (!form.airline) form.airline = data.airline_options[0]
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import {User, UserAction} from "@/Types/types";
import { Head } from "@inertiajs/vue3";
import FeedItem from "@/Components/FlightsGoneBy/Feed/FeedItem.vue";
defineOptions({ layout: MainLayout })
const props = defineProps<{
user: User
feed: UserAction[]
}>()
</script>
<template>
<Head title="Feed" />
<div class="feed-page">
<header class="feed-header">
<h1>Feed</h1>
<span class="feed-count">{{ feed.length }} updates</span>
</header>
<div v-if="feed.length === 0" class="empty">
<p>Nothing here yet follow someone to see their flight updates!</p>
</div>
<div class="feed-list">
<FeedItem v-for="action in feed" :key="action.id" :action="action" />
</div>
</div>
</template>
<style scoped>
.feed-page {
margin: 0 auto;
padding: 2rem 1rem;
width: 55%;
}
@media (max-width: 768px) {
.feed-page {
width: 100%;
}
}
.feed-header {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.feed-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.feed-count {
font-size: 0.85rem;
color: #6b7280;
}
.empty p {
text-align: center;
margin-top: 3rem;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 2rem;
}
</style>
+33 -1
View File
@@ -8,7 +8,7 @@ declare module '@vue/runtime-core' {
} }
} }
export type ProfileView = 'map' | 'board' | 'passes'; 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 type ChartType = "line" | "area" | "bar" | "pie" | "donut" | "radialBar" | "scatter" | "bubble" | "heatmap" | "candlestick" | "boxPlot" | "radar" | "polarArea" | "rangeBar" | "rangeArea" | "treemap" | undefined
export type BadgeVariant = 'first' | 'business' | 'premium' | 'economy' | 'private' | 'unspecified' | 'generic' | 'general_aviation' | 'crew' export type BadgeVariant = 'first' | 'business' | 'premium' | 'economy' | 'private' | 'unspecified' | 'generic' | 'general_aviation' | 'crew'
@@ -19,6 +19,38 @@ export interface User {
email_verified_at: string | null email_verified_at: string | null
} }
export type UserActionType = "flight_cancelled" | "flight_booked" | "flight_updated" | "flight_logged" | "flight_deleted"
export type UserActionDataKey = "field" | "from" | "to"
export type UserActionFlightBookedData = {
flight: Flight
}
export type UserActionFlightCancelledData = {
flight: Flight
}
export type UserActionFlightUpdatedData = {
changes: UserActionChange[],
original: Flight,
updated: Flight
}
export type UserActionChange = {field: string, from: string, to: string}
export type UserActionData = UserActionFlightBookedData | UserActionFlightCancelledData | UserActionFlightUpdatedData
export interface UserAction {
id: number
user_id: number
type: UserActionType
data: UserActionData
created_at: string
updated_at: string
user: User
display_type: string
user_flight: Flight
}
export type SharedProps = import('@inertiajs/core').PageProps & { export type SharedProps = import('@inertiajs/core').PageProps & {
auth: { auth: {
user: User | null user: User | null
+4 -3
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\Api\AirlineApiController; use App\Http\Controllers\Api\AirlineApiController;
use App\Http\Controllers\Api\UserApiController; use App\Http\Controllers\Api\UserApiController;
use App\Http\Controllers\FeedController;
use App\Http\Controllers\FlightController; use App\Http\Controllers\FlightController;
use App\Http\Controllers\FlightImportController; use App\Http\Controllers\FlightImportController;
use App\Http\Controllers\FlightProfileController; use App\Http\Controllers\FlightProfileController;
@@ -44,6 +45,7 @@ Route::domain(config('app.domain'))->group(
Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add'); Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add');
Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit'); Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit');
Route::put('/flights/{flight}', [FlightController::class, 'update'])->name('flights.update'); Route::put('/flights/{flight}', [FlightController::class, 'update'])->name('flights.update');
Route::delete('/flights/{flight}', [FlightController::class, 'delete'])->name('flights.delete');
Route::get('/reconcile', function () { Route::get('/reconcile', function () {
$flight = new FlightImportController()->reconcile(request()); $flight = new FlightImportController()->reconcile(request());
@@ -63,9 +65,8 @@ Route::domain(config('app.domain'))->group(
Route::post('/u/{user}/follow', [UserController::class, 'follow'])->name('profile.follow'); Route::post('/u/{user}/follow', [UserController::class, 'follow'])->name('profile.follow');
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::get('/feed', [FeedController::class, 'view'])->name('feed');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
}); });
Route::post('/import/save', [FlightImportController::class, 'save'])->name('import.save'); Route::post('/import/save', [FlightImportController::class, 'save'])->name('import.save');