Added Notifications
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import FormattedNumber from "@/Components/FormattedNumber.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value: number
|
||||
unit?: 'km' | 'mi' | 'nm'
|
||||
showUnits?: boolean
|
||||
includeSpace?: boolean
|
||||
}>(), {
|
||||
showUnits: true,
|
||||
unit: 'km',
|
||||
includeSpace: false,
|
||||
})
|
||||
|
||||
const CONVERSIONS: Record<string, number> = {
|
||||
km: 1,
|
||||
mi: 0.621371,
|
||||
nm: 0.539957,
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
km: 'km',
|
||||
mi: 'mi',
|
||||
nm: 'nm',
|
||||
}
|
||||
|
||||
const unit = computed(() => props.unit ?? 'km')
|
||||
|
||||
const converted = computed(() => props.value * CONVERSIONS[unit.value])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormattedNumber :value="converted" />{{includeSpace ? ' ' : ''}}{{ showUnits ? LABELS[unit] : '' }}
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -5,6 +5,13 @@ import {computed} from "vue";
|
||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
||||
import {Link} from '@inertiajs/vue3'
|
||||
import ButtonLink from "@/Components/FlightsGoneBy/ButtonLink.vue";
|
||||
import Distance from "@/Components/Distance.vue";
|
||||
|
||||
const distanceAchievements = [
|
||||
'general_flying.circumference_of_the_earth',
|
||||
'general_flying.to_the_moon',
|
||||
];
|
||||
|
||||
const props = defineProps<{
|
||||
achievement: Achievement
|
||||
@@ -85,13 +92,11 @@ const difficultyVariant = computed(() => {
|
||||
</div>
|
||||
|
||||
<p class="achievement-description">{{ achievement.short_description }}</p>
|
||||
<Link v-if="achievement.has_page && user" :href="route('profile.achievement', { user: user.name, achievement: achievement.internal_name })">
|
||||
View Details
|
||||
</Link>
|
||||
|
||||
|
||||
<template v-if="achievement.progressive && progress">
|
||||
<div class="progress-label">
|
||||
<span>{{ Math.min(progress.current, progress.threshold) }} / {{ progress.threshold }}</span>
|
||||
<span><distance :showUnits="false" :value="Math.min(progress.current, progress.threshold)" /> / <distance :value="progress.threshold" :showUnits="distanceAchievements.includes(achievement.internal_name)" /></span>
|
||||
<span>{{ progress.percentage }}%</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
@@ -102,11 +107,17 @@ const difficultyVariant = computed(() => {
|
||||
bg-color="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<br/>
|
||||
<ButtonLink
|
||||
variant="outlined"
|
||||
label="View Details"
|
||||
icon="mdi-magnify"
|
||||
v-if="achievement.has_page && user" :href="route('profile.achievement', { user: user.name, achievement: achievement.internal_name })" />
|
||||
</v-card-text>
|
||||
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -115,19 +126,17 @@ const difficultyVariant = computed(() => {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.achievement-card.locked {
|
||||
.achievement-card.locked .achievement-inner {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.achievement-card:hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.achievement-card:hover .achievement-inner,
|
||||
.achievement-inner {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.achievement-icon-wrap {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { Flight, Airline } from '@/Types/types'
|
||||
import type { CodeType } from '@/Composables/useAlphabetAirlines'
|
||||
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
|
||||
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
letters: string[]
|
||||
flightsByLetter: Record<string, Flight[]>
|
||||
codeType: CodeType
|
||||
selectedYear: number | null
|
||||
}>()
|
||||
|
||||
interface AirlineEntry {
|
||||
airline: Airline
|
||||
code: string
|
||||
firstYear: number
|
||||
}
|
||||
|
||||
function getCode(airline: Airline): string | null {
|
||||
const raw = props.codeType === 'iata' ? airline.IATA_code : airline.ICAO_code
|
||||
return raw?.trim().toUpperCase() ?? null
|
||||
}
|
||||
|
||||
function airlineEntriesForLetter(letter: string): AirlineEntry[] {
|
||||
const flights = props.flightsByLetter[letter] ?? []
|
||||
const seen = new Map<string, AirlineEntry>()
|
||||
|
||||
for (const flight of flights) {
|
||||
const airline = flight.airline
|
||||
if (!airline) continue
|
||||
|
||||
const code = getCode(airline)
|
||||
if (!code?.startsWith(letter)) continue
|
||||
|
||||
const year = new Date(flight.departure_date).getFullYear()
|
||||
const existing = seen.get(code)
|
||||
if (!existing || year < existing.firstYear) {
|
||||
seen.set(code, { airline, code, firstYear: year })
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, entry]) => entry)
|
||||
}
|
||||
|
||||
function isHighlighted({ firstYear }: AirlineEntry): boolean {
|
||||
return props.selectedYear !== null && firstYear === props.selectedYear
|
||||
}
|
||||
|
||||
function toBBCode(): string {
|
||||
return props.letters
|
||||
.map(letter => {
|
||||
const entries = airlineEntriesForLetter(letter)
|
||||
if (!entries.length) return letter
|
||||
|
||||
return entries
|
||||
.map(entry =>
|
||||
isHighlighted(entry)
|
||||
? `[b][color=#00BF00]${entry.code}[/color][/b]`
|
||||
: entry.code
|
||||
)
|
||||
.join(', ')
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
defineExpose({ toBBCode })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BadgeTable
|
||||
:rows="letters"
|
||||
:rowKey="letter => letter"
|
||||
:hasItems="letter => !!flightsByLetter[letter]?.length"
|
||||
labelWidth="4em"
|
||||
>
|
||||
<template #label="{ row: letter }">
|
||||
<div
|
||||
style="width:100%;display:flex;justify-content:center;align-items:center"
|
||||
:class="flightsByLetter[letter]?.length ? 'visited' : 'unvisited'"
|
||||
>
|
||||
{{ letter }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #items="{ row: letter }">
|
||||
<div
|
||||
v-for="entry in airlineEntriesForLetter(letter)"
|
||||
:key="entry.airline.IATA_code!"
|
||||
>
|
||||
<InlineBadge style="align-items:center;gap:0.2em" :variant="isHighlighted(entry) ? 'business' : undefined">
|
||||
<AirlineLogo :airline="entry.airline" /> {{ entry.code }}
|
||||
</InlineBadge>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</BadgeTable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visited {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.unvisited {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
|
||||
import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
|
||||
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
|
||||
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
|
||||
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
|
||||
import AirlineLogo from '@/Components/FlightsGoneBy/AirlineLogo.vue'
|
||||
import AllianceLogo from '@/Components/FlightsGoneBy/AllianceLogo.vue'
|
||||
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
|
||||
|
||||
|
||||
interface AirlineEntry {
|
||||
airline: Airline
|
||||
flights: Flight[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
achievement: Achievement
|
||||
user: User
|
||||
isFollowing: boolean
|
||||
alliance: Alliance
|
||||
airlines: Airline[]
|
||||
flights: Flight[]
|
||||
}>()
|
||||
|
||||
const flightsByAirline = computed(() => {
|
||||
const allianceAirlineIds = new Set(props.airlines.map(a => a.id))
|
||||
const map = new Map<string, AirlineEntry>()
|
||||
|
||||
// Pre-populate every alliance airline so unflown ones still appear
|
||||
for (const airline of props.airlines) {
|
||||
map.set(rowKey(airline), { airline, flights: [] })
|
||||
}
|
||||
|
||||
for (const flight of props.flights) {
|
||||
const airline = flight.airline
|
||||
if (!airline || !allianceAirlineIds.has(airline.id)) continue
|
||||
map.get(rowKey(airline))?.flights.push(flight)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
function rowKey(airline: Airline): string {
|
||||
return airline.IATA_code ?? airline.internal_name
|
||||
}
|
||||
|
||||
const rows = computed(() => [...flightsByAirline.value.keys()])
|
||||
|
||||
function entryFor(key: string): AirlineEntry {
|
||||
return flightsByAirline.value.get(key)!
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Header -->
|
||||
<Panel>
|
||||
<div class="alliance-header">
|
||||
<AllianceLogo :alliance="alliance" size="56" />
|
||||
<div>
|
||||
<PanelHeader centered>{{ alliance.name }}</PanelHeader>
|
||||
<PanelSubHeader centered>
|
||||
<slot />
|
||||
</PanelSubHeader>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
|
||||
<!-- Airlines table -->
|
||||
<Panel>
|
||||
<div class="table-toolbar">
|
||||
<PanelHeader>Airlines</PanelHeader>
|
||||
</div>
|
||||
|
||||
<BadgeTable
|
||||
:rows="rows"
|
||||
:rowKey="key => key"
|
||||
:hasItems="key => entryFor(key).flights.length > 0"
|
||||
labelWidth="14em"
|
||||
>
|
||||
<template #label="{ row: key }">
|
||||
<div class="airline-label" >
|
||||
<AirlineLogo :airline="entryFor(key).airline" size="24" />
|
||||
<span>{{ entryFor(key).airline.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #items="{ row: key }">
|
||||
<FlightBadge
|
||||
v-for="flight in entryFor(key).flights"
|
||||
:key="flight.id"
|
||||
:title="`${flight.departure_airport?.iata_code} → ${flight.arrival_airport?.iata_code}`"
|
||||
:flight="flight" />
|
||||
</template>
|
||||
</BadgeTable>
|
||||
</Panel>
|
||||
|
||||
<!-- Slot for alliance-specific panels -->
|
||||
<slot name="extra" />
|
||||
|
||||
<!-- Requirements -->
|
||||
<Panel>
|
||||
<PanelHeader centered>Requirements</PanelHeader>
|
||||
<p>
|
||||
To complete this challenge you must fly with every current member airline of
|
||||
<strong>{{ alliance.name }}</strong>. Alliance membership changes over time, so the
|
||||
required airlines reflect the current roster.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Codeshare flights do not count, the operating carrier must be a member of {{alliance.name}}.
|
||||
</p>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.alliance-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
.airline-label {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import {Airport, Flight} from '@/Types/types'
|
||||
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
|
||||
import AirportToolTip from '@/Components/FlightsGoneBy/AirportToolTip.vue'
|
||||
|
||||
type CodeType = 'iata' | 'icao'
|
||||
|
||||
const props = defineProps<{
|
||||
letters: string[]
|
||||
flightsByLetter: Record<string, Flight[]>
|
||||
codeType: CodeType
|
||||
selectedYear: number | null
|
||||
}>()
|
||||
|
||||
function getCode(airport: Airport): string | null {
|
||||
const raw = props.codeType === 'iata' ? airport.iata_code : airport.icao_code
|
||||
return raw?.trim().toUpperCase() ?? null
|
||||
}
|
||||
|
||||
interface AirportEntry {
|
||||
airport: Airport
|
||||
firstYear: number
|
||||
}
|
||||
|
||||
function airportEntriesForLetter(letter: string): AirportEntry[] {
|
||||
const flights = props.flightsByLetter[letter] ?? []
|
||||
const seen = new Map<string, AirportEntry>()
|
||||
|
||||
for (const flight of flights) {
|
||||
const year = new Date(flight.departure_date).getFullYear()
|
||||
|
||||
for (const airport of [flight.departure_airport, flight.arrival_airport]) {
|
||||
const code = getCode(airport)
|
||||
if (!code?.startsWith(letter)) continue
|
||||
|
||||
const existing = seen.get(code)
|
||||
if (!existing || year < existing.firstYear) {
|
||||
seen.set(code, { airport, firstYear: year })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, entry]) => entry)
|
||||
}
|
||||
|
||||
function isHighlighted({ firstYear }: AirportEntry): boolean {
|
||||
return props.selectedYear !== null && firstYear === props.selectedYear
|
||||
}
|
||||
|
||||
function toBBCode(): string {
|
||||
return props.letters
|
||||
.map(letter => {
|
||||
const entries = airportEntriesForLetter(letter)
|
||||
if (!entries.length) return letter
|
||||
|
||||
return entries
|
||||
.map(entry => {
|
||||
const code = getCode(entry.airport)!
|
||||
return isHighlighted(entry) ? `[b][color=#00BF00]${code}[/color][/b]` : code
|
||||
})
|
||||
.join(', ')
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
defineExpose({ toBBCode })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BadgeTable
|
||||
:rows="letters"
|
||||
:rowKey="letter => letter"
|
||||
:hasItems="letter => !!flightsByLetter[letter]?.length"
|
||||
labelWidth="4em"
|
||||
>
|
||||
<template #label="{ row: letter }">
|
||||
<div style="width:100%;display:flex;justify-content: center; align-items: center" :class="flightsByLetter[letter]?.length ? 'visited' : 'unvisited'">
|
||||
{{ letter }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #items="{ row: letter }">
|
||||
<AirportToolTip
|
||||
v-for="entry in airportEntriesForLetter(letter)"
|
||||
:key="getCode(entry.airport)!"
|
||||
:airport="entry.airport"
|
||||
>
|
||||
<InlineBadge :variant="isHighlighted(entry) ? 'business' : undefined">
|
||||
{{ getCode(entry.airport) }}
|
||||
</InlineBadge>
|
||||
</AirportToolTip>
|
||||
</template>
|
||||
</BadgeTable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visited {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.unvisited {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.airport-count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
margin-left: 0.2em;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { Flight } from "@/Types/types";
|
||||
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||
import AirportToolTip from "@/Components/FlightsGoneBy/AirportToolTip.vue";
|
||||
import AircraftToolTip from "@/Components/FlightsGoneBy/AircraftToolTip.vue";
|
||||
import Distance from "@/Components/Distance.vue";
|
||||
|
||||
defineProps<{
|
||||
flight: Flight
|
||||
@@ -70,7 +71,7 @@ defineProps<{
|
||||
<span class="pass-stat-divider" v-if="flight.duration_display && flight.distance">·</span>
|
||||
<span v-if="flight.distance" class="pass-stat">
|
||||
<span class="pass-stat-label">DISTANCE</span>
|
||||
<span class="pass-stat-value">{{ Math.round(flight.distance).toLocaleString() }} km</span>
|
||||
<span class="pass-stat-value"><Distance :value="Math.round(flight.distance)" /></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,16 +81,6 @@ defineProps<{
|
||||
|
||||
<style scoped>
|
||||
|
||||
.feed-boarding-pass{
|
||||
max-width: 600px;
|
||||
margin: 1em auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.boarding-pass{
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pass-stats-row {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import {Link} from "@inertiajs/vue3";
|
||||
type Method = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||
defineProps<{
|
||||
href: string | {
|
||||
url: string;
|
||||
method: Method;
|
||||
},
|
||||
label: string;
|
||||
icon?: string;
|
||||
variant?: "flat" | "text" | "elevated" | "outlined" | "plain" | "tonal" | undefined
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Link :href="href">
|
||||
<v-btn
|
||||
style="width:100%;"
|
||||
:prepend-icon="icon"
|
||||
:variant="variant"
|
||||
>
|
||||
{{ label }}
|
||||
</v-btn>
|
||||
</Link>
|
||||
</template>
|
||||
<style scoped>
|
||||
a{
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
||||
import CrewTooltip from "@/Components/FlightsGoneBy/CrewTooltip.vue";
|
||||
import {Link, router} from "@inertiajs/vue3";
|
||||
import AllianceLogo from "@/Components/FlightsGoneBy/AllianceLogo.vue";
|
||||
import Distance from "@/Components/Distance.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flightStats: FlightStats
|
||||
@@ -238,7 +239,7 @@ watch(
|
||||
|
||||
<td class="v-data-table__td">
|
||||
<span class="mono-tag distance-cell">
|
||||
{{ (item as Flight).distance ? Math.round((item as Flight).distance).toLocaleString() + ' km' : '' }}
|
||||
<Distance :value="Math.round((item as Flight).distance)" />
|
||||
</span>
|
||||
</td>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const props = defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="flight-booked">
|
||||
<BoardingPass :class="`feed-boarding-pass`" :flight="flight" />
|
||||
<BoardingPass style="max-width:90%; margin: 0 auto" :class="`feed-boarding-pass`" :flight="flight" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import {Flight} from "@/Types/types";
|
||||
|
||||
defineProps<{
|
||||
flight: Flight
|
||||
}>()
|
||||
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||
import FlightToolTip from "@/Components/FlightsGoneBy/FlightToolTip.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightToolTip
|
||||
:flight="flight"
|
||||
>
|
||||
<InlineBadge class="flight-badge">
|
||||
<AirlineLogo hideTooltip :airline="flight.airline" />
|
||||
{{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }}
|
||||
</InlineBadge>
|
||||
</FlightToolTip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flight-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -471,6 +471,7 @@ export default defineComponent({
|
||||
map = new maplibregl.Map({
|
||||
container: mapContainer.value!,
|
||||
cooperativeGestures: true,
|
||||
attributionControl: false,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -483,7 +484,6 @@ export default defineComponent({
|
||||
'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
maxzoom: 19,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import {Flight} from "@/Types/types";
|
||||
import FlightToolTip from "@/Components/FlightsGoneBy/FlightToolTip.vue";
|
||||
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||
import { Flight } from '@/Types/types'
|
||||
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
|
||||
import FlightToolTip from '@/Components/FlightsGoneBy/FlightToolTip.vue'
|
||||
import AirlineLogo from '@/Components/FlightsGoneBy/AirlineLogo.vue'
|
||||
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
|
||||
import {computed} from "vue";
|
||||
import FlightBadge from "@/Components/FlightsGoneBy/FlightBadge.vue";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
regionCodes: string[]
|
||||
flightsByRegion: Record<string, Flight[]>
|
||||
regionNames?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const sortedRegionCodes = computed(() =>
|
||||
[...props.regionCodes].sort((a, b) => {
|
||||
const nameA = props.regionNames?.[a] ?? a
|
||||
const nameB = props.regionNames?.[b] ?? b
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table>
|
||||
<tr v-for="code in regionCodes" :key="code">
|
||||
<td>{{ regionNames?.[code] ?? code }}</td>
|
||||
<td>
|
||||
<template v-if="flightsByRegion[code]?.length">
|
||||
<span style="display:inline-flex; align-items:center; gap:0.25em; flex-wrap:wrap;">
|
||||
<FlightToolTip
|
||||
v-for="(flight, index) in flightsByRegion[code].slice(0, 5)"
|
||||
:key="flight.id"
|
||||
:flight="flight"
|
||||
>
|
||||
<InlineBadge style="display:inline-flex; align-items:center; gap:0.25em;">
|
||||
<AirlineLogo hideTooltip :airline="flight.airline" />
|
||||
{{ flight.flight_number || `${flight.departure_airport.display_code}-${flight.arrival_airport.display_code}` }}
|
||||
</InlineBadge>
|
||||
</FlightToolTip>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<BadgeTable
|
||||
:rows="sortedRegionCodes"
|
||||
:rowKey="code => code"
|
||||
:hasItems="code => !!flightsByRegion[code]?.length"
|
||||
>
|
||||
<template #label="{ row: code }">
|
||||
{{ regionNames?.[code] ?? code }}
|
||||
</template>
|
||||
|
||||
<template #items="{ row: code }">
|
||||
<FlightBadge
|
||||
v-for="flight in flightsByRegion[code]"
|
||||
:key="flight.id"
|
||||
:flight="flight"
|
||||
/>
|
||||
</template>
|
||||
</BadgeTable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
}
|
||||
table td {
|
||||
border: solid 1px;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -35,18 +35,13 @@
|
||||
<div class="stat">
|
||||
<template v-if="totalDistanceKm">
|
||||
<div class="stat-primary">
|
||||
<span class="stat-num">{{ totalDistanceKm.toLocaleString() }}</span>
|
||||
<span class="unit">km</span>
|
||||
<span class="stat-num"><Distance includeSpace :value="totalDistanceKm" /></span>
|
||||
</div>
|
||||
<div class="stat-sub">{{ totalDistanceMi.toLocaleString() }} miles</div>
|
||||
</template>
|
||||
<template v-if="upcomingDistanceKm">
|
||||
<div :class="totalDistanceKm ? 'stat-upcoming' : 'stat-primary'">
|
||||
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'">{{ upcomingDistanceKm.toLocaleString() }}</span>
|
||||
<span :class="totalDistanceKm ? 'stat-upcoming-lbl' : 'unit'">{{ totalDistanceKm ? 'km upcoming' : 'km' }}</span>
|
||||
<span v-if="!totalDistanceKm" class="upcoming-badge">upcoming</span>
|
||||
<span :class="totalDistanceKm ? 'stat-upcoming-num' : 'stat-num'"><Distance includeSpace :value="upcomingDistanceKm"/></span>
|
||||
</div>
|
||||
<div v-if="!totalDistanceKm" class="stat-sub">{{ upcomingDistanceMi.toLocaleString() }} miles</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -105,6 +100,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Flight } from '@/Types/types'
|
||||
import Distance from "@/Components/Distance.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
flights: Flight[]
|
||||
@@ -117,10 +113,6 @@ const totalDistanceKm = computed(() =>
|
||||
Math.round(props.flights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
|
||||
)
|
||||
|
||||
const totalDistanceMi = computed(() =>
|
||||
Math.round(totalDistanceKm.value * 0.621371)
|
||||
)
|
||||
|
||||
const uniqueRoutes = computed(() => {
|
||||
const keys = new Set(props.flights.map(f =>
|
||||
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
|
||||
@@ -164,10 +156,6 @@ const upcomingDistanceKm = computed(() =>
|
||||
Math.round(props.upcomingFlights.reduce((sum, f) => sum + (f.distance ?? 0), 0))
|
||||
)
|
||||
|
||||
const upcomingDistanceMi = computed(() =>
|
||||
Math.round(upcomingDistanceKm.value * 0.621371)
|
||||
)
|
||||
|
||||
const uniqueUpcomingRoutes = computed(() => {
|
||||
const keys = new Set(props.upcomingFlights.map(f =>
|
||||
[f.departure_airport.id, f.arrival_airport.id].sort().join('-')
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Flight } from "@/Types/types";
|
||||
import GlassTooltip from "@/Components/FlightsGoneBy/GlassTooltip.vue";
|
||||
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
|
||||
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
|
||||
import FlightMap from "@/Components/FlightsGoneBy/FlightMap.vue";
|
||||
|
||||
defineProps<{
|
||||
flight: Flight
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
defineProps<{
|
||||
rows: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
hasItems: (row: T) => boolean
|
||||
labelWidth?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="badge-table">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="rowKey(row)"
|
||||
class="badge-row"
|
||||
>
|
||||
<!-- Label column: consumer provides content -->
|
||||
<div class="badge-label" :style="labelWidth ? `width: ${labelWidth}` : ''">
|
||||
<slot name="label" :row="row" />
|
||||
</div>
|
||||
|
||||
<!-- Scrollable badge strip -->
|
||||
<div class="badge-scroll-container">
|
||||
<div v-if="hasItems(row)" class="badge-strip">
|
||||
<slot name="items" :row="row" />
|
||||
</div>
|
||||
<span v-else class="badge-empty">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge-table {
|
||||
width: 100%;
|
||||
border: 1px solid var(--table-border);
|
||||
border-bottom: none;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.badge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
min-height: 2.5em;
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
flex: 0 0 auto;
|
||||
width: 15em;
|
||||
padding: 0.5em;
|
||||
border-right: 1px solid var(--table-border);
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size:0.9rem;
|
||||
}
|
||||
|
||||
@container (max-width: 480px) {
|
||||
.badge-label {
|
||||
font-size: 0.75rem;
|
||||
width: 9em;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-scroll-container {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge-strip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
padding: 0.5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-empty {
|
||||
padding: 0.5em;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -29,8 +29,6 @@ defineProps<{
|
||||
}
|
||||
|
||||
.glass-tooltip {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--table-border);
|
||||
padding: 10px 14px;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
@@ -38,5 +36,6 @@ defineProps<{
|
||||
gap: 8px;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
z-index: 20000
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@ withDefaults(defineProps<{
|
||||
|
||||
<style scoped>
|
||||
.class-badge {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
defineProps<{
|
||||
centered?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel-sub-header"><slot/></div>
|
||||
<div class="panel-sub-header" :class="centered ? 'centered' : ''"><slot/></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -12,4 +14,8 @@
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.centered{
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@ const props = defineProps<{
|
||||
flightCount?: number
|
||||
achievementCount?: number
|
||||
isFollowing?: boolean
|
||||
show: "flights" | "achievements"
|
||||
}>()
|
||||
|
||||
const auth = usePage<SharedProps>().props.auth
|
||||
@@ -20,6 +21,13 @@ const processing = ref(false)
|
||||
const snackbar = ref(false)
|
||||
const snackbarMessage = ref('')
|
||||
|
||||
const counts = computed(() => {
|
||||
return {
|
||||
flights: props.flightCount ?? 0,
|
||||
achievements: props.achievementCount ?? 0,
|
||||
} as Record<"flights" | "achievements", number>
|
||||
})
|
||||
|
||||
const follow = async () => {
|
||||
processing.value = true
|
||||
const response = await fetch(route('profile.follow', { user: props.user.name }), {
|
||||
@@ -61,8 +69,8 @@ const follow = async () => {
|
||||
</div>
|
||||
|
||||
<div class="board-count">
|
||||
<span class="count-number">{{ flightCount ?? achievementCount }}</span>
|
||||
<span class="count-label">{{achievementCount ? 'Achievements' : 'Flights'}}</span>
|
||||
<span class="count-number">{{ counts[show] }}</span>
|
||||
<span class="count-label">{{show.toUpperCase()}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +122,12 @@ const follow = async () => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.board-header {
|
||||
padding: 1em
|
||||
}
|
||||
}
|
||||
|
||||
.follow-btn {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -14,7 +14,7 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="board-wrapper">
|
||||
<ProfileHeader :is-following="isFollowing" :user="user" :flightCount="flightCount" />
|
||||
<ProfileHeader :show="achievementCount && achievementCount > 0 ? 'achievements' : 'flights'" :is-following="isFollowing" :user="user" :flightCount="flightCount" :achievementCount="achievementCount" />
|
||||
<div v-if="loading" class="loading-state">
|
||||
<PlaneLoader />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
value: number
|
||||
}>()
|
||||
|
||||
|
||||
const formatted = computed(() =>
|
||||
new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(props.value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
{{ formatted }}
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user