Session api token validation
This commit is contained in:
@@ -16,6 +16,10 @@ use Inertia\Inertia;
|
|||||||
|
|
||||||
class UserProfileController extends Controller
|
class UserProfileController extends Controller
|
||||||
{
|
{
|
||||||
|
public function currentUserCanEditProfile(User $profileUser){
|
||||||
|
return auth()->check() && (auth()->id() === $profileUser->id || auth()->user()->hasRole('admin'));
|
||||||
|
}
|
||||||
|
|
||||||
public function index(){
|
public function index(){
|
||||||
if (auth()->check()) {
|
if (auth()->check()) {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@@ -39,7 +43,7 @@ class UserProfileController extends Controller
|
|||||||
return [
|
return [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'canView' => Gate::allows('viewProfileData', $user),
|
'canView' => Gate::allows('viewProfileData', $user),
|
||||||
'canEdit' => auth()->check() && (auth()->id() === $user->id || auth()->user()->hasRole('admin')),
|
'canEdit' => $this->currentUserCanEditProfile($user),
|
||||||
'initialView' => $view,
|
'initialView' => $view,
|
||||||
'selectedFlightId' => $selectedFlightId,
|
'selectedFlightId' => $selectedFlightId,
|
||||||
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||||
@@ -85,10 +89,11 @@ class UserProfileController extends Controller
|
|||||||
return Inertia::render('UserFlight', [
|
return Inertia::render('UserFlight', [
|
||||||
'flightCount' => $user->departedFlights()->count(),
|
'flightCount' => $user->departedFlights()->count(),
|
||||||
'flight' => $userFlight->snapshot($userFlight->id),
|
'flight' => $userFlight->snapshot($userFlight->id),
|
||||||
'canEdit' => auth()->check() && auth()->id() === $user->id,
|
'canEdit' => $this->currentUserCanEditProfile($user),
|
||||||
'canView' => Gate::allows('viewProfileData', $user),
|
'canView' => Gate::allows('viewProfileData', $user),
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||||
|
'otherUsersOnFlight' => $userFlight->otherUsersOnFlight(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +133,26 @@ class UserProfileController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function copyFlight(UserFlight $flight)
|
||||||
|
{
|
||||||
|
$loggedInUser = auth()->user();
|
||||||
|
|
||||||
|
$exists = UserFlight::where('user_id', $loggedInUser->id)
|
||||||
|
->where('departure_airport_id', $flight->departure_airport_id)
|
||||||
|
->where('arrival_airport_id', $flight->arrival_airport_id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
return back()->withErrors(['flight' => 'You already have a flight logged between these airports on this date.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newFlight = $flight->replicate();
|
||||||
|
$newFlight->user_id = $loggedInUser->id;
|
||||||
|
$newFlight->save();
|
||||||
|
|
||||||
|
return redirect()->route('flights.edit', ['flight' => $newFlight->id]);
|
||||||
|
}
|
||||||
|
|
||||||
public function achievement(User $user, Achievement $achievement)
|
public function achievement(User $user, Achievement $achievement)
|
||||||
{
|
{
|
||||||
$regions = match($achievement->internal_name){
|
$regions = match($achievement->internal_name){
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
|
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
|
||||||
'apiToken' => session('api_token'),
|
'apiToken' => session('api_token'),
|
||||||
],
|
],
|
||||||
|
'flash' => [
|
||||||
|
'success' => $request->session()->get('success'),
|
||||||
|
'error' => $request->session()->get('error'),
|
||||||
|
],
|
||||||
'achievement_notifications' => fn() => $request->user()
|
'achievement_notifications' => fn() => $request->user()
|
||||||
? $request->user()
|
? $request->user()
|
||||||
->notifications()
|
->notifications()
|
||||||
|
|||||||
@@ -182,6 +182,20 @@ class UserFlight extends Model
|
|||||||
return !$this->isDomestic();
|
return !$this->isDomestic();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function otherUsersOnFlight(){
|
||||||
|
return UserFlight::where('user_id', '!=', $this->user_id)
|
||||||
|
->where('departure_airport_id', $this->departure_airport_id)
|
||||||
|
->where('arrival_airport_id', $this->arrival_airport_id)
|
||||||
|
->where('flight_number', $this->flight_number)
|
||||||
|
->where('airline_id', $this->airline_id)
|
||||||
|
->whereRaw('DATE(departure_date AT TIME ZONE \'UTC\') = ?', [
|
||||||
|
Carbon::parse($this->departure_date)->utc()->toDateString()
|
||||||
|
])
|
||||||
|
->with('user')
|
||||||
|
->get()
|
||||||
|
->pluck('user.name');
|
||||||
|
}
|
||||||
|
|
||||||
public static function snapshot($userFlightId): array
|
public static function snapshot($userFlightId): array
|
||||||
{
|
{
|
||||||
return UserFlight::with([
|
return UserFlight::with([
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class FlightObserver
|
|||||||
//Make queued task if the site gets big
|
//Make queued task if the site gets big
|
||||||
$flight->user->followers()
|
$flight->user->followers()
|
||||||
->get()
|
->get()
|
||||||
->each(fn ($follower) => Cache::forget("user_following_flights_{$follower->id}"));
|
->each(fn ($follower) => Cache::forget("user_following_flights_{$follower->user_id}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class SettingsRegistry
|
|||||||
'type' => 'checkbox',
|
'type' => 'checkbox',
|
||||||
'label' => 'Hide Map Filters By Default',
|
'label' => 'Hide Map Filters By Default',
|
||||||
'category' => 'FlightsGoneBy Settings',
|
'category' => 'FlightsGoneBy Settings',
|
||||||
'default' => false,
|
'default' => true,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'hide_impossible_achievements',
|
'key' => 'hide_impossible_achievements',
|
||||||
@@ -111,8 +111,8 @@ class SettingsRegistry
|
|||||||
'options' => [
|
'options' => [
|
||||||
['value' => 'airline', 'label' => 'Airline'],
|
['value' => 'airline', 'label' => 'Airline'],
|
||||||
['value' => 'flight_number', 'label' => 'Flight Number'],
|
['value' => 'flight_number', 'label' => 'Flight Number'],
|
||||||
['value' => 'from', 'label' => 'From'],
|
['value' => 'departure_airport', 'label' => 'From'],
|
||||||
['value' => 'to', 'label' => 'To'],
|
['value' => 'arrival_airport', 'label' => 'To'],
|
||||||
['value' => 'departure_date', 'label' => 'Departure Date'],
|
['value' => 'departure_date', 'label' => 'Departure Date'],
|
||||||
['value' => 'departure_time', 'label' => 'Departure Time'],
|
['value' => 'departure_time', 'label' => 'Departure Time'],
|
||||||
['value' => 'arrival_time', 'label' => 'Arrival Time'],
|
['value' => 'arrival_time', 'label' => 'Arrival Time'],
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ const showColumn = (column: string) => columnsToShow.value.includes(column)
|
|||||||
const allHeaders = [
|
const allHeaders = [
|
||||||
{ title: '', key: 'airline', sortable: true },
|
{ title: '', key: 'airline', sortable: true },
|
||||||
{ title: 'FLIGHT', key: 'flight_number', sortable: true },
|
{ title: 'FLIGHT', key: 'flight_number', sortable: true },
|
||||||
{ title: 'FROM', key: 'from', sortable: true },
|
{ title: 'FROM', key: 'departure_airport', sortable: true },
|
||||||
{ title: 'TO', key: 'to', sortable: true },
|
{ title: 'TO', key: 'arrival_airport', sortable: true },
|
||||||
{ title: 'DATE', key: 'departure_date', sortable: true },
|
{ title: 'DATE', key: 'departure_date', sortable: true },
|
||||||
{ title: 'DEPART', key: 'departure_time', sortable: false },
|
{ title: 'DEPART', key: 'departure_time', sortable: false },
|
||||||
{ title: 'ARRIVE', key: 'arrival_time', sortable: false },
|
{ title: 'ARRIVE', key: 'arrival_time', sortable: false },
|
||||||
|
|||||||
@@ -49,14 +49,14 @@ const showColumn = (column: string) => props.columnsToShow.includes(column)
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td v-if="showColumn('from')" class="v-data-table__td">
|
<td v-if="showColumn('departure_airport')" class="v-data-table__td">
|
||||||
<AirportToolTip :airport="flight.departure_airport">
|
<AirportToolTip :airport="flight.departure_airport">
|
||||||
<Mono class="iata">{{ flight.departure_airport.display_code }}</Mono><br/>
|
<Mono class="iata">{{ flight.departure_airport.display_code }}</Mono><br/>
|
||||||
</AirportToolTip>
|
</AirportToolTip>
|
||||||
<span class="city-name">{{ flight.departure_airport.municipality }}</span>
|
<span class="city-name">{{ flight.departure_airport.municipality }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td v-if="showColumn('to')" class="v-data-table__td">
|
<td v-if="showColumn('arrival_airport')" class="v-data-table__td">
|
||||||
<AirportToolTip :airport="flight.arrival_airport">
|
<AirportToolTip :airport="flight.arrival_airport">
|
||||||
<span class="iata"><Mono>{{ flight.arrival_airport.display_code }}</Mono></span><br/>
|
<span class="iata"><Mono>{{ flight.arrival_airport.display_code }}</Mono></span><br/>
|
||||||
</AirportToolTip>
|
</AirportToolTip>
|
||||||
|
|||||||
@@ -40,26 +40,4 @@ defineProps<{
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 0.25rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import PanelLabel from "@/Components/FlightsGoneBy/Panels/PanelLabel.vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
label?: string
|
label?: string
|
||||||
}>()
|
}>()
|
||||||
@@ -6,30 +8,16 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="panel glass glass-border">
|
<div class="panel glass glass-border">
|
||||||
<div v-if="label" class="panel-label">{{label}}</div>
|
<PanelLabel v-if="label">{{label}}</PanelLabel>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
/* Panels */
|
|
||||||
.panel {
|
.panel {
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="panel-label">
|
||||||
|
<slot/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { usePage } from "@inertiajs/vue3";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { SharedProps } from "@/Types/types";
|
||||||
|
|
||||||
|
const page = usePage<SharedProps>().props;
|
||||||
|
|
||||||
|
const errors = computed(() => Object.values(page.errors ?? {}));
|
||||||
|
const success = computed(() => page.flash?.success);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="status-notifications">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<v-alert
|
||||||
|
v-for="(error, i) in errors"
|
||||||
|
:key="`error-${i}`"
|
||||||
|
type="error"
|
||||||
|
variant="flat"
|
||||||
|
rounded="0"
|
||||||
|
closable
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="success"
|
||||||
|
key="success"
|
||||||
|
type="success"
|
||||||
|
variant="flat"
|
||||||
|
rounded="0"
|
||||||
|
closable
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
{{ success }}
|
||||||
|
</v-alert>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.status-notifications {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9999;
|
||||||
|
width: 360px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { router } from "@inertiajs/vue3";
|
||||||
|
import { Notification } from "@/Types/types";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const activeToasts = ref<Notification[]>([])
|
||||||
|
const seenNotificationIds = ref<Set<number>>(new Set())
|
||||||
|
const achievementSound = new Audio('/sounds/seatBelt.wav')
|
||||||
|
|
||||||
|
function handleNewNotifications(notifications: Notification[]) {
|
||||||
|
if (!notifications?.length) return
|
||||||
|
|
||||||
|
const newToasts: Notification[] = []
|
||||||
|
for (const n of notifications) {
|
||||||
|
if (!seenNotificationIds.value.has(n.id)) {
|
||||||
|
seenNotificationIds.value.add(n.id)
|
||||||
|
newToasts.push(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newToasts.length) return
|
||||||
|
activeToasts.value.push(...newToasts)
|
||||||
|
achievementSound.play().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissToast(notification: Notification) {
|
||||||
|
activeToasts.value = activeToasts.value.filter(n => n.id !== notification.id)
|
||||||
|
await axios.patch(`/notifications/${notification.id}/read`)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.on('success', (event) => {
|
||||||
|
handleNewNotifications(event.detail.page.props.achievement_notifications as Notification[] ?? [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle initial page load notifications
|
||||||
|
import { usePage } from "@inertiajs/vue3";
|
||||||
|
import { SharedProps } from "@/Types/types";
|
||||||
|
const page = usePage<SharedProps>().props;
|
||||||
|
watch(
|
||||||
|
() => page.achievement_notifications,
|
||||||
|
handleNewNotifications,
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-stack">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<v-card
|
||||||
|
v-for="notification in activeToasts"
|
||||||
|
:key="notification.id"
|
||||||
|
class="toast-card glass"
|
||||||
|
rounded="lg"
|
||||||
|
elevation="4"
|
||||||
|
max-width="360"
|
||||||
|
>
|
||||||
|
<v-card-text class="d-flex align-center ga-3">
|
||||||
|
<v-icon icon="mdi-trophy" color="amber" size="32" />
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-close"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="ml-auto"
|
||||||
|
@click="dismissToast(notification)"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
z-index: 9999;
|
||||||
|
max-height: 50dvh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-stack::-webkit-scrollbar { display: none; }
|
||||||
|
.toast-card { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.toast-enter-from { opacity: 0; transform: translateX(100%); }
|
||||||
|
.toast-leave-to { opacity: 0; transform: translateX(100%); }
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active { transition: all 0.3s ease; }
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref} from "vue";
|
import {computed, ref} from "vue";
|
||||||
import {Flight, User} from "@/Types/types";
|
import {Flight, SharedProps, User} from "@/Types/types";
|
||||||
import {router} from "@inertiajs/vue3";
|
import {router, usePage} from "@inertiajs/vue3";
|
||||||
import { copyToClipboard } from "@/Composables/useClipboard";
|
import { copyToClipboard } from "@/Composables/useClipboard";
|
||||||
|
|
||||||
|
const loggedInUser = usePage<SharedProps>().props.auth.user?.id
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
profileUser: User
|
profileUser: User
|
||||||
flight: Flight
|
flight: Flight
|
||||||
@@ -56,6 +58,12 @@ function copyFlightToClipboard(flight: Flight) {
|
|||||||
title="View Details"
|
title="View Details"
|
||||||
:href="route('profile.flight', { userFlight: flight.id, user: profileUser.name })"
|
:href="route('profile.flight', { userFlight: flight.id, user: profileUser.name })"
|
||||||
/>
|
/>
|
||||||
|
<v-list-item
|
||||||
|
v-if="loggedInUser && loggedInUser !== profileUser.id"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
title="Log Flight for Myself"
|
||||||
|
:href="route('flights.copy', { flight: flight.id })"
|
||||||
|
/>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-content-copy"
|
prepend-icon="mdi-content-copy"
|
||||||
title="Copy to Clipboard"
|
title="Copy to Clipboard"
|
||||||
@@ -72,6 +80,7 @@ function copyFlightToClipboard(flight: Flight) {
|
|||||||
@click="flightToDelete = flight"
|
@click="flightToDelete = flight"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
<v-dialog v-if="canEdit" v-model="showDeleteDialog" max-width="400">
|
<v-dialog v-if="canEdit" v-model="showDeleteDialog" max-width="400">
|
||||||
|
|||||||
@@ -2,54 +2,18 @@
|
|||||||
import MainHeader from "@/Components/FlightsGoneBy/MainHeader.vue";
|
import MainHeader from "@/Components/FlightsGoneBy/MainHeader.vue";
|
||||||
import MainFooter from "@/Components/FlightsGoneBy/MainFooter.vue";
|
import MainFooter from "@/Components/FlightsGoneBy/MainFooter.vue";
|
||||||
import Radar from "@/Components/FlightsGoneBy/Radar.vue";
|
import Radar from "@/Components/FlightsGoneBy/Radar.vue";
|
||||||
import { usePage, router } from "@inertiajs/vue3";
|
import StatusNotifications from "@/Components/FlightsGoneBy/StatusNotifications.vue";
|
||||||
import { ref, watch } from "vue";
|
import ToastNotifications from "@/Components/FlightsGoneBy/ToastNotifications.vue";
|
||||||
import { SharedProps, Notification } from "@/Types/types";
|
import { router } from "@inertiajs/vue3";
|
||||||
import axios from "axios";
|
import { ref } from "vue";
|
||||||
|
|
||||||
const page = usePage<SharedProps>().props;
|
|
||||||
const transitionKey = ref(0);
|
const transitionKey = ref(0);
|
||||||
|
|
||||||
const seenNotificationIds = ref<Set<number>>(new Set())
|
router.on('success', () => {
|
||||||
|
|
||||||
const achievementSound = new Audio('/sounds/seatBelt.wav')
|
|
||||||
|
|
||||||
function handleNewNotifications(notifications: Notification[]) {
|
|
||||||
if (!notifications?.length) return
|
|
||||||
|
|
||||||
const newToasts: Notification[] = []
|
|
||||||
for (const n of notifications) {
|
|
||||||
if (!seenNotificationIds.value.has(n.id)) {
|
|
||||||
seenNotificationIds.value.add(n.id)
|
|
||||||
newToasts.push(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newToasts.length) return
|
|
||||||
activeToasts.value.push(...newToasts)
|
|
||||||
achievementSound.play().catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ── Toasts ────────────────────────────────────────────────────────────────────
|
|
||||||
const activeToasts = ref<Notification[]>([])
|
|
||||||
|
|
||||||
async function dismissToast(notification: Notification) {
|
|
||||||
activeToasts.value = activeToasts.value.filter(n => n.id !== notification.id)
|
|
||||||
await axios.patch(`/notifications/${notification.id}/read`)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => page.achievement_notifications,
|
|
||||||
handleNewNotifications,
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
router.on('success', (event) => {
|
|
||||||
transitionKey.value++;
|
transitionKey.value++;
|
||||||
handleNewNotifications(event.detail.page.props.achievement_notifications as Notification[] ?? [])
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Radar>
|
<Radar>
|
||||||
<div class="layoutContainer">
|
<div class="layoutContainer">
|
||||||
@@ -61,65 +25,11 @@ router.on('success', (event) => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
<MainFooter :key="`footer-${transitionKey}`" />
|
<MainFooter :key="`footer-${transitionKey}`" />
|
||||||
</div>
|
</div>
|
||||||
|
<StatusNotifications />
|
||||||
<div class="toast-stack">
|
<ToastNotifications />
|
||||||
<TransitionGroup name="toast">
|
|
||||||
<v-card
|
|
||||||
v-for="notification in activeToasts"
|
|
||||||
:key="notification.id"
|
|
||||||
class="toast-card glass"
|
|
||||||
rounded="lg"
|
|
||||||
elevation="4"
|
|
||||||
max-width="360"
|
|
||||||
>
|
|
||||||
<v-card-text class="d-flex align-center ga-3">
|
|
||||||
<v-icon icon="mdi-trophy" color="amber" size="32" />
|
|
||||||
<div>
|
|
||||||
<div class="text-subtitle-2 font-weight-bold">{{ notification.title }}</div>
|
|
||||||
<div class="text-body-2 text-medium-emphasis">{{ notification.body }}</div>
|
|
||||||
</div>
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-close"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
class="ml-auto"
|
|
||||||
@click="dismissToast(notification)"
|
|
||||||
/>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</TransitionGroup>
|
|
||||||
</div>
|
|
||||||
</Radar>
|
</Radar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.toast-stack {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 1.5rem;
|
|
||||||
right: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
z-index: 9999;
|
|
||||||
max-height: 50dvh;
|
|
||||||
overflow-y: scroll;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-stack::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-card {
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: rgb(var(--v-theme-surface)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter-from { opacity: 0; transform: translateX(100%); }
|
|
||||||
.toast-leave-to { opacity: 0; transform: translateX(100%); }
|
|
||||||
.toast-enter-active,
|
|
||||||
.toast-leave-active { transition: all 0.3s ease; }
|
|
||||||
</style>
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.layoutContainer {
|
.layoutContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import MainLayout from "@/Layouts/MainLayout.vue";
|
import MainLayout from "@/Layouts/MainLayout.vue";
|
||||||
import {Head, Link} from "@inertiajs/vue3";
|
import {Head, Link} 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 {Flight, User} from "@/Types/types";
|
||||||
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPasses/BoardingPass.vue";
|
import BoardingPass from "@/Components/FlightsGoneBy/BoardingPasses/BoardingPass.vue";
|
||||||
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
|
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
|
||||||
import AirportPanel from "@/Components/FlightsGoneBy/Panels/AirportPanel.vue";
|
import AirportPanel from "@/Components/FlightsGoneBy/Panels/AirportPanel.vue";
|
||||||
@@ -11,6 +11,7 @@ import AircraftPanel from "@/Components/FlightsGoneBy/Panels/AircraftPanel.vue";
|
|||||||
import RoutePanel from "@/Components/FlightsGoneBy/Panels/RoutePanel.vue";
|
import RoutePanel from "@/Components/FlightsGoneBy/Panels/RoutePanel.vue";
|
||||||
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
|
import DetailRows from "@/Components/FlightsGoneBy/Panels/DetailRows.vue";
|
||||||
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
|
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
|
||||||
|
import PanelLabel from "@/Components/FlightsGoneBy/Panels/PanelLabel.vue";
|
||||||
|
|
||||||
defineOptions({ layout: MainLayout })
|
defineOptions({ layout: MainLayout })
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const props = defineProps<{
|
|||||||
followStatus: string
|
followStatus: string
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
canView: boolean
|
canView: boolean
|
||||||
|
otherUsersOnFlight: string[]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -42,7 +44,21 @@ const props = defineProps<{
|
|||||||
<RoutePanel :flight="flight" />
|
<RoutePanel :flight="flight" />
|
||||||
<Panel label="Flight Details">
|
<Panel label="Flight Details">
|
||||||
<BoardingPass :user="user" :showToolTips="false" style="width:100%;max-width:600px; margin:0 auto" :flight="flight" :canEdit="canEdit" />
|
<BoardingPass :user="user" :showToolTips="false" style="width:100%;max-width:600px; margin:0 auto" :flight="flight" :canEdit="canEdit" />
|
||||||
<DetailRows/>
|
<DetailRows>
|
||||||
|
<PanelLabel>Other Users On This Flight</PanelLabel>
|
||||||
|
<div class="d-flex flex-wrap ga-2">
|
||||||
|
<v-chip
|
||||||
|
v-for="userName in otherUsersOnFlight"
|
||||||
|
:key="userName"
|
||||||
|
:href="route('profile.view', userName)"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
link
|
||||||
|
>
|
||||||
|
{{ userName }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</DetailRows>
|
||||||
</Panel>
|
</Panel>
|
||||||
<AircraftPanel :flight="flight"/>
|
<AircraftPanel :flight="flight"/>
|
||||||
<AirportPanel :airport="flight.departure_airport" label="Departure" />
|
<AirportPanel :airport="flight.departure_airport" label="Departure" />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const props = defineProps<{
|
|||||||
flightCount: number
|
flightCount: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const hideFilters = ref(page.auth?.user?.resolved_settings?.hide_map_filters ?? false)
|
const hideFilters = ref(page.auth?.user?.resolved_settings?.hide_map_filters ?? true)
|
||||||
|
|
||||||
watch(hideFilters, (value) => {
|
watch(hideFilters, (value) => {
|
||||||
updateSetting('hide_map_filters', value).catch(() => {})
|
updateSetting('hide_map_filters', value).catch(() => {})
|
||||||
|
|||||||
Vendored
+4
@@ -100,6 +100,10 @@ export type SharedProps = import('@inertiajs/core').PageProps & {
|
|||||||
permissions: string[];
|
permissions: string[];
|
||||||
apiToken: string | null;
|
apiToken: string | null;
|
||||||
},
|
},
|
||||||
|
flash: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
logo_api_url: string
|
logo_api_url: string
|
||||||
achievement_notifications: Notification[]
|
achievement_notifications: Notification[]
|
||||||
unread_notification_count: number
|
unread_notification_count: number
|
||||||
|
|||||||
+5
-4
@@ -39,6 +39,10 @@ use Inertia\Inertia;
|
|||||||
Route::put('/flights/{flight}', [FlightController::class, 'update'])->name('flights.update');
|
Route::put('/flights/{flight}', [FlightController::class, 'update'])->name('flights.update');
|
||||||
Route::delete('/flights/{flight}/{referrer?}', [FlightController::class, 'delete'])->name('flights.delete');
|
Route::delete('/flights/{flight}/{referrer?}', [FlightController::class, 'delete'])->name('flights.delete');
|
||||||
|
|
||||||
|
Route::get('/flights/lookup', [FlightController::class, 'lookup'])->name('flights.lookup');
|
||||||
|
Route::post('/flights/import', [FlightImportController::class, 'store'])->name('flights.import.store');
|
||||||
|
Route::get('/flights/{flight}/copy', [UserProfileController::class, 'copyFlight'])->name('flights.copy');
|
||||||
|
|
||||||
Route::patch('/settings/{key}', [SettingsController::class, 'updateSingle'])
|
Route::patch('/settings/{key}', [SettingsController::class, 'updateSingle'])
|
||||||
->where('key', '[a-z_]+')
|
->where('key', '[a-z_]+')
|
||||||
->name('settings.update-single');
|
->name('settings.update-single');
|
||||||
@@ -47,10 +51,6 @@ use Inertia\Inertia;
|
|||||||
Route::get('/import/fr24', [FlightImportController::class, 'showFr24Import'])->name('import.fr24');
|
Route::get('/import/fr24', [FlightImportController::class, 'showFr24Import'])->name('import.fr24');
|
||||||
Route::get('/reconcile', [FlightImportController::class, 'reconcile'])->name('reconcile');;
|
Route::get('/reconcile', [FlightImportController::class, 'reconcile'])->name('reconcile');;
|
||||||
|
|
||||||
Route::get('/flights/lookup', [FlightController::class, 'lookup'])->name('flights.lookup');
|
|
||||||
Route::post('/flights/import', [FlightImportController::class, 'store'])->name('flights.import.store');
|
|
||||||
|
|
||||||
|
|
||||||
Route::get('/settings/{category?}', [UserController::class, 'settings'])->name('user.settings');
|
Route::get('/settings/{category?}', [UserController::class, 'settings'])->name('user.settings');
|
||||||
|
|
||||||
|
|
||||||
@@ -71,6 +71,7 @@ use Inertia\Inertia;
|
|||||||
|
|
||||||
Route::post('/import/save', [FlightImportController::class, 'save'])->name('import.save');
|
Route::post('/import/save', [FlightImportController::class, 'save'])->name('import.save');
|
||||||
|
|
||||||
|
|
||||||
//Search Routes
|
//Search Routes
|
||||||
Route::get('/search/airlines', [SearchController::class, 'airlines'])->name('search.airlines');
|
Route::get('/search/airlines', [SearchController::class, 'airlines'])->name('search.airlines');
|
||||||
Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');
|
Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');
|
||||||
|
|||||||
Reference in New Issue
Block a user