Updated Map View

This commit is contained in:
2026-06-20 22:21:17 +10:00
parent 6fad966b7e
commit 05ca994253
52 changed files with 2038 additions and 803 deletions
@@ -0,0 +1,122 @@
<script lang="ts" setup>
import { ref } from 'vue'
import axios from 'axios'
import type { FollowerEntry } from "@/Types/types"
const props = defineProps<{
entry: FollowerEntry
}>()
const emit = defineEmits<{
approved: []
denied: []
removed: []
}>()
const processing = ref(false)
const confirmingRemove = ref(false)
async function approve() {
processing.value = true
try {
await axios.post(`/followers/${encodeURIComponent(props.entry.user.name)}/approve`)
emit('approved')
} finally {
processing.value = false
}
}
async function deny() {
processing.value = true
try {
await axios.post(`/followers/${encodeURIComponent(props.entry.user.name)}/deny`)
emit('denied')
} finally {
processing.value = false
}
}
async function remove() {
processing.value = true
try {
await axios.delete(`/followers/${encodeURIComponent(props.entry.user.name)}`)
emit('removed')
} finally {
processing.value = false
confirmingRemove.value = false
}
}
</script>
<template>
<v-card variant="outlined" class="follower-card pa-3 d-flex align-center">
<v-icon icon="mdi-account-circle" size="40" class="mr-3" />
<div class="flex-grow-1">
<div class="text-body-2 font-weight-medium">{{ entry.user.name }}</div>
<div v-if="!entry.verified" class="text-caption text-medium-emphasis">
Wants to follow you
</div>
</div>
<template v-if="!entry.verified">
<v-btn
size="small"
color="primary"
variant="flat"
:loading="processing"
class="mr-2"
@click="approve"
>
Approve
</v-btn>
<v-btn
size="small"
variant="outlined"
:disabled="processing"
@click="deny"
>
Deny
</v-btn>
</template>
<template v-else>
<v-btn
v-if="!confirmingRemove"
size="small"
variant="text"
:disabled="processing"
@click="confirmingRemove = true"
>
Remove
</v-btn>
<template v-else>
<span class="text-caption mr-2">Remove follower?</span>
<v-btn
size="small"
color="error"
variant="flat"
:loading="processing"
class="mr-1"
@click="remove"
>
Confirm
</v-btn>
<v-btn
size="small"
variant="text"
:disabled="processing"
@click="confirmingRemove = false"
>
Cancel
</v-btn>
</template>
</template>
</v-card>
</template>
<style scoped>
.follower-card {
background: rgba(255, 255, 255, 0.02);
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import VueApexCharts from "vue3-apexcharts"
const props = defineProps<{
@@ -12,23 +12,63 @@ const props = defineProps<{
barHeight?: number
colors?: string[]
options?: object
limit?: number
}>()
const BAR_HEIGHT = computed(() => props.barHeight ?? 32)
const MAX_VISIBLE = computed(() => props.maxVisible ?? 12)
const chartHeight = computed(() => props.categories.length * BAR_HEIGHT.value + 40)
// ── Limit / Show More ─────────────────────────────────────────────────────────
const showAll = ref(false)
const isExpanding = ref(false)
async function expand() {
isExpanding.value = true
await nextTick() // paint "Loading..." before blocking on chart render
showAll.value = true
await nextTick() // wait for ApexCharts to finish
isExpanding.value = false
}
function collapse() {
showAll.value = false
}
const visibleCategories = computed(() =>
props.limit && !showAll.value
? props.categories.slice(0, props.limit)
: props.categories
)
const visibleSeries = computed(() =>
props.limit && !showAll.value
? props.series.map(s => ({ ...s, data: s.data.slice(0, props.limit) }))
: props.series
)
const hasMore = computed(() =>
!!props.limit && props.categories.length > props.limit
)
const hiddenCount = computed(() =>
props.limit ? props.categories.length - props.limit : 0
)
// ── Chart dimensions ──────────────────────────────────────────────────────────
const chartHeight = computed(() => visibleCategories.value.length * BAR_HEIGHT.value + 40)
const scrollHeight = computed(() => {
const visible = Math.min(props.categories.length, MAX_VISIBLE.value)
const visible = Math.min(visibleCategories.value.length, MAX_VISIBLE.value)
return `${visible * BAR_HEIGHT.value + 40}px`
})
// ── Tooltip state (exposed to parent via scoped slot) ─────────────────────────
// ── Tooltip state ─────────────────────────────────────────────────────────────
const tooltipVisible = ref(false)
const tooltipX = ref(0)
const tooltipY = ref(0)
const hoveredIndex = ref<number | null>(null)
const tooltipVisible = ref(false)
const tooltipX = ref(0)
const tooltipY = ref(0)
const hoveredIndex = ref<number | null>(null)
function onMouseMove(e: MouseEvent) {
tooltipX.value = e.clientX + 14
@@ -74,7 +114,7 @@ const chartOptions = computed(() => ({
colors: props.colors ?? ['#4da6ff', '#ffc107'],
dataLabels: { enabled: false },
xaxis: {
categories: props.categories,
categories: visibleCategories.value,
labels: { show: false },
axisBorder: { show: false },
axisTicks: { show: false },
@@ -113,7 +153,7 @@ const chartOptions = computed(() => ({
type="bar"
:height="chartHeight"
:options="options ?? chartOptions"
:series="series"
:series="visibleSeries"
/>
</div>
@@ -125,6 +165,22 @@ const chartOptions = computed(() => ({
:index="hoveredIndex"
/>
<div v-if="hasMore || (limit && showAll)" class="show-more-wrap">
<button
v-if="!showAll"
class="show-more"
:disabled="isExpanding"
:class="{ loading: isExpanding }"
@click="expand"
>
<span v-if="isExpanding" class="spinner" />
<span>{{ isExpanding ? 'Loading…' : `Show ${hiddenCount} more` }}</span>
</button>
<button v-else class="show-more" @click="collapse">
Show less
</button>
</div>
<div v-if="footerValue !== undefined" class="chart-footer">
<span class="total-count">{{ footerValue }}</span>
<span class="total-label">{{ footerLabel }}</span>
@@ -167,6 +223,49 @@ const chartOptions = computed(() => ({
.chart-scroll::-webkit-scrollbar-track { background: transparent; }
.chart-scroll::-webkit-scrollbar-thumb { background: #334455; border-radius: 2px; }
.show-more-wrap {
display: flex;
padding-left: 2px;
}
.show-more {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid #334455;
border-radius: 4px;
color: #778899;
cursor: pointer;
font-size: 12px;
padding: 4px 10px;
transition: color 0.15s, border-color 0.15s;
}
.show-more:hover:not(:disabled) {
border-color: #4da6ff;
color: #4da6ff;
}
.show-more:disabled {
cursor: default;
opacity: 0.6;
}
.spinner {
width: 10px;
height: 10px;
border: 1.5px solid #556677;
border-top-color: #4da6ff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.chart-footer {
display: flex;
align-items: baseline;
@@ -32,6 +32,8 @@ const chartEvents = computed(() => ({
<template>
<ScrollingHorizontalBarChart
:limit="18
"
title="Top countries"
:series="flightStats.countries.value.series"
:categories="countries.map(c => c.name)"
@@ -41,6 +41,7 @@ const chartEvents = computed(() => ({
:events="chartEvents"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
:limit="12"
>
<template #tooltip="{ visible, x, y, index }">
<ChartTooltip :visible="visible" :x="x" :y="y">
@@ -40,6 +40,7 @@ const chartEvents = computed(() => ({
:categories="airlines.map(a => a.name)"
:footer-value="airlines.length"
footer-label="total airlines"
:limit="12"
:events="chartEvents"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
@@ -42,6 +42,7 @@ const chartEvents = computed(() => ({
:events="chartEvents"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
:limit="12"
>
<template #tooltip="{ visible, x, y, index }">
<ChartTooltip :visible="visible" :x="x" :y="y">
@@ -31,6 +31,7 @@ const series = computed(() => props.flightStats.topRoutes.value.series)
:footer-value="routes.length"
footer-label="total routes"
:max-visible="12"
:limit="12"
>
<template #tooltip="{ visible, x, y, index }">
<ChartTooltip :visible="visible" :x="x" :y="y">
@@ -47,7 +47,8 @@ const CLASS_ORDER: Record<string, number> = {
'Premium Economy': 2,
'Business': 3,
'First': 4,
'Private': 5
'Private': 5,
'Crew' : 6,
}
const customKeySort = {
@@ -57,6 +58,9 @@ const customKeySort = {
airline: (a: Flight['airline'], b: Flight['airline']) => {
return (a?.iata_code ?? '').localeCompare(b?.iata_code ?? '')
},
aircraft: (a: Flight['aircraft'], b: Flight['aircraft']) => {
return (a?.designator ?? '').localeCompare(b?.designator ?? '')
},
duration: (a: any, b: any) => (a ?? 0) - (b ?? 0),
departure_airport: (a: Flight['departure_airport'], b: Flight['departure_airport']) => {
return (a?.display_code ?? '').localeCompare(b?.display_code ?? '')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type {Airline, Flight} from '@/Types/types'
import { ref } from 'vue'
import { usePage } from '@inertiajs/vue3'
import type { Airline, Alliance, Flight, RegionRange, FlightRange, FlightScope } from '@/Types/types'
import { ref, watch } from 'vue'
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue"
const props = defineProps<{
flights: Flight[]
@@ -11,31 +11,72 @@ const emit = defineEmits<{
change: [filters: {
years: number[]
airlines: number[]
alliances: number[]
countries: string[]
continents: string[]
flightClasses: number[]
crewTypes: number[]
flightScopes: FlightScope[]
flightRanges: FlightRange[]
regionRanges: RegionRange[]
flightReasons: number[]
seatTypes: number[]
manufacturers: string[]
aircraftModels: number[]
airportRegions: number[]
}]
}>()
// ── Available options ─────────────────────────────────────────────────────────
type AirlineData = { id: number; name: string, airline: Airline }
type AirlineData = { id: number; name: string; airline: Airline }
const FLIGHT_SCOPE_LABELS: Record<FlightScope, string> = {
domestic: 'Domestic',
international: 'International',
}
const FLIGHT_RANGE_LABELS: Record<FlightRange, string> = {
intracontinental: 'Intracontinental',
intercontinental: 'Intercontinental',
}
const REGION_RANGE_LABELS: Record<RegionRange, string> = {
intraregional: 'Intraregional',
interregional: 'Interregional',
}
function buildOptions(flights: Flight[]) {
const years = new Set<number>()
const airlines = new Map<number, AirlineData>()
const countries = new Map<string, { code: string; name: string }>()
const continents = new Map<string, { code: string; name: string }>()
const classes = new Map<number, { id: number; name: string }>()
const crewTypes = new Map<number, { id: number; name: string }>()
const years = new Set<number>()
const airlines = new Map<number, AirlineData>()
const alliances = new Map<number, { id: number; name: string; alliance: Alliance }>()
const countries = new Map<string, { code: string; name: string }>()
const continents = new Map<string, { code: string; name: string }>()
const classes = new Map<number, { id: number; name: string }>()
const crewTypes = new Map<number, { id: number; name: string }>()
const flightReasons = new Map<number, { id: number; name: string }>()
const seatTypes = new Map<number, { id: number; name: string }>()
const flightScopes = new Set<FlightScope>()
const flightRanges = new Set<FlightRange>()
const regionRanges = new Set<RegionRange>()
const manufacturers = new Map<string, { name: string }>()
const aircraftModels = new Map<number, { id: number; name: string }>()
const airportRegions = new Map<number, { id: number; name: string; countryCodes: Set<string> }>()
flights.forEach(f => {
years.add(new Date(f.departure_date).getFullYear())
if (f.airline)
if (f.airline) {
airlines.set(f.airline.id, { id: f.airline.id, name: f.airline.name, airline: f.airline })
if (f.airline.alliance?.id != null && f.airline.alliance?.name)
alliances.set(f.airline.alliance.id, {
id: f.airline.alliance.id,
name: f.airline.alliance.name,
alliance: f.airline.alliance,
})
}
const dep = f.departure_airport.region
const arr = f.arrival_airport.region
@@ -44,46 +85,119 @@ function buildOptions(flights: Flight[]) {
if (dep?.continent) continents.set(dep.continent.code, { code: dep.continent.code, name: dep.continent.name })
if (arr?.continent) continents.set(arr.continent.code, { code: arr.continent.code, name: arr.continent.name })
if (f.flight_class?.id && f.flight_class?.name)
for (const airport of [f.departure_airport, f.arrival_airport]) {
const region = airport.region
if (region?.id != null && region?.name) {
if (!airportRegions.has(region.id)) {
airportRegions.set(region.id, {
id: region.id,
name: region.country?.code ? `${region.country.code} - ${region.name}` : region.name,
countryCodes: new Set(),
})
}
if (region.country?.code)
airportRegions.get(region.id)!.countryCodes.add(region.country.code)
}
}
if (f.flight_class?.id != null && f.flight_class?.name)
classes.set(f.flight_class.id, { id: f.flight_class.id, name: f.flight_class.name })
if (f.crew_type?.id && f.crew_type?.name)
if (f.crew_type?.id != null && f.crew_type?.name)
crewTypes.set(f.crew_type.id, { id: f.crew_type.id, name: f.crew_type.name })
if (f.flight_reason?.id != null && f.flight_reason?.name)
flightReasons.set(f.flight_reason.id, { id: f.flight_reason.id, name: f.flight_reason.name })
if (f.seat_type?.id != null && f.seat_type?.name)
seatTypes.set(f.seat_type.id, { id: f.seat_type.id, name: f.seat_type.name })
if (f.aircraft?.manufacturer_code)
manufacturers.set(f.aircraft.manufacturer_code, { name: f.aircraft.manufacturer_code })
if (f.aircraft?.id != null && f.aircraft?.display_name_short)
aircraftModels.set(f.aircraft.id, { id: f.aircraft.id, name: f.aircraft.display_name_short })
flightScopes.add(f.scope)
flightRanges.add(f.range)
regionRanges.add(f.region_range)
})
const scopeOrder: FlightScope[] = ['domestic', 'international']
const rangeOrder: FlightRange[] = ['intracontinental', 'intercontinental']
const regionRangeOrder: RegionRange[] = ['intraregional', 'interregional']
return {
years: [...years].sort((a, b) => b - a),
airlines: [...airlines.values()].sort((a, b) => a.name.localeCompare(b.name)),
countries: [...countries.values()].sort((a, b) => a.name.localeCompare(b.name)),
continents: [...continents.values()].sort((a, b) => a.name.localeCompare(b.name)),
classes: [...classes.values()].sort((a, b) => a.name.localeCompare(b.name)),
crewTypes: [...crewTypes.values()].sort((a, b) => a.name.localeCompare(b.name)),
years: [...years].sort((a, b) => b - a),
airlines: [...airlines.values()].sort((a, b) => a.name.localeCompare(b.name)),
alliances: [...alliances.values()].sort((a, b) => a.name.localeCompare(b.name)),
countries: [...countries.values()].sort((a, b) => a.name.localeCompare(b.name)),
continents: [...continents.values()].sort((a, b) => a.name.localeCompare(b.name)),
classes: [...classes.values()].sort((a, b) => a.name.localeCompare(b.name)),
crewTypes: [...crewTypes.values()].sort((a, b) => a.name.localeCompare(b.name)),
flightReasons: [...flightReasons.values()].sort((a, b) => a.name.localeCompare(b.name)),
seatTypes: [...seatTypes.values()].sort((a, b) => a.id - b.id),
flightScopes: scopeOrder.filter(s => flightScopes.has(s)).map(s => ({ value: s, label: FLIGHT_SCOPE_LABELS[s] })),
flightRanges: rangeOrder.filter(r => flightRanges.has(r)).map(r => ({ value: r, label: FLIGHT_RANGE_LABELS[r] })),
regionRanges: regionRangeOrder.filter(r => regionRanges.has(r)).map(r => ({ value: r, label: REGION_RANGE_LABELS[r] })),
manufacturers: [...manufacturers.values()].sort((a, b) => a.name.localeCompare(b.name)),
aircraftModels: [...aircraftModels.values()].sort((a, b) => a.name.localeCompare(b.name)),
airportRegions: [...airportRegions.values()].sort((a, b) => a.name.localeCompare(b.name)),
}
}
const availableOptions = buildOptions(props.flights)
// ── Filter state ──────────────────────────────────────────────────────────────
const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([])
const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([])
const selectedCrewTypes = ref<number[]>([])
const selectedYears = ref<number[]>([])
const selectedAirlines = ref<number[]>([])
const selectedAlliances = ref<number[]>([])
const selectedCountries = ref<string[]>([])
const selectedContinents = ref<string[]>([])
const selectedFlightClasses = ref<number[]>([])
const selectedCrewTypes = ref<number[]>([])
const selectedFlightScopes = ref<FlightScope[]>([])
const selectedFlightRanges = ref<FlightRange[]>([])
const selectedRegionRanges = ref<RegionRange[]>([])
const selectedFlightReasons = ref<number[]>([])
const selectedSeatTypes = ref<number[]>([])
const selectedManufacturers = ref<string[]>([])
const selectedAircraftModels = ref<number[]>([])
const selectedAirportRegions = ref<number[]>([])
// When selected countries change, drop any selected regions that no longer
// belong to any of the selected countries.
watch(selectedCountries, (countries) => {
if (countries.length === 0) return
selectedAirportRegions.value = selectedAirportRegions.value.filter(regionId => {
const region = availableOptions.airportRegions.find(r => r.id === regionId)
if (!region) return false
return [...(region as any).countryCodes].some((code: string) => countries.includes(code))
})
})
function emitFilters() {
emit('change', {
years: selectedYears.value,
airlines: selectedAirlines.value,
countries: selectedCountries.value,
continents: selectedContinents.value,
flightClasses: selectedFlightClasses.value,
crewTypes: selectedCrewTypes.value,
years: selectedYears.value,
airlines: selectedAirlines.value,
alliances: selectedAlliances.value,
countries: selectedCountries.value,
continents: selectedContinents.value,
flightClasses: selectedFlightClasses.value,
crewTypes: selectedCrewTypes.value,
flightScopes: selectedFlightScopes.value,
flightRanges: selectedFlightRanges.value,
regionRanges: selectedRegionRanges.value,
flightReasons: selectedFlightReasons.value,
seatTypes: selectedSeatTypes.value,
manufacturers: selectedManufacturers.value,
aircraftModels: selectedAircraftModels.value,
airportRegions: selectedAirportRegions.value,
})
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const page = usePage()
const countryFlagClass = (code: string) =>
`fi fi-${code.toLowerCase()}`
@@ -91,6 +205,7 @@ const countryFlagClass = (code: string) =>
<template>
<div class="flight-filters">
<v-select
v-model="selectedYears"
:items="availableOptions.years"
@@ -140,6 +255,35 @@ const countryFlagClass = (code: string) =>
</template>
</v-select>
<v-select
v-if="availableOptions.alliances.length > 0"
v-model="selectedAlliances"
:items="availableOptions.alliances"
item-title="name" item-value="id"
label="Alliance"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #prepend="{ isSelected }">
<v-checkbox-btn :model-value="isSelected" tabindex="-1" />
</template>
<template #title>
<AllianceLogo :size="22" :alliance="(item as any).alliance" style="margin-right: 8px;" />
{{ (item as any).name }}
</template>
</v-list-item>
</template>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAlliances.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAlliances.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedCountries"
:items="availableOptions.countries"
@@ -169,6 +313,60 @@ const countryFlagClass = (code: string) =>
</template>
</v-select>
<v-select
v-if="availableOptions.flightScopes.length > 1"
v-model="selectedFlightScopes"
:items="availableOptions.flightScopes"
item-title="label" item-value="value"
label="Flight Scope"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightScopes.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightScopes.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.airportRegions.length > 0"
v-model="selectedAirportRegions"
:items="availableOptions.airportRegions"
item-title="name" item-value="id"
label="Region"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAirportRegions.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAirportRegions.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.regionRanges.length > 1"
v-model="selectedRegionRanges"
:items="availableOptions.regionRanges"
item-title="label" item-value="value"
label="Region Range"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedRegionRanges.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedRegionRanges.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedContinents"
:items="availableOptions.continents"
@@ -186,11 +384,29 @@ const countryFlagClass = (code: string) =>
</template>
</v-select>
<v-select
v-if="availableOptions.flightRanges.length > 1"
v-model="selectedFlightRanges"
:items="availableOptions.flightRanges"
item-title="label" item-value="value"
label="Flight Range"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).label }}<span v-if="index < Math.min(selectedFlightRanges.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightRanges.length - 2 }}</span>
</template>
</v-select>
<v-select
v-model="selectedFlightClasses"
:items="availableOptions.classes"
item-title="name" item-value="id"
label="Flight class"
label="Flight Class"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
@@ -203,6 +419,78 @@ const countryFlagClass = (code: string) =>
</template>
</v-select>
<v-select
v-if="availableOptions.flightReasons.length > 0"
v-model="selectedFlightReasons"
:items="availableOptions.flightReasons"
item-title="name" item-value="id"
label="Flight Reason"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedFlightReasons.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedFlightReasons.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.seatTypes.length > 0"
v-model="selectedSeatTypes"
:items="availableOptions.seatTypes"
item-title="name" item-value="id"
label="Seat Type"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedSeatTypes.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedSeatTypes.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.manufacturers.length > 0"
v-model="selectedManufacturers"
:items="availableOptions.manufacturers"
item-title="name" item-value="name"
label="Aircraft Manufacturer"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedManufacturers.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedManufacturers.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.aircraftModels.length > 0"
v-model="selectedAircraftModels"
:items="availableOptions.aircraftModels"
item-title="name" item-value="id"
label="Aircraft Model"
multiple clearable hide-details
density="compact" variant="outlined"
@update:model-value="emitFilters"
>
<template #selection="{ item, index }">
<span v-if="index < 2" class="v-select__selection-text">
{{ (item as any).name }}<span v-if="index < Math.min(selectedAircraftModels.length, 2) - 1">,&nbsp;</span>
</span>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedAircraftModels.length - 2 }}</span>
</template>
</v-select>
<v-select
v-if="availableOptions.crewTypes.length > 0"
v-model="selectedCrewTypes"
@@ -220,6 +508,7 @@ const countryFlagClass = (code: string) =>
<span v-if="index === 2" class="text-caption text-medium-emphasis">+{{ selectedCrewTypes.length - 2 }}</span>
</template>
</v-select>
</div>
</template>
@@ -13,7 +13,7 @@
<p>No flight data available</p>
</div>
<div v-if="showLegend" class="map-legend" :class="{ 'map-legend--open': legendOpen }">
<button class="map-legend__toggle" @click="legendOpen = !legendOpen">
<button class="map-legend__toggle" @click="toggleLegend">
<span class="mdi mdi-format-list-bulleted" />
<span class="map-legend__toggle-label">Legend</span>
<span class="mdi" :class="legendOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'" />
@@ -40,12 +40,11 @@
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, nextTick, PropType } from 'vue'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { usePage } from '@inertiajs/vue3'
import { Flight, Airport, SharedProps } from '@/Types/types'
import PlaneLoader from '@/Components/FlightsGoneBy/PlaneLoader.vue'
import type { Feature, FeatureCollection, LineString, Point } from 'geojson'
// ── Types ─────────────────────────────────────────────────────────────────────
import {useUpdateSetting} from "@/Composables/useUpdateSetting";
import {usePage} from "@inertiajs/vue3";
type LngLat = [number, number]
@@ -256,6 +255,9 @@ export default defineComponent({
setup(props) {
const mapContainer = ref<HTMLDivElement | null>(null)
const mapReady = ref(false)
const { updateSetting } = useUpdateSetting()
const page = usePage<SharedProps>().props
let map: maplibregl.Map | null = null
let popup: maplibregl.Popup | null = null
@@ -267,7 +269,12 @@ export default defineComponent({
let selectedAirportId: number | null = null
const legendOpen = ref(true)
const legendOpen = ref(page.auth?.user?.resolved_settings?.show_map_legend ?? true)
function toggleLegend() {
legendOpen.value = !legendOpen.value
updateSetting('show_map_legend', legendOpen.value).catch(() => {})
}
const isGlobe = ref(false)
@@ -735,7 +742,7 @@ export default defineComponent({
if (map) { map.remove(); map = null }
})
return { mapContainer, mapReady, exportMapBasic, legendOpen, legendItems, isGlobe, toggleProjection }
return { mapContainer, mapReady, exportMapBasic, legendOpen, legendItems, isGlobe, toggleProjection, toggleLegend }
},
})
@@ -1,32 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
import { SharedProps } from '@/Types/types'
import { ref, onMounted, nextTick } from 'vue'
import { FlightStats } from '@/Composables/useFlightStats'
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
import FlightFilter from "@/Components/FlightsGoneBy/FlightFilter.vue";
import FlightStatsBar from "@/Components/FlightsGoneBy/FlightStatsBar.vue";
import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue";
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue"
import FlightStatsBar from "@/Components/FlightsGoneBy/FlightStatsBar.vue"
import FlightCharts from "@/Components/FlightsGoneBy/FlightCharts.vue"
const props = defineProps<{
stats: FlightStats
canEdit: boolean
}>()
const emit = defineEmits<{
filtersChange: [filters: {
years: number[]
airlines: number[]
countries: string[]
continents: string[]
flightClasses: number[]
crewTypes: number[]
}]
}>()
const mappedFlights = computed(() => [
...props.stats.pastFlights.value,
...props.stats.upcomingFlights.value,
@@ -35,12 +18,8 @@ const mappedFlights = computed(() => [
<template>
<div>
<FlightMap :flights="mappedFlights" />
<FlightFilter :flights="mappedFlights" @change="$emit('filtersChange', $event)" />
<FlightStatsBar :flights="stats.pastFlights.value" :upcoming-flights="stats.upcomingFlights.value" />
<FlightCharts :stats="stats" :flight-stats="stats"/>
<!-- <div v-else style="width:100%; display:flex; align-items: center;justify-content: center">
<PlaneLoader />
</div>-->
<FlightMap :flights="mappedFlights" />
<FlightStatsBar :flights="stats.pastFlights.value" :upcoming-flights="stats.upcomingFlights.value" />
<FlightCharts :stats="stats" :flight-stats="stats" />
</div>
</template>
@@ -1,109 +1,8 @@
<template>
<div class="stats-bar glass">
<div class="stat">
<template v-if="flights.length">
<div class="stat-primary">
<span class="stat-num">{{ flights.length.toLocaleString() }}</span>
<span class="unit">flights</span>
</div>
</template>
<template v-if="upcomingFlights.length">
<div :class="flights.length ? 'stat-upcoming' : 'stat-primary'">
<span :class="flights.length ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingFlights.length.toLocaleString() }}</span>
<span :class="flights.length ? 'stat-upcoming-lbl' : 'unit'">{{ flights.length ? 'upcoming' : 'flights' }}</span>
<span v-if="!flights.length" class="upcoming-badge">upcoming</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="uniqueRoutes">
<div class="stat-primary">
<span class="stat-num">{{ uniqueRoutes.toLocaleString() }}</span>
<span class="unit">routes</span>
</div>
</template>
<template v-if="uniqueUpcomingRoutes">
<div :class="uniqueRoutes ? 'stat-upcoming' : 'stat-primary'">
<span :class="uniqueRoutes ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingRoutes.toLocaleString() }}</span>
<span :class="uniqueRoutes ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueRoutes ? 'upcoming' : 'routes' }}</span>
<span v-if="!uniqueRoutes" class="upcoming-badge">upcoming</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="totalDistanceKm">
<div class="stat-primary">
<span class="stat-num"><Distance :unit="page.auth?.user?.resolved_settings?.distance_unit" includeSpace :value="totalDistanceKm" /></span>
</div>
</template>
<template v-if="upcomingDistanceKm">
<div :class="totalDistanceKm ? 'stat-upcoming' : 'stat-primary'">
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'"><Distance :unit="page.auth?.user?.resolved_settings?.distance_unit" includeSpace :value="upcomingDistanceKm"/>
<span :class="upcomingDistanceKm ? 'stat-upcoming-lbl' : 'unit'"> upcoming</span>
</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="uniqueCountries">
<div class="stat-primary">
<span class="stat-num">{{ uniqueCountries }}</span>
<span class="unit">countries</span>
</div>
</template>
<template v-if="uniqueUpcomingCountries">
<div :class="uniqueCountries ? 'stat-upcoming' : 'stat-primary'">
<span :class="uniqueCountries ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingCountries }}</span>
<span :class="uniqueCountries ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueCountries ? 'upcoming' : 'countries' }}</span>
<span v-if="!uniqueCountries" class="upcoming-badge">upcoming</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="uniqueAirports">
<div class="stat-primary">
<span class="stat-num">{{ uniqueAirports }}</span>
<span class="unit">airports</span>
</div>
</template>
<template v-if="uniqueUpcomingAirports">
<div :class="uniqueAirports ? 'stat-upcoming' : 'stat-primary'">
<span :class="uniqueAirports ? 'stat-upcoming-num' : 'stat-num'">{{ uniqueUpcomingAirports }}</span>
<span :class="uniqueAirports ? 'stat-upcoming-lbl' : 'unit'">{{ uniqueAirports ? 'upcoming' : 'airports' }}</span>
<span v-if="!uniqueAirports" class="upcoming-badge">upcoming</span>
</div>
</template>
</div>
<div class="stat">
<template v-if="durationDisplay.hours">
<div class="stat-primary">
<span class="stat-num">{{ durationDisplay.hours.toLocaleString() }}</span>
<span class="unit">hours in the air</span>
</div>
<div class="stat-sub">{{ durationDisplay.days }} days · {{ durationDisplay.weeks }} weeks</div>
</template>
<template v-if="upcomingDurationDisplay.hours">
<div :class="durationDisplay.hours ? 'stat-upcoming' : 'stat-primary'">
<span :class="durationDisplay.hours ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingDurationDisplay.hours.toLocaleString() }}</span>
<span :class="durationDisplay.hours ? 'stat-upcoming-lbl' : 'unit'">{{ durationDisplay.hours ? 'hrs upcoming' : 'hours in the air' }}</span>
<span v-if="!durationDisplay.hours" class="upcoming-badge">upcoming</span>
</div>
<div v-if="!durationDisplay.hours" class="stat-sub">{{ upcomingDurationDisplay.days }} days · {{ upcomingDurationDisplay.weeks }} weeks</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type {Flight, SharedProps} from '@/Types/types'
import Distance from "@/Components/Distance.vue";
import {usePage} from "@inertiajs/vue3";
import type { Flight, SharedProps } from '@/Types/types'
import StatItem from "@/Components/StatItem.vue"
import { usePage } from "@inertiajs/vue3"
const props = defineProps<{
flights: Flight[]
@@ -197,8 +96,63 @@ const upcomingDurationDisplay = computed(() => {
weeks: Math.floor(totalHours / 24 / 7),
}
})
const distanceUnit = computed(() => page.auth?.user?.resolved_settings?.distance_unit)
</script>
<template>
<div class="stats-bar glass">
<StatItem
:primary="flights.length || null"
unit="flights"
:upcoming="upcomingFlights.length || null"
:upcoming-label="flights.length ? 'upcoming' : 'flights'"
:show-upcoming-badge="!flights.length"
/>
<StatItem
:primary="uniqueRoutes || null"
unit="routes"
:upcoming="uniqueUpcomingRoutes || null"
:upcoming-label="uniqueRoutes ? 'upcoming' : 'routes'"
:show-upcoming-badge="!uniqueRoutes"
/>
<StatItem
:primary="totalDistanceKm || null"
:upcoming="upcomingDistanceKm || null"
:upcoming-label="totalDistanceKm ? 'upcoming' : undefined"
is-distance
:distance-unit="distanceUnit"
/>
<StatItem
:primary="uniqueCountries || null"
unit="countries"
:upcoming="uniqueUpcomingCountries || null"
:upcoming-label="uniqueCountries ? 'upcoming' : 'countries'"
:show-upcoming-badge="!uniqueCountries"
/>
<StatItem
:primary="uniqueAirports || null"
unit="airports"
:upcoming="uniqueUpcomingAirports || null"
:upcoming-label="uniqueAirports ? 'upcoming' : 'airports'"
:show-upcoming-badge="!uniqueAirports"
/>
<StatItem
:primary="durationDisplay.hours || null"
unit="hours in the air"
:upcoming="upcomingDurationDisplay.hours || null"
:upcoming-label="durationDisplay.hours ? 'hrs upcoming' : 'hours in the air'"
:show-upcoming-badge="!durationDisplay.hours"
:sub="!durationDisplay.hours ? `${upcomingDurationDisplay.days} days · ${upcomingDurationDisplay.weeks} weeks` : undefined"
/>
</div>
</template>
<style scoped>
.stats-bar {
display: grid;
@@ -209,94 +163,11 @@ const upcomingDurationDisplay = computed(() => {
margin-top: 12px;
}
.stat {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-primary {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
}
.stat-num {
font-size: 26px;
font-weight: 500;
color: #e0e6f0;
letter-spacing: -0.5px;
line-height: 1;
}
.unit {
font-size: 13px;
font-weight: 400;
color: #3a5566;
}
.stat-sub {
font-size: 11px;
color: #334455;
margin-top: 1px;
}
.stat-upcoming {
display: flex;
align-items: baseline;
gap: 5px;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.stat-upcoming-num {
font-size: 13px;
font-weight: 500;
color: #4a8fa8;
}
.stat-upcoming-lbl {
font-size: 12px;
color: #335566;
}
.upcoming-badge {
font-size: 10px;
font-weight: 500;
color: #4a8fa8;
background: rgba(74, 143, 168, 0.12);
border: 1px solid rgba(74, 143, 168, 0.2);
border-radius: 4px;
padding: 1px 6px;
letter-spacing: 0.06em;
text-transform: uppercase;
align-self: center;
}
@media (max-width: 1024px) {
.stats-bar {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat {
padding: 14px 16px;
}
.stats-bar { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.stats-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat {
padding: 12px 14px;
}
.stat-num {
font-size: 22px;
}
.stats-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import {computed, ref} from "vue";
import {usePage} from "@inertiajs/vue3";
import type {SharedProps, User} from "@/Types/types";
type FollowStatus = 'following' | 'requested' | 'none'
const props = defineProps<{
user: User
followStatus: FollowStatus
}>()
const snackbar = ref(false)
const snackbarMessage = ref('')
const status = ref<FollowStatus>(props.followStatus)
const processing = ref(false)
const auth = usePage<SharedProps>().props.auth
const isOwnProfile = computed(() => auth.user?.id == props.user.id)
const isLoggedIn = computed(() => !!auth.user)
const buttonLabel = computed(() => {
switch (status.value) {
case 'following': return 'Following'
case 'requested': return 'Request sent'
default: return '+ Follow'
}
})
const follow = async () => {
processing.value = true
const response = await fetch(route('profile.follow', { user: props.user.name }), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
},
})
const data = await response.json()
status.value = data.status
snackbarMessage.value = data.status === 'following'
? `You are now following ${props.user.name}`
: data.status === 'requested'
? `Follow request sent to ${props.user.name}`
: `You unfollowed ${props.user.name}`
snackbar.value = true
processing.value = false
}
</script>
<template>
<button
v-if="isLoggedIn && !isOwnProfile"
class="follow-btn"
:disabled="processing"
@click="follow"
>
{{ buttonLabel }}
</button>
<v-snackbar v-model="snackbar" :timeout="5000" color="#ffc107" location="bottom center">
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="snackbar = false">Close</v-btn>
</template>
</v-snackbar>
</template>
<style scoped>
.follow-btn {
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.12em;
color: #ffc107;
background: none;
border: 1px solid rgba(255, 193, 7, 0.35);
border-radius: 4px;
padding: 0.3em 0.85em;
cursor: pointer;
transition: background 0.15s ease;
white-space: nowrap;
align-self: center;
}
.follow-btn:hover:not(:disabled) {
background: rgba(255, 193, 7, 0.1);
}
.follow-btn:disabled {
opacity: 0.5;
cursor: default;
}
</style>
@@ -18,6 +18,7 @@ defineProps<{
width: clamp(280px, 100%, 700px);
gap: 1em;
padding: 2em;
margin: 2em;
}
h2 {
@@ -51,7 +51,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</button>
<div v-if="dropdownOpen" class="dropdown-menu">
<Link :href="route('import.fr24')" class="dropdown-item">Import from FR24</Link>
<Link :href="route('profile.settings')" class="dropdown-item">Settings</Link>
<Link :href="route('user.settings')" class="dropdown-item">Settings</Link>
<div class="dropdown-divider" />
<button class="dropdown-item dropdown-item--danger" @click="logout">Log Out</button>
</div>
@@ -77,7 +77,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<Link :href="route('profile.view', { user: page.props.auth.user.name })" class="nav-link" @click="menuOpen = false">Profile</Link>
<Link :href="route('feed')" class="nav-link nav-link" @click="menuOpen = false">Feed</Link>
<Link :href="route('import.fr24')" class="nav-link" @click="menuOpen = false">Import from FR24</Link>
<Link :href="route('profile.settings')" class="nav-link" @click="menuOpen = false">Settings</Link>
<Link :href="route('user.settings')" class="nav-link" @click="menuOpen = false">Settings</Link>
<div class="dropdown-divider" />
<button class="nav-link nav-link--danger" @click="logout">Log Out</button>
</template>
@@ -3,11 +3,17 @@ import {ref, watch} from 'vue'
import axios from "axios";
import {Notification} from "@/Types/types";
import {Link} from "@inertiajs/vue3";
import {local} from "laravel-vite-plugin/fonts";
const props = defineProps<{
unreadCount: number
}>()
const localUnreadCount = ref(props.unreadCount)
watch(() => props.unreadCount, (val) => {
localUnreadCount.value = val
})
const open = ref(false)
const notifications = ref<Notification[]>([])
@@ -26,20 +32,25 @@ const emit = defineEmits<{
}>()
watch(open, async (isOpen) => {
if (!isOpen || notifications.value.length) return
if (!isOpen) return
localUnreadCount.value = 0
emit('update:unreadCount', 0)
if (notifications.value.length) return
loading.value = true
const { data } = await axios.get('/notifications')
notifications.value = data
loading.value = false
await markAllRead(notifications.value)
emit('update:unreadCount', 0) // <-- add this
})
</script>
<template>
<div class="notif-wrapper">
<v-btn icon variant="text" @click="open = !open" aria-label="Notifications">
<v-badge :content="unreadCount" :model-value="unreadCount > 0" color="primary">
<v-badge :content="localUnreadCount" :model-value="localUnreadCount > 0" color="primary">
<v-icon>mdi-bell-outline</v-icon>
</v-badge>
</v-btn>
@@ -0,0 +1,43 @@
<script setup lang="ts">
defineProps<{
name?: string
}>()
</script>
<template>
<div class="private-panel glass glass-border">
<span class="private-icon mdi mdi-lock-outline"></span>
<span class="private-title">PRIVATE PROFILE</span>
<span class="private-sub">{{ name ? `${name}'s profile is private. You can request to follow them for access.` : 'This profile is private' }}</span>
</div>
</template>
<style scoped>
.private-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 4rem 2rem;
}
.private-icon {
font-size: 1.6rem;
color: #556;
margin-bottom: 0.2rem;
}
.private-title {
font-family: 'Share Tech Mono', monospace;
font-size: 0.8rem;
letter-spacing: 0.2em;
color: #778899;
}
.private-sub {
font-family: 'Barlow', sans-serif;
font-size: 0.82rem;
color: #445;
}
</style>
@@ -3,24 +3,16 @@ import { usePage } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import type { Flight, User, SharedProps } from "@/Types/types";
import { Link } from "@inertiajs/vue3";
import FollowButton from "@/Components/FlightsGoneBy/FollowButton.vue";
const props = defineProps<{
user: User
flightCount?: number
achievementCount?: number
isFollowing?: boolean
followStatus?: string
show: "flights" | "achievements"
}>()
const auth = usePage<SharedProps>().props.auth
const isOwnProfile = computed(() => auth.user?.id == props.user.id)
const isLoggedIn = computed(() => !!auth.user)
const following = ref(props.isFollowing ?? false)
const processing = ref(false)
const snackbar = ref(false)
const snackbarMessage = ref('')
const counts = computed(() => {
return {
flights: props.flightCount ?? 0,
@@ -28,21 +20,7 @@ const counts = computed(() => {
} as Record<"flights" | "achievements", number>
})
const follow = async () => {
processing.value = true
const response = await fetch(route('profile.follow', { user: props.user.name }), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '',
},
})
const data = await response.json()
following.value = data.following
snackbarMessage.value = data.following ? `You are now following ${props.user.name}` : `You unfollowed ${props.user.name}`
snackbar.value = true
processing.value = false
}
</script>
<template>
@@ -56,14 +34,7 @@ const follow = async () => {
{{ user.name }}
</Link>
</h1>
<button
v-if="isLoggedIn && !isOwnProfile"
class="follow-btn"
:disabled="processing"
@click="follow"
>
{{ following ? 'Following' : '+ Follow' }}
</button>
<FollowButton v-if="followStatus !== undefined" :user="user" :followStatus="followStatus" />
</div>
</div>
</div>
@@ -73,13 +44,6 @@ const follow = async () => {
<span class="count-label">{{show.toUpperCase()}}</span>
</div>
</div>
<v-snackbar v-model="snackbar" :timeout="5000" color="#ffc107" location="bottom center">
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="snackbar = false">Close</v-btn>
</template>
</v-snackbar>
</template>
<style scoped>
@@ -126,30 +90,6 @@ const follow = async () => {
padding: 1em
}
.follow-btn {
font-family: 'Share Tech Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.12em;
color: #ffc107;
background: none;
border: 1px solid rgba(255, 193, 7, 0.35);
border-radius: 4px;
padding: 0.3em 0.85em;
cursor: pointer;
transition: background 0.15s ease;
white-space: nowrap;
align-self: center;
}
.follow-btn:hover:not(:disabled) {
background: rgba(255, 193, 7, 0.1);
}
.follow-btn:disabled {
opacity: 0.5;
cursor: default;
}
.board-count {
text-align: right;
line-height: 1;
@@ -2,23 +2,29 @@
import {Flight, User} from "@/Types/types";
import ProfileHeader from "@/Components/FlightsGoneBy/ProfileHeader.vue";
import PlaneLoader from "@/Components/FlightsGoneBy/PlaneLoader.vue";
import PrivateProfileMessage from "@/Components/FlightsGoneBy/PrivateProfileMessage.vue";
import {Head} from "@inertiajs/vue3";
defineProps<{
user: User
flightCount?: number
achievementCount? : number
isFollowing: boolean
followStatus: string
loading: boolean
canView: boolean
title?: string
}>()
</script>
<template>
<div class="board-wrapper">
<ProfileHeader :show="achievementCount && achievementCount > 0 ? 'achievements' : 'flights'" :is-following="isFollowing" :user="user" :flightCount="flightCount" :achievementCount="achievementCount" />
<Head :title="title" />
<ProfileHeader :show="achievementCount && achievementCount > 0 ? 'achievements' : 'flights'" :followStatus="followStatus" :user="user" :flightCount="flightCount" :achievementCount="achievementCount" />
<div v-if="loading" class="loading-state">
<PlaneLoader />
</div>
<slot v-else />
<slot v-else-if="canView" />
<PrivateProfileMessage :name="user.name" v-else />
</div>
</template>