25 Commits
Author SHA1 Message Date
Michal Kváček 9daea5974b Bugfixing 2024-05-07 08:24:57 +02:00
Michal Kváček a56c6828b1 Fix geocode u poi 2024-05-06 16:30:06 +02:00
Michal Kváček 670e80d998 Pridani linku na planovac do menu, drobny bugfixing 2024-05-06 09:40:25 +02:00
Michal Kváček be8966b5c1 Fullscreen u planovace 2024-05-02 16:29:17 +02:00
Michal Kváček 8dd0a16918 Ukladani metadat k planum 2024-04-30 22:34:41 +02:00
Michal Kváček 0760e6fb1c Vicemene funkcni mapova cast planovace 2024-04-29 06:45:29 +02:00
Michal Kváček bc0b5e2f15 Vkladani mista na konkretni pozici 2024-04-18 16:37:46 +02:00
Michal Kváček de0b75163d Dalsi iterace planovace, snad jiz kompletni vrstvy mapy 2024-04-17 13:21:32 +02:00
Michal Kváček cee979fec8 Zakladni verze planovace 2024-04-15 18:13:22 +02:00
Michal Kváček 338ec13db2 Merge branch 'master' of kvacek.cz:poletani.cz/app into planovac 2024-04-02 06:13:48 +02:00
Michal Kváček 9af80cda06 draft planovace 2024-03-25 21:59:55 +01:00
Michal Kváček 02b839674e Planovac, zobrazeni vzdusnych prostoru 2024-03-21 16:40:41 +01:00
Michal Kváček 275c48986e Fix zorbazovani udalosti 2024-03-14 06:27:30 +01:00
Michal Kváček 691bd4c7c5 Bootstrap na planovac 2024-03-14 06:27:01 +01:00
Michal Kváček c0cfd7115c uprava breadcrumbs v privatni casti, nasazeni prefetche 2024-03-13 16:16:08 +01:00
Michal Kváček 9379d3bfd6 Prusunuti nacitani detailu do prefetche (neverejna cast), zruseni resetu breadcrumbs a title pred zmenou routy 2024-03-13 07:31:53 +01:00
Michal Kváček 48f57cde0d Breadcrumbs resit pres funkce 2024-03-13 06:35:26 +01:00
Michal Kváček 5cff3580b0 Pagination prepsano do inteligentnejsi composable 2024-03-09 23:16:37 +01:00
Michal Kváček 883b4fa97f Init lightboxu az po otevreni fotky 2024-03-09 23:02:30 +01:00
Michal Kváček 722e832f1d Docasne vypnuti intersection observeru u fotek - nefunguje pak proklik na titulni fotku 2024-03-07 10:13:31 +01:00
Michal Kváček 415ff15ab9 Pridan intersection na galeriii a mapu -> zrychleni 2024-03-07 07:08:03 +01:00
Michal Kváček cd9c7351e9 Moznost nahravat fotky copilotem 2024-03-07 06:45:22 +01:00
Michal Kváček f0a4c439dc Fix nacitani spatnych dat do mapy u udalosti 2024-02-29 08:19:24 +01:00
Michal Kváček 7a45a0133e Mapa s trasami letu u eventu, drobna uprava vizualu eventu 2024-02-28 09:41:15 +01:00
Michal Kváček 92678ec829 Zobrazeni linku na event u detailu letu 2024-02-26 09:45:23 +01:00
102 changed files with 2974 additions and 726 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

+57
View File
@@ -0,0 +1,57 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
lowerLimit: {
type: [String, Number],
required: true,
},
lowerLimitUnit: {
type: String,
required: false // GND nema jednotky
},
upperLimit: {
type: [String, Number],
required: true,
},
upperLimitUnit: {
type: String,
required: false // GND nema jednotky
}
})
const getLimitFormatted = (limit, unit) => {
return `${limit} ${unit}`
}
const upper = computed(() => getLimitFormatted(props.upperLimit, props.upperLimitUnit));
const lower = computed(() => getLimitFormatted(props.lowerLimit, props.lowerLimitUnit));
</script>
<template>
<div class="altitude-limit">
<span class="upper">
{{ upper}}
</span>
<span class="lower">
{{ lower }}
</span>
</div>
</template>
<style lang="scss">
.altitude-limit {
display: inline-block;
text-align: center;
vertical-align: middle;
font-size: 12px;
.upper, .lower {
display: block;
}
.upper {
border-bottom: 1px solid black;
}
}
</style>
+39 -39
View File
@@ -6,17 +6,6 @@ import {isNumber, isRequired, maxLength, minLength} from "src/composables/form/r
import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
const formData = reactive({
id: null,
callSign: "",
model: "",
seats: null,
manufacturer: "",
description: "",
organization: null,
isPublic: false
});
const props = defineProps({
defaultValues: {
type: Object,
@@ -34,6 +23,17 @@ const props = defineProps({
}
});
const formData = reactive({
id: null,
callSign: "",
model: "",
seats: null,
manufacturer: "",
description: "",
organization: null,
isPublic: false
});
const rules = {
callSign: [isRequired, minLength(4), maxLength(8)],
seats: [isRequired, isNumber]
@@ -54,48 +54,48 @@ onMounted(() => {
<q-img :src="props.defaultValues.photoUrl" v-if="props.defaultValues.photoUrl"/>
<q-input
type="text"
:rules="rules.callSign"
v-model="formData.callSign"
label="Volací značka"
v-bind="commonProps"
type="text"
:rules="rules.callSign"
v-model="formData.callSign"
label="Volací značka"
v-bind="commonProps"
/>
<q-input
type="number"
v-model.number="formData.seats"
label="Počet míst (včetně pilota)"
min="1"
:rules="rules.seats"
v-bind="commonProps"
type="number"
v-model.number="formData.seats"
label="Počet míst (včetně pilota)"
min="1"
:rules="rules.seats"
v-bind="commonProps"
/>
<ComboboxInput
:options="organizations"
label="Organizace"
v-model="formData.organization"
v-bind="commonProps"
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
:options="organizations"
label="Organizace"
v-model="formData.organization"
v-bind="commonProps"
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
/>
<q-input
type="text"
v-model="formData.manufacturer"
label="Výrobce"
v-bind="commonProps"
type="text"
v-model="formData.manufacturer"
label="Výrobce"
v-bind="commonProps"
/>
<q-input
type="text"
v-model="formData.model"
label="Model"
v-bind="commonProps"
type="text"
v-model="formData.model"
label="Model"
v-bind="commonProps"
/>
<q-input
type="textarea"
v-model="formData.description"
label="Popis"
v-bind="commonProps"
type="textarea"
v-model="formData.description"
label="Popis"
v-bind="commonProps"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
import {FORMAT_DATE, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
const props = defineProps({
event: {
type: Object,
required: true
}
})
</script>
<template>
<q-chip icon="date_range" v-if="event.dateFrom || event.dateTo">
<template v-if="event.dateFrom !== event.dateTo">
{{ formatDatetime(event.dateFrom, FORMAT_DATE) }} - {{ formatDatetime(event.dateTo, FORMAT_DATE) }}
</template>
<template v-else>
{{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
</template>
</q-chip>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import MapWithMarkers from "components/map/MapWithMarkers.vue";
import {ref} from "vue";
import {useEventsStore} from "stores/Events";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {pluralize} from "src/composables/utils";
const props = defineProps({
eventId: {
type: Number,
required: false
},
eventUrlSlug: {
type: String,
required: false,
},
onlyPublic: {
type: Boolean,
required: true
}
})
const tracks = ref([]);
const totalTime = ref(0);
const totalFlights = ref(0);
useEventsStore().fetchFlightsTracks({
eventId: props.eventId,
eventUrlSlug: props.eventUrlSlug,
onlyPublic: props.onlyPublic
}).then((data) => {
if (!data.flights) return;
totalFlights.value = data.flights.length;
for (const flightTrack of data.flights) {
totalTime.value += flightTrack.durationMinCalculated;
if (flightTrack.gpxTrack) {
tracks.value.push(flightTrack.gpxTrack.coordinates);
}
}
});
</script>
<template>
<template v-if="tracks.length > 1">
<h3>Trasy letů</h3>
<MapWithMarkers
style="width: 100%; height: 500px;"
:tracks="tracks"
/>
<div class="text-center q-my-md">
<q-chip>
<q-avatar>{{ totalFlights }}</q-avatar>
{{ pluralize(totalFlights, "let", "lety", "letů") }}
</q-chip>
<q-chip icon="schedule">
{{ minutesToHumanReadable(totalTime) }}
</q-chip>
</div>
</template>
</template>
+2 -4
View File
@@ -14,7 +14,8 @@ import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({
event: {
type: Object
type: Object,
required: false,
},
isRequestPending: {
type: Boolean,
@@ -72,9 +73,6 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
</div>
</div>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
</FormWrapper>
</template>
+14
View File
@@ -0,0 +1,14 @@
{
"name": "event",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git@kvacek.cz:poletani.cz/app.git"
},
"private": true
}
@@ -0,0 +1,115 @@
<script setup>
import {computed, ref, watch} from "vue";
import {Marker, CustomMarker, Polyline} from "vue3-google-map";
import MapWrapper from "components/map/MapWrapper.vue";
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
import {useGeocode} from "src/composables/geocoding";
const props = defineProps({
flightPlan: {
type: Object,
required: true
},
airspaceData: {
type: Array,
required: true
},
airports: {
type: Array,
required: true,
},
pointsOfInterest: {
type: Array,
required: true
},
insertPosition: {
type: Number,
required: true
}
});
const emits = defineEmits(["markers-changed", "change-insert-position"]);
const {center} = useGeocode();
const {markerObject, googleMapsMarkerObject, editableNameTypes} = useFlightPlanObjects();
const {getMarkersFrom} = useFlightPlanUtils();
const mapWrap = ref(null);
const handleMarkerClick = (marker) => {
mapWrap.value.openInfoWindow({marker});
}
const handlePoiClick = (poi) => {
mapWrap.value.openInfoWindow({poi});
}
const markers = ref([]);
watch(() => props.flightPlan.track, (newVal) => {
markers.value = getMarkersFrom(props.flightPlan);
}, {immediate: true, deep: true});
const track = computed(() => markers.value.map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude})));
const addMarker = async (point) => {
if (!point) return;
const insertAt = Math.min(markers.value.length, props.insertPosition);
markers.value.splice(insertAt, 0, markerObject({...point}));
// infoWindow.value = infoWindowObject({active: false});
emits("markers-changed", markers.value);
emits("change-insert-position", insertAt + 1);
}
const {searchPlaceName} = useGeocode();
const handleMarkerMove = async (idx, event) => {
markers.value[idx].name = await searchPlaceName(event.lat(), event.lng());
markers.value[idx].gpsLatitude = event.lat();
markers.value[idx].gpsLongitude = event.lng();
markers.value[idx].type = "mapPoint"
emits("markers-changed", markers.value);
}
</script>
<template>
<MapWrapper
ref="mapWrap"
style="width: 100%; height: 100%; min-height: 500px"
:center="center"
:zoom="8"
:airspaces-data="airspaceData"
:airports="airports"
v-bind="$attrs"
:fullscreen-control="false"
:show-info-window="true"
:show-layer-controls="true"
:allow-add-marker="true"
@add-to-route="addMarker"
>
<template v-for="poi in pointsOfInterest" :key="poi.name">
<CustomMarker
v-if="poi.gpsLatitude && poi.gpsLongitude"
:options="{position: {lat: poi.gpsLatitude, lng: poi.gpsLongitude}, anchorPoint: 'BOTTOM_CENTER'}"
@click.stop="handlePoiClick(poi)"
>
<q-icon name="place" style="color: orange; font-size: 30px"/>
</CustomMarker>
</template>
<template v-for="(marker, i) in markers" :key="marker.name">
<Marker
v-if="editableNameTypes.includes(marker.type)"
:options="googleMapsMarkerObject(marker)"
@click="handleMarkerClick(marker)"
@dragend="handleMarkerMove(i, $event.latLng)"
/>
</template>
<Polyline :options="{path: track, geodesic: true, strokeColor: '#ff00ff', strokeOpacity: 1, strokeWeight: 3}"/>
</MapWrapper>
</template>
@@ -0,0 +1,71 @@
<script setup>
import {ref, watch} from "vue";
import {commonProps} from "src/composables/inputs";
import {useGeocode} from "src/composables/geocoding";
const props = defineProps({
options: {
type: Array,
required: true
}
})
const emits = defineEmits(["select"])
const place = ref("");
const placesOptions = ref(null);
const {searchGeocode} = useGeocode()
watch(() => place.value, (newVal) => {
if (newVal)
emits("select", newVal);
place.value = null;
});
const placesFilter = (val, update, abort) => {
update(() => {
if (val && val !== '') {
placesOptions.value = props.options.filter(p => p.name.includes(val));
if (placesOptions.value.length === 0) {
searchGeocode(val).then(results => {
if (!results) return;
placesOptions.value = results.map(result => ({
name: result.formatted_address,
type: "geocode",
id: null,
gpsLatitude: result.geometry.location.lat,
gpsLongitude: result.geometry.location.lng,
}))
});
}
} else {
placesOptions.value = props.options;
}
})
}
</script>
<template>
<q-select
v-bind="commonProps"
v-model="place"
label="Místo"
use-input
input-debounce="100"
map-options
emit-value
option-label="name"
option-disable="disabled"
:options="placesOptions"
@filter="placesFilter"
>
<template #no-option>
<q-item>
<q-item-section>
Toto místo tu nemám. Zkus psát dál, najdem něco v Google Places.
</q-item-section>
</q-item>
</template>
</q-select>
</template>
@@ -0,0 +1,119 @@
<script setup>
import {computed, ref, watch} from "vue";
import FlightPlanPlaceInput from "components/flightPlans/FlightPlanPlaceInput.vue";
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
const props = defineProps({
markers: {
type: Array,
required: true
},
pointsOfInterest: {
type: Array,
required: true
},
airports: {
type: Array,
required: true
}
});
const emits = defineEmits(["markers-changed"]);
const model = ref(props.markers);
const removeMarker = (idx) => {
model.value.splice(idx, 1);
}
const swapMarkers = (idx1, idx2) => {
const tmp = model.value[idx1];
model.value[idx1] = model.value[idx2];
model.value[idx2] = tmp;
};
const handlePlaceSelect = (modelVal) => {
console.log(modelVal);
model.value.push(modelVal);
}
watch(() => model.value, () => {
emits("markers-changed", model.value);
}, {deep: true});
const {getIcon} = useFlightPlanUtils();
const {editableNameTypes} = useFlightPlanObjects();
const allPlacesOptions = computed(() => {
const reportingPoints = props.airports.map(a => a.reportingPoints.map(r => ({...r, airport: a}))).flat()
return [
...props.pointsOfInterest.map(poi => ({...poi, type: "poi"})),
...reportingPoints.map(r => ({
...r,
name: `${r.airport.icaoCode}:${r.name}`,
type: "reportingPoint"
})),
...props.airports.map(airport => ({
...airport,
name: `${airport.icaoCode} - ${airport.name}`,
type: "airport",
}))
].map(i => ({...i, disabled: !i.gpsLatitude || !i.gpsLongitude}));
});
</script>
<template>
<q-scroll-area style="height: 60vh;">
<q-list bordered separator class="full-width">
<q-item v-for="(marker, i) in model" :key="marker">
<q-item-section side>
<q-icon :name="getIcon(marker.type)"></q-icon>
</q-item-section>
<q-item-section side>
<div>
<q-btn
v-if="i > 0"
text-color="primary"
size="16px"
flat dense round icon="arrow_upward"
@click="swapMarkers(i, i-1)"
/>
<q-btn
v-if="i < model.length - 1"
text-color="primary"
flat dense round icon="arrow_downward"
size="16px"
@click="swapMarkers(i, i+1)"
/>
</div>
</q-item-section>
<q-item-section>
<div class="cursor-pointer" v-if="editableNameTypes.includes(marker.type)">
{{ marker.name }}
<q-icon name="edit"/>
<q-popup-edit v-model="marker.name" v-slot="scope">
<q-input v-model="scope.value" autofocus @keyup.enter="scope.set">
<template #append>
<q-icon name="edit"/>
</template>
</q-input>
</q-popup-edit>
</div>
<template v-else>
{{ marker.name }}
</template>
</q-item-section>
<q-item-section side class="text-right">
<q-btn size="12px" @click="removeMarker(i)" flat dense round color="red" icon="delete"/>
</q-item-section>
</q-item>
</q-list>
</q-scroll-area>
<FlightPlanPlaceInput
:options="allPlacesOptions"
@select="handlePlaceSelect"
/>
</template>
@@ -0,0 +1,139 @@
<script setup>
import {computed, ref, watch} from "vue";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {useFlightPlanUtils} from "src/composables/flightPlan";
const props = defineProps({
flightPlan: {
type: Object,
required: true
},
plannedSpeed: {
type: Number,
required: false,
default: 150
},
isFullscreen: {
type: Boolean,
default: false,
},
insertPosition: {
type: Number,
required: true
}
});
const emits = defineEmits(["open", "change-insert-position", "fullscren-toggle"])
const getDuration = (distance, speed) => Math.round(distance / speed * 60)
const totalDistance = computed(() => {
if (!props.flightPlan.track.length) return null;
return Math.round(props.flightPlan.track.map(t => t.distance).reduce((a, b) => a + b));
});
const totalTime = computed(() => {
if (!props.flightPlan.track.length) return null;
return props.flightPlan.track.map(t => getDuration(t.distance, props.plannedSpeed)).reduce((a, b) => a + b)
});
</script>
<template>
<q-list bordered class="q-ml-md-md q-pt-md">
<q-item>
<q-item-section>
<div style="display: flex; justify-content: space-between">
<q-btn
@click="emits('change-insert-position', 0)"
icon="add"
:color="insertPosition === 0 ? 'primary' : 'grey'"
size="sm"
>
<template v-if="insertPosition === 0">Přidávám na začátek</template>
</q-btn>
<q-btn
@click='emits("fullscreen-toggle")'
size="sm"
:icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
/>
</div>
</q-item-section>
</q-item>
<q-scroll-area style="height: 50vh">
<template v-for="(segment, i) in props.flightPlan.track" :key="segment">
<q-item dense>
<q-item-section>
<q-item-label>{{ segment.from.name }}</q-item-label>
<q-item-label caption class="q-py-sm" v-if="segment.distance">
{{ segment.bearing }}°,
{{ segment.distance }} km,
{{ minutesToHumanReadable(getDuration(segment.distance, props.plannedSpeed)) }}
</q-item-label>
<q-item-label>
<q-btn
@click="emits('change-insert-position', i+1)"
icon="add"
size="sm"
class="q-ml-lg q-mb-sm"
:round="insertPosition !== i+1"
:color="insertPosition === i+1 ? 'primary' : 'grey'"
>
<template v-if="insertPosition === i+1">Přidávám sem</template>
</q-btn>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense v-if="segment.to && i === props.flightPlan.track.length - 1">
<q-item-section>
<q-item-label>
{{ segment.to.name }}
</q-item-label>
</q-item-section>
</q-item>
</template>
</q-scroll-area>
<q-item>
<q-item-section>
<div>
<q-btn
@click="emits('change-insert-position', flightPlan.track.length + 1)"
icon="add"
size="sm"
:color="insertPosition === flightPlan.track.length + 1? 'primary' : 'grey'"
>
<template v-if="insertPosition === flightPlan.track.length + 1">Přidávám na konec</template>
</q-btn>
</div>
</q-item-section>
</q-item>
<template v-if="totalDistance">
<q-item>
<q-item-section>
<q-separator/>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
Celková vzdálenost: {{ totalDistance }} km
<br>
Plánovaná doba letu: {{ minutesToHumanReadable(totalTime) }}
</q-item-section>
</q-item>
</template>
<q-item>
<q-item-section>
<q-btn @click="emits('open')" color="secondary">
{{ totalDistance ? 'Upravit trasu' : 'Sestavit trasu' }}
</q-btn>
</q-item-section>
</q-item>
</q-list>
</template>
@@ -0,0 +1,36 @@
<script setup>
import {formatDatetime} from "src/composables/datetimeUtils";
const props = defineProps({
items: {
type: Array,
required: true
}
})
</script>
<template>
<q-list bordered separator>
<q-item
clickable
:to="{name: 'editFlightPlan', params: {id: flightPlan.id }}"
v-ripple
v-for="flightPlan of items" :key="flightPlan">
<q-item-section avatar>
<q-avatar icon="flight" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
<q-item-label>
{{ flightPlan.name || "Nepojmenovaný plán" }}
</q-item-label>
<q-item-label caption v-if="flightPlan.plannedTakeoffDatetime">
{{ formatDatetime(flightPlan.plannedTakeoffDatetime) }}
</q-item-label>
<q-item-label caption v-if="flightPlan.aircraft">
{{ flightPlan.aircraft.callSign }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
@@ -0,0 +1,104 @@
import {computed} from "vue";
const markersCenter = computed(() => {
if (markers.value.length === 0) return null;
return {
gpsLatitude: markers.value.map(m => m.gpsLatitude).reduce((a, b) => a + b) / markers.value.length,
gpsLongitude: markers.value.map(m => m.gpsLongitude).reduce((a, b) => a + b) / markers.value.length,
}
});
const calcAngleFromCenter = (pointA, pointB) => {
if (!markersCenter.value) return 0;
const u = vector(markersCenter.value, pointA);
const v = vector(markersCenter.value, pointB);
return Math.acos(
(u[0] * v[0] + u[1] * v[1])
/
(vectorSize(u) * vectorSize(v))
)
}
let targetPosition = null;
let minAngleDiff = Infinity;
let mapPoints = 1;
const handleMapClickNefunkcni = (position) => {
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng};
const clickAngle = calcAngleFromCenter(clickPoint, markers.value[0]);
for (let i = 1; i < markers.value.length - 1; i++) {
const marker = markers.value[i];
const angle = calcAngleFromCenter(marker, markers.value[0]);
const angleDiff = clickAngle - angle;
console.log("click", i, marker.name, angleDiff);
if (angleDiff < minAngleDiff) {
console.log(" update => ", minAngleDiff, " > ", Math.abs(angleDiff), marker.name, " -> ", markers.value[i + 1].name);
minAngleDiff = angleDiff
targetPosition = Math.max(1, angleDiff < 0 ? i - 1 : i + 1);
}
// console.log(marker.name, angle);
}
console.log("click", clickAngle);
console.log("insert at position", targetPosition);
console.log("---------------")
markers.value.splice(targetPosition, 0, {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
mapPoints++;
}
const handleMapClickNefunkcni2 = (position) => {
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng}
const markersToSort = markers.value.slice(1, -1);
markersToSort.push({name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
markersToSort.sort(
(a, b) => calcAngleFromCenter(a, markers.value[0]) - calcAngleFromCenter(b, markers.value[0])
);
for (const marker of markersToSort) {
console.log(marker.name, calcAngleFromCenter(marker, markers.value[0]));
}
markers.value = [markers.value[0], ...markersToSort, markers.value[markers.value.length - 1]];
mapPoints++;
}
const calcTotalDistance = (points) => {
let distance = 0;
for (let i = 1; i < points.length; i++) {
distance += vectorDistance(points[i-1], points[i])
}
return distance
}
let minDistance = Infinity;
const handleMapClick = (position) => {
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng};
const newMarker = {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint}
markers.value.push(newMarker)
for (let i = 1; i < markers.value.length - 1; i++) {
console.log(markers.value[i], clickPoint);
const dist = vectorDistance(markers.value[i], clickPoint);
if (dist < minDistance) {
minDistance = dist;
targetPosition = i;
}
}
targetPosition = Math.min(markers.value.length - 1, Math.max(1, targetPosition));
markers.value.splice(targetPosition, 0, {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
mapPoints++;
}
+47 -6
View File
@@ -1,6 +1,7 @@
<script setup>
import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
import WeatherInfo from "components/weather/WeatherInfo.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
const props = defineProps({
flight: {
@@ -21,6 +22,16 @@ const props = defineProps({
type: Object,
required: false,
default: null
},
eventDetailRoute: {
type: Object,
required: false,
default: null
},
loggedUser: {
type: Object,
required: false,
default: null
}
})
</script>
@@ -122,6 +133,18 @@ const props = defineProps({
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
</q-item-section>
</q-item>
<q-item v-if="flight.event && (loggedUser || flight.event.isPublic)">
<q-item-section avatar>
<q-icon name="event"/>
</q-item-section>
<q-item-section>
<router-link :to="eventDetailRoute">
{{ flight.event.name }}
</router-link>
</q-item-section>
</q-item>
<q-item v-if="flight.copilots?.length > 0">
<q-list>
<q-item v-for="copilot in flight.copilots" :key="copilot.id">
@@ -129,14 +152,32 @@ const props = defineProps({
<q-icon name="people"/>
</q-item-section>
<q-item-section>
<router-link
v-if="copilot.isPublic"
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
>
{{ copilot.name }}
</router-link>
<template v-if="copilot.isPublic">
<div>
<router-link
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
>
{{ copilot.name }}
</router-link>
</div>
</template>
<template v-else> {{ copilot.name }}</template>
</q-item-section>
<q-item-section>
<CopyLinkButton
v-if="loggedUser && copilot.token && copilot.isPublic"
:route='{
name: "publicCopilotUploadPhoto",
params: {flightSlug: flight.urlSlug, username: loggedUser.publicUsername},
query: {token: copilot.token}
}'
icon="cloud_upload"
title="Získat odkaz na nahrání fotek od kopilota"
label=""
round
class="q-mx-3"
/>
</q-item-section>
</q-item>
</q-list>
</q-item>
+13 -10
View File
@@ -102,16 +102,19 @@ const chartOptions = {
<template>
<q-no-ssr>
<MapWithMarkers
v-if="displayMap"
style="width: 100%; height: 500px;"
:gpx-track="flight.gpxTrack?.coordinates || []"
:markers="pointsOfInterests"
:highlight-position="currentPositionMarker"
:photos="mapPhotos"
/>
<q-intersection>
<MapWithMarkers
v-if="displayMap"
style="width: 100%; height: 500px;"
:gpx-track="flight.gpxTrack?.coordinates || []"
:markers="pointsOfInterests"
:highlight-position="currentPositionMarker"
:photos="mapPhotos"
v-bind="$attrs"
/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
</q-intersection>
</q-no-ssr>
</template>
+2 -9
View File
@@ -5,6 +5,7 @@ import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {computed} from "vue";
import LoadMoreList from "components/LoadMoreList.vue";
import {pluralize} from "../../composables/utils";
const props = defineProps({
items: {
@@ -37,16 +38,8 @@ const routeDetail = (flight) => {
else
return {name: "flightDetail", params: {id: flight.id}}
};
const pluralize = (count, singular, plural, pluralMore) => {
if (count === 1) return singular;
else if (count < 5) return plural;
else return pluralMore;
}
const loadMoreCount = computed(() => {
const remaining = props.totalItemsCount - props.items.length;
return Math.min(props.itemsPerPage, remaining);
return Math.min(props.itemsPerPage, props.totalItemsCount - props.items.length);
});
</script>
@@ -1,8 +1,6 @@
<script setup>
import FormWrapper from "components/FormWrapper.vue";
import {reactive} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {roundWithPrecision} from "../../../composables/utils";
const props = defineProps({
flight: {
+1 -1
View File
@@ -100,7 +100,7 @@ const filterFn = (val, update, abort) => {
clear-icon="clear"
:v-bind="$attrs"
>
<template v-slot:no-option>
<template #no-option>
<q-item v-if="inputValue">
<q-item-section avatar>
<q-avatar text-color="green" size="32px" icon="add"/>
+14 -3
View File
@@ -7,6 +7,16 @@ const props = defineProps({
route: {
type: Object,
required: true
},
icon: {
type: String,
required: false,
default: "link"
},
label: {
type: String,
required: false,
default: "Získat odkaz"
}
})
@@ -24,9 +34,10 @@ const copyLinkToClipboard = () => {
<template>
<q-btn
icon="link"
@click="copyLinkToClipboard()"
:icon="icon"
:label="label"
v-bind="$attrs"
flat
label="Získat odkaz"
@click="copyLinkToClipboard()"
/>
</template>
+24 -24
View File
@@ -1,14 +1,9 @@
<script setup>
import {Marker} from "vue3-google-map";
import {onMounted, ref} from "vue";
import axios from "axios";
import MapWrapper from "components/map/MapWrapper.vue";
const center = ref({
lat: 50.1314,
lng: 14.5256
});
const zoom = ref(10);
import {useGeocode} from "src/composables/geocoding";
import axios from "axios";
const props = defineProps({
gpsLatitude: {
@@ -24,35 +19,30 @@ const props = defineProps({
required: false
}
});
const emits = defineEmits(["update:gpsLatitude", "update:gpsLongitude", "notFound"]);
const emitModels = (lat, lng) => {
emits("update:gpsLatitude", lat);
emits("update:gpsLongitude", lng);
}
const {searchGeocode, center} = useGeocode(emits);
const markerDropped = (position) => {
emitModels(position.latLng.lat(), position.latLng.lng());
}
const setCoordinatesFromGeocode = (address) => {
axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURI(address)}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => {
if (response.data.status !== 'OK') {
emits("notFound", props.label);
return;
}
center.value = response.data.results[0].geometry.location;
emitModels(center.value.lat, center.value.lng);
zoom.value = 13;
});
}
const zoom = ref(8);
onMounted(() => {
if (props.gpsLatitude && props.gpsLongitude) {
center.value.lng = props.gpsLongitude;
center.value.lat = props.gpsLatitude
} else if (props.label) {
setCoordinatesFromGeocode(props.label);
searchGeocode(props.label).then(results => {
if (!results) return;
center.value = results[0].geometry.location;
emitModels(center.value.lat, center.value.lng);
zoom.value = 13;
});
} else {
console.error("Set either label or gps coordinates.");
}
@@ -61,7 +51,17 @@ onMounted(() => {
</script>
<template>
<MapWrapper style="width: 100%; height: 500px" :center="center" :zoom="zoom">
<Marker :options="{ position: center, title: label, draggable: true }" @dragend="markerDropped"/>
<MapWrapper
:show-info-window="false"
:show-layer-controls="false"
style="width: 100%; height: 500px"
:center="center"
:zoom="zoom"
v-bind="$attrs"
>
<Marker
:options="{ position: center, title: label, draggable: true }"
@dragend="markerDropped"
/>
</MapWrapper>
</template>
+121
View File
@@ -0,0 +1,121 @@
<script setup>
import {InfoWindow} from "vue3-google-map";
import {useFlightPlanUtils} from "src/composables/flightPlan";
import {useGeocode} from "src/composables/geocoding";
import {ref, watch} from "vue";
import {useAirspaceColors} from "src/composables/airspaces";
import AltitudeLimit from "components/AltitudeLimit.vue";
const props = defineProps({
infoWindow: {
type: Object,
required: true
},
highlightedAirspaces: {
type: Array,
required: false,
default: () => []
},
showActions: {
type: Boolean,
required: false,
default: false
}
});
const emits = defineEmits(["add-to-route", "highlight-airspace", "close"])
const {getIcon} = useFlightPlanUtils();
const {searchPlaceName} = useGeocode()
const title = ref(null);
watch(() => props.infoWindow.position, async () => {
if (props.infoWindow.title) {
title.value = props.infoWindow.title
} else {
title.value = await searchPlaceName(props.infoWindow.position.lat, props.infoWindow.position.lng);
}
}, {immediate: true, deep: true});
const addPoint = async () => {
let type = "mapPoint";
if (props.infoWindow.poi) {
type = "poi";
} else if (props.infoWindow.airport) {
type = "airport";
}
const point = props.infoWindow.marker || {
name: title.value,
type: type,
gpsLatitude: props.infoWindow.position.lat,
gpsLongitude: props.infoWindow.position.lng
}
emits("add-to-route", point)
}
const {getColorByType} = useAirspaceColors();
const highlightAirspace = (airspace) => {
emits('highlight-airspace', airspace.name)
}
</script>
<template>
<InfoWindow
@close="emits('close')"
:options="{position: infoWindow.position}"
>
<div class="text-h5">{{ title }}</div>
<div v-if="infoWindow.marker">
<span class="text-h6">
<q-icon :name="getIcon(infoWindow.marker.type)"/>
{{ infoWindow.marker.name }}
</span>
</div>
<div v-if="infoWindow.poi">
<q-img :src="infoWindow.poi.titlePhoto.thumbnailUrl" width="300px" v-if="infoWindow.poi.titlePhoto"/>
</div>
<template v-if="infoWindow.airspaces?.length">
<span class="text-h6">Prostory</span>
<q-list>
<q-item-label v-for="airspace in infoWindow.airspaces" :key="airspace.name">
<q-avatar size="16px" class="q-mx-sm" :style="`border: solid 1px; background: ${getColorByType(airspace.type)}`"/>
<strong>
{{ airspace.name }}
</strong>
<AltitudeLimit
class="q-mx-md"
:upper-limit="airspace.upperLimit"
:upper-limit-unit="airspace.upperAltUnit"
:lower-limit="airspace.lowerLimit"
:lower-limit-unit="airspace.lowerAltUnit"
/>
<q-btn
v-if="airspace.type !== 'FIR'"
color="white"
:text-color="props.highlightedAirspaces.includes(airspace.name) ? 'primary' : 'grey-6'"
size="x-small"
round
text
icon="mdi-eye"
@click="highlightAirspace(airspace)"
/>
</q-item-label>
</q-list>
</template>
<q-btn
v-if="showActions"
class="q-mt-lg"
color="secondary"
@click="addPoint()">
Přidat bod do trasy
</q-btn>
</InfoWindow>
</template>
+53 -7
View File
@@ -1,14 +1,20 @@
<script setup>
import MapWrapper from "components/map/MapWrapper.vue";
import {Marker, Polyline, InfoWindow} from "vue3-google-map";
import {computed, onMounted, ref} from "vue";
import {axios} from "boot/axios";
import {computed} from "vue";
const props = defineProps({
gpxTrack: {
type: Object,
required: false,
default: () => {
}
},
tracks: {
type: Array,
required: false,
default: () => []
},
markers: {
type: Array,
@@ -26,12 +32,47 @@ const props = defineProps({
}
})
const track = ref([]);
const bounds = computed(() => {
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
const allData = [...props.markers, ...track.value, ...photosTransformed];
return allData.filter(i => !!i.position).map(i => i.position);
const coordinates = [
...[...props.markers, ...photosTransformed].filter(i => !!i.position).map(i => i.position),
...(props.gpxTrack || []),
...(props.tracks || []).flat()
];
const minLat = Math.min(...coordinates.map(i => i.lat));
const maxLat = Math.max(...coordinates.map(i => i.lat));
const minLng = Math.min(...coordinates.map(i => i.lng));
const maxLng = Math.max(...coordinates.map(i => i.lng));
return [
{lat: minLat, lng: minLng},
{lat: minLat, lng: maxLng},
{lat: maxLat, lng: minLng},
{lat: maxLat, lng: maxLng},
];
});
const getRandomColor = () => {
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
return `rgb(${r}, ${g}, ${b})`;
}
const getColor = (i) => {
const colors = [
'#FF0000',
'#5733FF', '#4bec41', '#8d3f25',
'#33A1FF', '#6d7e59', '#FF3333', '#0e1c48',
'#A133FF', '#FFFF33', '#c9decc'
]
return colors[i] || getRandomColor()
}
</script>
<template>
@@ -44,10 +85,15 @@ const bounds = computed(() => {
</Marker>
<template v-if="gpxTrack && gpxTrack.length > 0">
<Polyline :options="{path: gpxTrack, geodesic: true, strokeColor: '#ff0000', strokeOpacity: 0.3, strokeWeight: 5}"/>
<Polyline :options="{path: gpxTrack, geodesic: true, strokeColor: getColor(0), strokeOpacity: 0.5, strokeWeight: 5}"/>
</template>
<template v-if="tracks && tracks.length > 0">
<template v-for="(track, i) in tracks" :key="track">
<Polyline :options="{path: track, geodesic: true, strokeColor: getColor(i), strokeOpacity: 0.5, strokeWeight: 5}"/>
</template>
</template>
<Marker v-if="highlightPosition" :options="{position: highlightPosition, icon: '/mapIcons/currentPosition32x32.png'}"/>
</MapWrapper>
</template>
+216 -11
View File
@@ -1,37 +1,242 @@
<script setup>
import {GoogleMap} from "vue3-google-map";
import {ref, watch} from "vue";
import {GoogleMap, CustomControl, CustomMarker, Polygon} from "vue3-google-map";
import {computed, ref, watch} from "vue";
import {useVectorUtilities} from "src/graphql/fragments/vectorUtilities";
import {useAirspaceColors} from "src/composables/airspaces";
import AirspaceInfoWindow from "components/map/AirspaceInfoWindow.vue";
import {useFlightPlanObjects} from "src/composables/flightPlan";
const props = defineProps({
bounds: {
type: Array,
required: false
},
airspacesData: {
type: Array,
required: false,
default: () => []
},
airports: {
type: Array,
required: false,
default: () => []
},
showInfoWindow: {
type: Boolean,
required: false,
default: false,
},
allowAddMarker: {
type: Boolean,
required: false,
default: false
},
showLayerControls: {
type: Boolean,
required: false,
default: true
}
});
const emits = defineEmits(["map-click", "add-to-route"]);
const key = process.env.GOOGLE_MAPS_API_KEY;
const zoomLevel = ref(null);
const mapRef = ref(null);
watch(
() => mapRef.value?.ready,
(ready) => {
if (!props.bounds || !ready) return;
const map = mapRef.value.map;
const api = mapRef.value.api;
if (!ready) return;
const bounds = new api.LatLngBounds();
for (let i = 0; i < props.bounds.length; i++) {
bounds.extend(props.bounds[i]);
if (props.bounds) {
const bounds = new mapRef.value.api.LatLngBounds();
for (let i = 0; i < props.bounds.length; i++) {
bounds.extend(props.bounds[i]);
}
mapRef.value.map.fitBounds(bounds);
}
map.fitBounds(bounds);
zoomLevel.value = mapRef.value.map.getZoom();
mapRef.value.map.addListener("zoom_changed", () => {
console.log("zoom changed");
zoomLevel.value = mapRef.value.map.getZoom();
})
}
);
const displayAirspaceLayers = ref({
CTR: true,
TMA: true,
RMZ: false,
ATZ: true,
PARA: false,
DANGER: false,
PROHIBIT: true,
RESTRICT: true,
TRAGA: true,
AIRPORTS: true,
FIELDSTRIP: false,
});
const airspaces = computed(() => {
const colorsMap = useAirspaceColors().colors;
return props.airspacesData
.filter(airspace => !!colorsMap[airspace.type])
.filter(airspace => displayAirspaceLayers.value[airspace.type])
.map(airspace => {
let fillOpacity = 0;
if (["PROHIBIT", "RESTRICT"].includes(airspace.type))
fillOpacity = 0.1;
if (highlightedAirspaces.value.includes(airspace.name))
fillOpacity = 0.2;
const data = {
name: airspace.name,
strokeColor: colorsMap[airspace.type],
fillColor: colorsMap[airspace.type],
strokeOpacity: 0.5,
strokeWeight: 1,
fillOpacity,
};
if (airspace.mapBounds.length > 0) {
data['path'] = airspace.mapBounds;
}
return data
});
});
const airportsOnMap = computed(() => {
if (!displayAirspaceLayers.value.AIRPORTS && !displayAirspaceLayers.value.FIELDSTRIP) return [];
return props.airports.filter(a => a.gpsLatitude && a.gpsLongitude).filter(a => a.icaoCode.length === 6 ? displayAirspaceLayers.value.FIELDSTRIP : displayAirspaceLayers.value.AIRPORTS);
});
const {isPointInPolygon} = useVectorUtilities();
const getAirspacesAtCoordinates = (clickCoordinates) => {
const airspacesUnderClick = []
for (const airspace of props.airspacesData) {
if (airspace.mapBounds && isPointInPolygon(clickCoordinates, airspace.mapBounds)) {
airspacesUnderClick.push(airspace);
}
}
return airspacesUnderClick;
}
const {infoWindowObject} = useFlightPlanObjects();
const infoWindow = ref(infoWindowObject({}));
const openInfoWindow = (data) => {
infoWindow.value = infoWindowObject({active: true, ...data});
}
defineExpose({
openInfoWindow
});
const emitMapClick = (data) => {
openInfoWindow(data);
emits("map-click", data);
}
const highlightedAirspaces = ref([]);
const handleMapClick = (e) => {
const position = {lat: e.latLng.lat(), lng: e.latLng.lng()};
const airspacesUnderClick = getAirspacesAtCoordinates(position);
highlightedAirspaces.value = airspacesUnderClick.map(a => a.name);
emitMapClick({position, airspaces: airspacesUnderClick});
}
const handleReportingPointClick = (airport, reportingPoint) => {
const position = {lat: reportingPoint.gpsLatitude, lng: reportingPoint.gpsLongitude};
const airspacesUnderClick = getAirspacesAtCoordinates(position);
highlightedAirspaces.value = [];
emitMapClick({position, airport, reportingPoint, airspaces: airspacesUnderClick})
}
const handleAirportClick = (airport) => {
const position = {lat: airport.gpsLatitude, lng: airport.gpsLongitude};
const airspacesUnderClick = getAirspacesAtCoordinates(position);
highlightedAirspaces.value = [];
emitMapClick({position, airport, airspaces: airspacesUnderClick})
}
</script>
<template>
<GoogleMap :api-key="key" v-bind="$attrs" :max-zoom="13" ref="mapRef">
<GoogleMap
:api-key="key"
v-bind="$attrs"
:street-view-control="false"
@click="handleMapClick"
map-type-id="roadmap"
:max-zoom="13"
ref="mapRef"
>
<AirspaceInfoWindow
v-if="showInfoWindow && infoWindow.active"
:info-window="infoWindow"
:show-actions="allowAddMarker"
:highlighted-airspaces="highlightedAirspaces"
@close="infoWindow.active=false"
@add-to-route="emits('add-to-route', $event)"
@highlight-airspace="highlightedAirspaces = [$event]"
/>
<template v-for="airspace in airspaces" :key="airspace.name">
<Polygon :options="airspace" @click="handleMapClick"/>
</template>
<slot/>
<template v-for="airport in airportsOnMap" :key="airport.icaoCode">
<CustomMarker
:options="{position: {lat: airport.gpsLatitude, lng: airport.gpsLongitude}, anchorPoint: 'CENTER'}"
@click.stop="handleAirportClick(airport)"
>
<q-chip size="xs">{{ airport.icaoCode }}</q-chip>
</CustomMarker>
<template v-if="displayAirspaceLayers.CTR">
<CustomMarker
v-for="reportingPoint in airport.reportingPoints" :key="reportingPoint.id"
:options="{position: {lat: reportingPoint.gpsLatitude, lng: reportingPoint.gpsLongitude, anchorPoint: 'CENTER'}}"
@click.stop="handleReportingPointClick(airport, reportingPoint)"
:title="`${airport.icaoCode}: ${reportingPoint.name}`"
>
<q-icon name="mdi-triangle"/>
</CustomMarker>
</template>
</template>
<CustomControl position="BOTTOM_CENTER" class="text-center" v-if="showLayerControls">
<q-card class="q-my-md q-mx-auto q-px-lg" style="max-width: 98%;">
<q-checkbox v-model="displayAirspaceLayers.CTR" label="CTR"/>
<q-checkbox v-model="displayAirspaceLayers.TMA" label="TMA"/>
<q-checkbox v-model="displayAirspaceLayers.RMZ" label="RMZ"/>
<q-checkbox v-model="displayAirspaceLayers.ATZ" label="ATZ"/>
<q-checkbox v-model="displayAirspaceLayers.DANGER" label="Nebezpečné prostory"/>
<q-checkbox v-model="displayAirspaceLayers.PROHIBIT" label="Zakázané prostory"/>
<q-checkbox v-model="displayAirspaceLayers.RESTRICT" label="TRA"/>
<q-checkbox v-model="displayAirspaceLayers.TRAGA" label="TRAGA"/>
<q-checkbox v-model="displayAirspaceLayers.PARA" label="PARA"/>
<q-checkbox v-model="displayAirspaceLayers.AIRPORTS" label="Letiště"/>
<q-checkbox v-model="displayAirspaceLayers.FIELDSTRIP" label="SLZ plochy"/>
</q-card>
</CustomControl>
</GoogleMap>
</template>
+18 -23
View File
@@ -9,12 +9,12 @@
v-if="icon"
avatar
>
<q-icon :name="icon" />
<q-icon :name="icon"/>
</q-item-section>
<q-item-section>
<q-item-label>{{ title }}</q-item-label>
<q-item-label caption>{{ caption }}</q-item-label>
<q-item-label caption v-if="caption">{{ caption }}</q-item-label>
</q-item-section>
</q-item>
</template>
@@ -24,26 +24,21 @@ import {computed} from "vue";
import {useRoute, useRouter} from "vue-router";
const props = defineProps({
title: {
type: String,
required: true
},
caption: {
type: String,
default: ''
},
to: {
type: Object,
required: true
},
icon: {
type: String,
default: ''
}
});
title: {
type: String,
required: true
},
to: {
type: Object,
required: true
},
caption: {
type: String,
},
icon: {
type: String,
}
});
const $route = useRoute();
const $router = useRouter();
@@ -51,7 +46,7 @@ const isButtonActive = computed(() => {
const btnResolvedPath = $router.resolve(props.to).path;
if (props.to.name === 'myFlights') {
return $route.name.toLowerCase().includes("flight");
return ["myFlights", "addFlight", "flightDetail", "flightEdit"].includes($route.name);
} else {
return $route.fullPath === btnResolvedPath || $route.fullPath.startsWith(btnResolvedPath);
}
@@ -6,9 +6,15 @@ const linksList = [
{
title: 'Moje lety',
icon: "flight_takeoff",
to: {name: "myFlights"}
to: {name: "myFlights"},
},
{separator: true},
{
title: 'Moje plány',
icon: "pin_drop",
to: {name: "flightPlans"}
},
{separator: true},
{
title: 'Letadla',
+79 -35
View File
@@ -6,8 +6,9 @@ import PhotoSwipeDynamicCaption from "../../../node_modules/photoswipe-dynamic-c
import PhotoCaption from "components/photos/PhotoCaption.vue";
import PhotoSwipeLightbox from "../../../node_modules/photoswipe/dist/photoswipe-lightbox.esm"
import PhotoSwipe from "../../../node_modules/photoswipe/dist/photoswipe.esm"
import {useRoute, useRouter} from "vue-router";
import {useRouter} from "vue-router";
import {usePhotosStore} from "stores/Photos";
import {useQuasar} from "quasar";
const props = defineProps({
galleryId: {
@@ -21,9 +22,8 @@ const props = defineProps({
}
})
const route = useRoute();
const $router = useRouter();
const $q = useQuasar();
let $lightbox = ref(null);
@@ -46,18 +46,15 @@ const initLightbox = () => {
$lightbox.value.init();
}
const isCollapsed = ref(true);
const photosCollapseLimit = computed(() => {
if ($q.screen.width <= $q.screen.sizes.sm) return 2;
else if ($q.screen.width <= $q.screen.sizes.md) return 3;
else return 4;
});
onMounted(() => {
initLightbox();
// todo: tohle nefunguje, sice se galerie zavre, ale i tak se user presmeruje na predchozi stranku
window.onpopstate = function (e) {
if (!$lightbox.value || !$lightbox.value.pswp) {
initLightbox();
}
if ($lightbox.value && $lightbox.value.pswp)
$lightbox.value.pswp.close();
};
isCollapsed.value = props.photos.length >= photosCollapseLimit.value;
});
onUnmounted(() => {
@@ -69,38 +66,85 @@ onUnmounted(() => {
const openedPhoto = computed(() => usePhotosStore().openedPhoto);
watch(() => [openedPhoto.value, $lightbox.value], () => {
if (!$lightbox.value || !openedPhoto.value) return;
if (!openedPhoto.value) return;
if (!$lightbox.value) {
initLightbox();
}
const idx = props.photos.findIndex(p => p.id === openedPhoto.value.id);
if (idx >= 0) {
$lightbox.value.loadAndOpen(idx);
usePhotosStore().setOpenedPhoto(null);
}
}, {immediate: true})
}, {immediate: true});
</script>
<template>
<div :id="galleryId" class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" v-for="photo in photos" :key="photo.id">
<a
:href="photo.url"
:data-pswp-width="photo.width"
:data-pswp-height="photo.height"
target="_blank"
rel="noreferrer"
<div :class="{'photogallery--wrapper': true, collapsed: isCollapsed}" :id="`${galleryId}-wrapper`">
<div :id="galleryId" class="row photogallery--content">
<div
v-for="photo in photos" :key="photo.id"
style="min-height: 100px"
class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm"
>
<q-img
spinner-color="primary"
class="rounded-borders"
:src="photo.thumbnailUrl"
alt=""
<a
:data-pswp-src="photo.url"
:data-pswp-width="photo.width"
:data-pswp-height="photo.height"
target="_blank"
@click="initLightbox"
rel="noreferrer"
>
<div class="absolute-bottom" v-if="photo.name !== ''">
{{ photo.name }}
</div>
</q-img>
<PhotoCaption :photo="photo"/>
</a>
<q-img
spinner-color="primary"
class="rounded-borders"
:src="photo.thumbnailUrl"
alt=""
>
<div class="absolute-bottom" v-if="photo.name !== ''">
{{ photo.name }}
</div>
</q-img>
<PhotoCaption :photo="photo"/>
</a>
</div>
</div>
<a class="photogallery--collapse" v-if="isCollapsed" @click="isCollapsed = false">
<q-btn class="q-mt-lg q-mb-md" color="secondary">Rozbalit</q-btn>
</a>
</div>
</template>
<style lang="scss">
.photogallery--wrapper {
margin: 50px 0;
a:hover {
cursor: pointer
}
&.collapsed {
position: relative;
.photogallery--content {
max-height: 250px;
overflow: hidden
}
.photogallery--collapse {
display: block;
position: absolute;
width: 100%;
bottom: 0;
left: 0;
right: 0;
text-align: center;
background: rgb(255, 255, 255);
background: linear-gradient(0deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 1) 35%, rgba(255, 255, 255, 0) 100%);
}
}
}
</style>
+13 -89
View File
@@ -2,6 +2,7 @@
import {computed, onMounted, reactive, ref, watch} from "vue";
import {useQuasar} from "quasar";
import {confirmObject} from "src/composables/dialog";
import {usePhotoBatchManagement} from "src/composables/photos/batch";
const props = defineProps({
photos: {
@@ -19,100 +20,23 @@ const props = defineProps({
const $q = useQuasar();
const batchEdit = ref({});
for (const photo of props.photos) {
batchEdit.value[photo.id] = false;
}
const emits = defineEmits(["edit", "delete", "set-title-photo", "batch-edit", "batch-rotate"])
const openEdit = (photo) => {
emits("edit", photo);
};
const confirmPhotoDelete = (id) => {
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
.onOk(() => {
emits("delete", id);
});
.onOk(() => {
emits("delete", id);
});
}
const confirmBatchPhotoDelete = () => {
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?"))
.onOk(() => {
for (const id in batchEdit.value) {
if (!batchEdit.value[id]) continue;
emits("delete", parseInt(id));
batchEdit.value[id] = false;
}
});
}
const handleImageClick = (photo, idx) => {
batchEdit.value[photo.id] = !batchEdit.value[photo.id];
}
const selectedPhotos = computed(() => {
const photos = [];
for (const i in batchEdit.value) {
const photo = props.photos.find(p => p.id === parseInt(i));
if (photo && batchEdit.value[i]) photos.push(photo);
}
return photos;
});
const openBatchEdit = () => {
const photos = selectedPhotos.value;
for (const i in batchEdit.value) {
batchEdit.value[i] = false;
}
emits('batch-edit', photos);
}
const batchRotate = (direction) => {
const photos = selectedPhotos.value;
emits("batch-rotate", {photos, direction});
}
const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0);
const checkAll = ref(false);
watch(() => checkAll.value, (newVal) => {
if (newVal === null) return;
for (const photo of props.photos) {
batchEdit.value[photo.id] = newVal;
}
});
watch(() => batchEdit.value, () => {
let checkedAll = true;
let uncheckedAll = true;
for (const i of Object.keys(batchEdit.value)) {
if (typeof batchEdit.value[i] === 'undefined') continue;
if (batchEdit.value[i]) uncheckedAll = false;
if (!batchEdit.value[i]) checkedAll = false;
}
if (checkedAll) {
checkAll.value = true;
} else if (uncheckedAll) {
checkAll.value = false;
} else {
checkAll.value = null;
}
}, {deep: true});
const batchOptionsProps = computed(() => ({
round: true,
flat: true,
class: "q-ml-lg",
disable: !isBatchEditAllowed.value
}));
const {
openBatchEdit, batchEdit, handleImageClick, confirmBatchPhotoDelete, batchOptionsProps,
isBatchEditAllowed, batchRotate, checkAll
} = usePhotoBatchManagement({photos: props.photos, emits});
const setFlightCover = (titlePhoto) => {
emits("set-title-photo", titlePhoto);
@@ -136,10 +60,10 @@ const showLarge = (displayedPhoto) => {
<q-btn icon="fullscreen" color="grey-6" round @click.stop.prevent="showLarge(photo)"/>
</div>
<q-btn
icon="star"
:color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'"
round
@click.stop.prevent="setFlightCover(photo)"
icon="star"
:color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'"
round
@click.stop.prevent="setFlightCover(photo)"
/>
</div>
@@ -183,7 +107,7 @@ const showLarge = (displayedPhoto) => {
<q-dialog v-model="openPhoto" full-width>
<q-img :src="photo.url">
<div class="absolute-top bg-transparent text-right">
<q-btn icon="close" round color="primary" @click="openPhoto = false" />
<q-btn icon="close" round color="primary" @click="openPhoto = false"/>
</div>
</q-img>
</q-dialog>
@@ -0,0 +1,63 @@
<script setup>
import {useQuasar} from "quasar";
import {confirmObject} from "src/composables/dialog";
import {usePhotoBatchManagement} from "src/composables/photos/batch";
const props = defineProps({
photos: {
type: Array
},
titlePhoto: {
type: Object,
},
showActions: {
type: Boolean,
required: false,
default: true
}
});
const $q = useQuasar();
const emits = defineEmits(["delete", "batch-edit", "batch-rotate"])
const confirmPhotoDelete = (id) => {
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
.onOk(() => {
emits("delete", id);
});
}
const {
batchEdit, handleImageClick, confirmBatchPhotoDelete, batchOptionsProps, isBatchEditAllowed, batchRotate, checkAll
} = usePhotoBatchManagement({photos: props.photos, emits});
</script>
<template>
<div class="row" v-bind="$attrs">
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id">
<q-card :class='{"q-ma-sm": true, "shadow-1": !batchEdit[photo.id], "shadow-10": batchEdit[photo.id]}'>
<q-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)" style="cursor: pointer"/>
<q-card-actions align="right" :class="{'bg-primary': batchEdit[photo.id], 'text-white': batchEdit[photo.id]}" v-if="showActions">
<div class="col-grow">
<q-checkbox v-model="batchEdit[photo.id]"/>
</div>
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"/>
</q-card-actions>
</q-card>
</div>
</div>
<q-page-sticky :offset="[50, 50]" position="bottom" v-if="photos.length > 0 && showActions">
<q-card>
<q-card-actions>
<q-checkbox v-model="checkAll" label="Vybrat vše"/>
<q-btn v-bind="batchOptionsProps" icon="rotate_90_degrees_ccw" :color="isBatchEditAllowed ? 'secondary' : 'grey'" @click="batchRotate('counterClockwise')"/>
<q-btn v-bind="batchOptionsProps" icon="rotate_90_degrees_cw" :color="isBatchEditAllowed ? 'secondary' : 'grey'" @click="batchRotate('clockwise')"/>
<q-btn v-bind="batchOptionsProps" icon="delete" :color="isBatchEditAllowed ? 'red' : 'grey'" @click="confirmBatchPhotoDelete"/>
</q-card-actions>
</q-card>
</q-page-sticky>
</template>
-1
View File
@@ -1,5 +1,4 @@
<script setup>
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import LoadMoreList from "components/LoadMoreList.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
+20
View File
@@ -0,0 +1,20 @@
export const useAirspaceColors = () => {
const colors = {
TMA: "#800080",
CTR: "#366e36",
PROHIBIT: "#FF0000",
RMZ: "#FFdd00",
RESTRICT: "#ff6a00",
DANGER: "#FFA500",
TRAGA: "#008000",
ATZ: "#4e7cfc",
FIR: '#ffffff',
PARA: "#000000"
}
const getColorByType = (airspaceType) => {
return colors[airspaceType] || '#ff00ff';
}
return {colors, getColorByType}
}
+70
View File
@@ -0,0 +1,70 @@
import {flightName} from "src/composables/flight";
export const homepageBreadcrumbs = () => {
return [{to: {name: 'homepage'}, label: "Polétání.cz"}];
}
export const publicProfileBreadcrumbs = (pilot, link = true) => {
const profile = {label: `@${pilot.publicUsername}`}
if (link) {
profile.to = {name: "publicProfileDetail", params: {username: pilot.publicUsername}};
}
return [
...homepageBreadcrumbs(),
profile,
]
}
export const publicEventBreadcrumbs = (pilot, event, link = true) => {
const eventDetail = {label: event.name}
if (link) {
eventDetail.to = {
name: 'publicEventDetail',
params: {eventSlug: event.urlSlug, username: pilot.publicUsername}
};
}
return [
...publicProfileBreadcrumbs(pilot),
{
to: {name: 'publicEventsList', username: pilot.publicUsername},
label: "Události"
},
eventDetail
]
}
export const publicFlightBreadcrumbs = (pilot, flight, event, link = true) => {
let breadcrumbs;
if (event && event.isPublic) {
breadcrumbs = publicEventBreadcrumbs(pilot, event)
} else {
breadcrumbs = publicProfileBreadcrumbs(pilot)
}
const flightEntry = {label: flightName(flight)}
if (link) {
flightEntry.to = {name: "publicFlightDetail", params: {username: pilot.publicUsername, flightSlug: flight.urlSlug}}
}
breadcrumbs.push(flightEntry)
return breadcrumbs;
}
export const publicCopilotBreadcrumbs = (pilot, flight, event, copilot) => {
return [
...publicProfileBreadcrumbs(pilot),
{label: "Kopiloti"},
{label: copilot.name}
]
}
export const publicPOIBaseBreadcrumbs = () => {
return [
...homepageBreadcrumbs(),
{label: "Místa"},
]
}
-28
View File
@@ -14,34 +14,6 @@ export const minutesToHumanReadable = (totalMinutes) => {
return `${hoursPart}${minutes}min`
}
export const formatDatetimeNative = (date, format = "DD. MM. YYYY") => {
if (typeof date === 'string') {
date = parseDate(date);
}
return format
.replace("DD", date.getDate().toString())
.replace("MM", (date.getMonth() + 1).toString())
.replace("YYYY", date.getFullYear().toString())
// todo: doplnit pocatecni nuly
.replace("hh", date.getHours().toString())
.replace("mm", date.getMinutes().toString())
.replace("ss", date.getSeconds().toString());
}
export const parseDateNative = (datetime) => {
// const [date, time] = datetime.split("T");
// const [year, month, day] = date.split("-");
// const [hour, minute] = time.split(":");
// const dateObject = new Date(year, month, day, hour, minute, 0);
// ^ tohle funguje ok
const timestamp = Date.parse(datetime);
return new Date(timestamp);
}
export const parseDate = (datetimeStr, format = FORMAT_ISO) => {
return date.extractDate(datetimeStr, format)
}
+90
View File
@@ -0,0 +1,90 @@
export const useFlightPlanUtils = () => {
const getIcon = (type) => {
const icons = {
"poi": "pin_drop",
"geocode": "travel_explore",
"airport": "local_airport"
};
return icons[type] || "place"
}
const getMarkersFrom = (flightPlan) => {
const segments = flightPlan.track.length;
const markers = flightPlan.track.map(m => m.from);
if (segments > 0 && flightPlan.track[segments - 1].to) {
markers.push(flightPlan.track[segments - 1].to);
}
return markers;
}
return {getIcon, getMarkersFrom}
}
export const useFlightPlanObjects = () => {
const editableNameTypes = ["geocode", "mapPoint"];
const markerObject = ({name, type, gpsLatitude, gpsLongitude}) => ({
name, type, gpsLatitude, gpsLongitude
});
const googleMapsCustomMarkerObject = (marker) => {
const anchorPoints = {
airport: "CENTER",
geocode: "BOTTOM_CENTER",
poi: "BOTTOM_CENTER",
mapPoint: "BOTTOM_CENTER",
}
return {
anchorPoint: anchorPoints[marker.type],
position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}
}
}
const googleMapsMarkerObject = (marker) => {
return {
label: marker.name,
draggable: editableNameTypes.includes(marker.type),
position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}
}
}
const infoWindowObject = ({active, position, marker, poi, airport, reportingPoint, airspaces}) => {
if (!position) position = {};
if (!position.lat) position.lat = 0;
if (!position.lng) position.lng = 0;
let title = null;
if (airport) {
if (!reportingPoint) {
title = `${airport.icaoCode} (${airport.name})`;
position.lat = airport.gpsLatitude;
position.lng = airport.gpsLongitude;
} else {
title = `${airport.icaoCode}:${reportingPoint.name}`
position.lat = reportingPoint.gpsLatitude;
position.lng = reportingPoint.gpsLongitude;
}
}
if (marker) {
title = marker.name;
position.lat = marker.gpsLatitude;
position.lng = marker.gpsLongitude;
}
if (poi) {
title = poi.name;
position.lat = poi.gpsLatitude;
position.lng = poi.gpsLongitude;
}
return {title, active, position, airspaces, poi, marker, airport, reportingPoint};
};
return {markerObject, googleMapsMarkerObject, googleMapsCustomMarkerObject, infoWindowObject, editableNameTypes}
}
+44
View File
@@ -0,0 +1,44 @@
import {ref} from "vue";
import axios from "axios";
export const useGeocode = () => {
const center = ref({lat: 50.1314, lng: 14.5256});
const searchGeocode = (address) => {
return axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURI(address)}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => {
if (response.data.status !== 'OK') {
return null;
}
return response.data.results;
});
}
const searchPlaceName = (lat, lng) => {
return axios.get(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => {
if (response.data.status !== 'OK' || !response.data.results?.length) {
return null;
}
const results = response.data.results;
const components = {};
for (const addressComponent of results[0].address_components) {
for (const namePartType of ["locality", "postal_town", 'administrative_area_level_2']) {
if (addressComponent.types.includes(namePartType)) {
components[namePartType] = addressComponent.long_name
}
}
}
if (!Object.keys(components).length) {
return "Bod na mapě";
}
return Object.values(components).join(", ");
});
}
return {center, searchGeocode, searchPlaceName}
}
+9
View File
@@ -16,6 +16,15 @@ export const aircraftKeywords = (aircraft) => {
return [aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
};
export const flightEventKeywords = (event) => {
if (!event) return [];
return [event.name];
}
export const flightKeywords = (flight) => {
return ["let", "výlet", "letadlo"]
}
export const metaData = ({title, description, keywords, image, index = true}) => {
const filteredKeywords = (keywords || []).filter(kw => !!kw);
const filteredDescription = stripHtml(description);
+21 -12
View File
@@ -1,18 +1,27 @@
import {ref} from "vue";
export const paginationLimit = ref(10);
export const paginationOffset = ref(0);
export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
if (typeof fetchFunction === "undefined") {
console.error("Set fetchFunction to usePagination!");
}
export const filters = ref({});
const paginationLimit = ref(itemsPerPage);
const paginationOffset = ref(0);
const filters = ref({});
export const loadPage = ({fetchFunc, appendItems}) => {
return fetchFunc({
...filters.value,
limit: paginationLimit.value,
offset: paginationOffset.value,
appendItems
}).then(() => {
paginationOffset.value += paginationLimit.value;
})
const loadPage = (appendItems) => {
return fetchFunction({
...filters.value,
limit: paginationLimit.value,
offset: paginationOffset.value,
appendItems
}).then(() => {
paginationOffset.value += paginationLimit.value;
})
}
return {
paginationOffset, paginationLimit, filters, loadPage
}
}
+89
View File
@@ -0,0 +1,89 @@
import {ref} from "vue";
import {usePhotosStore} from "stores/Photos";
import {errorObject, successObject, warningObject} from "src/composables/dialog";
import {useQuasar} from "quasar";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {storeToRefs} from "pinia";
import {useFlightsStore} from "stores/Flights";
export const useBasicPhotoOperations = ({copilotUploadToken}) => {
const $q = useQuasar();
const {flight} = storeToRefs(useFlightsStore());
const {photos} = storeToRefs(usePhotosStore());
// TODO: V PRIVATNI CASTI JE TO ZATIM ROZBITE!!!
const uploadInProgress = ref(false);
const handlePhotosUpload = (data) => {
const promises = [];
uploadInProgress.value = true;
for (const photo of data.photos) {
promises.push(usePhotosStore().upload(flight.value.id, {photo, copilotUploadToken}).then((data) => {
photos.value.push(data);
photo._uploaded = true;
}));
}
Promise.allSettled(promises).then((values) => {
const success = values.filter(v => v.status === 'fulfilled');
const failed = values.filter(v => v.status === 'rejected'); // TODO: nebo vse - success?
if (success.length > 0)
$q.notify(successObject(`Úspěšně nahráno ${success.length} fotek`));
if (failed.length > 0)
$q.notify(warningObject(`Nepovedlo se nahrát ${failed.length} fotek. Zde jsou důvody: ${failed.map(f => f.reason)}`));
data.photos = [];
}).finally(() => uploadInProgress.value = false);
}
const _findPhotoIndex = (photoId) => photos.value.findIndex((object) => object.id === photoId);
const deletePhoto = (id) => {
usePhotosStore().destroy(id, copilotUploadToken).then((data) => {
photos.value.splice(_findPhotoIndex(data.id), 1);
});
}
const batchRotatePhotos = (data) => {
const selectedPhotos = data.photos;
const direction = data.direction;
const promises = [];
for (const photo of selectedPhotos) {
promises.push(usePhotosStore().changeOrientation(photo.id, direction, copilotUploadToken).then(data => {
const idx = _findPhotoIndex(data.id);
data.thumbnailUrl += "?t=" + (new Date()).getTime();
photos.value[idx] = data;
}));
}
let success = 0;
Promise.allSettled(promises).then(result => {
for (const item of result) {
if (item.status === 'fulfilled') success++;
else console.error(item.reason)
}
const error = result.length - success;
if (success > 0) $q.notify(successObject("Otočeno " + success + " fotografií"));
if (error > 0) $q.notify(errorObject(`Nepovedlo se otočit ${error} fotografi`))
});
}
const updateSinglePhoto = (id, formData) => {
formData.copilots = formData.copilots.map(c => ({id: c.id, name: c.name}));
return usePhotosStore().update(id, formData).then((data) => {
const idx = _findPhotoIndex(data.id);
photos.value[idx] = data;
usePointsOfInterestStore().fetch({});
});
}
return {
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
}
}
+96
View File
@@ -0,0 +1,96 @@
import {computed, ref, watch} from "vue";
import {confirmObject} from "src/composables/dialog";
import {useQuasar} from "quasar";
export const usePhotoBatchManagement = ({photos, emits}) => {
const batchEdit = ref({});
const $q = useQuasar();
// console.log(photos);
for (const photo of photos) {
batchEdit.value[photo.id] = false;
}
const handleImageClick = (photo, idx) => {
batchEdit.value[photo.id] = !batchEdit.value[photo.id];
}
const selectedPhotos = computed(() => {
const selected = [];
for (const i in batchEdit.value) {
const photo = photos.find(p => p.id === parseInt(i));
if (photo && batchEdit.value[i]) selected.push(photo);
}
return selected;
});
const confirmBatchPhotoDelete = () => {
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?"))
.onOk(() => {
for (const id in batchEdit.value) {
if (!batchEdit.value[id]) continue;
emits("delete", parseInt(id));
batchEdit.value[id] = false;
}
});
}
const openBatchEdit = () => {
const photos = selectedPhotos.value;
for (const i in batchEdit.value) {
batchEdit.value[i] = false;
}
emits('batch-edit', photos);
}
const batchRotate = (direction) => {
const photos = selectedPhotos.value;
emits("batch-rotate", {photos, direction});
}
const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0);
const checkAll = ref(false);
watch(() => checkAll.value, (newVal) => {
if (newVal === null) return;
for (const photo of photos) {
batchEdit.value[photo.id] = newVal;
}
});
const batchOptionsProps = computed(() => ({
round: true,
flat: true,
class: "q-ml-lg",
disable: !isBatchEditAllowed.value
}));
watch(() => batchEdit.value, () => {
let checkedAll = true;
let uncheckedAll = true;
for (const i of Object.keys(batchEdit.value)) {
if (typeof batchEdit.value[i] === 'undefined') continue;
if (batchEdit.value[i]) uncheckedAll = false;
if (!batchEdit.value[i]) checkedAll = false;
}
if (checkedAll) {
checkAll.value = true;
} else if (uncheckedAll) {
checkAll.value = false;
} else {
checkAll.value = null;
}
}, {deep: true});
return {
batchEdit, handleImageClick, selectedPhotos, confirmBatchPhotoDelete,
openBatchEdit, batchRotate, checkAll, isBatchEditAllowed, batchOptionsProps
}
};
+6
View File
@@ -2,3 +2,9 @@ export const roundWithPrecision = (number, precision) => {
const factor = Math.pow(10, precision);
return Math.round(number * factor) / factor;
}
export const pluralize = (count, singular, plural, pluralMore) => {
if (count === 1) return singular;
else if (count < 5) return plural;
else return pluralMore;
}
+1
View File
@@ -9,6 +9,7 @@ fragment aircraftListInfo on Aircraft {
model
manufacturer
isPublic
seats
titlePhoto {
thumbnailUrl
+6
View File
@@ -7,5 +7,11 @@ export const FRAGMENT_AIRPORT_INFO = gql`
icaoCode
gpsLatitude
gpsLongitude
reportingPoints {
id
gpsLatitude
gpsLongitude
name
}
}
`;
+10 -2
View File
@@ -11,18 +11,26 @@ fragment copilotListInfo on Copilot {
}
`;
export const FRAGMENT_COPILOT_FULL_INFO = gql`
fragment copilotFullInfo on Copilot {
export const FRAGMENT_COPILOT_PUBLIC_INFO = gql`
fragment copilotPublicInfo on Copilot {
id
name
isPublic
urlSlug
}
`
export const FRAGMENT_COPILOT_FULL_INFO = gql`
fragment copilotFullInfo on Copilot {
...copilotPublicInfo
titlePhoto {
id
url
}
}
${FRAGMENT_COPILOT_PUBLIC_INFO}
`;
export const FRAGMENT_COPILOT_FULL_PRIVATE_INFO = gql`
+9 -7
View File
@@ -3,6 +3,7 @@ import {FRAGMENT_AIRCRAFT_FULL_INFO} from "src/graphql/fragments/aircraft";
import {FRAGMENT_AIRPORT_INFO} from "src/graphql/fragments/airport";
import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
import {FRAGMENT_POI_FULL_INFO} from "src/graphql/fragments/poi";
import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
export const FRAGMENT_WEATHER_FULL_INFO = gql`
fragment weatherFullInfo on WeatherInfo {
@@ -51,15 +52,10 @@ fragment flightFullInfo on Flight {
...weatherFullInfo
}
copilots {
id
name
isPublic
urlSlug
}
event {
id
isPublic
urlSlug
name
}
@@ -105,6 +101,11 @@ fragment flightFullPrivateInfo on Flight {
...flightFullInfo
debriefingNotes
copilots {
...copilotPublicInfo
token
}
photos {
...photoFullInfo
@@ -122,4 +123,5 @@ fragment flightFullPrivateInfo on Flight {
${FRAGMENT_FLIGHT_FULL_INFO}
${FRAGMENT_PHOTO_FULL_INFO}
${FRAGMENT_COPILOT_PUBLIC_INFO}
`;
+52
View File
@@ -0,0 +1,52 @@
import gql from "graphql-tag";
export const FRAGMENT_FLIGHT_PLAN_MARKER = gql`
fragment flightPlanMarker on FlightPlanMarker {
name
position
type
gpsLatitude
gpsLongitude
}
`
export const FRAGMENT_FLIGHT_PLAN_FULL_INFO = gql`
fragment flightPlanFullInfo on FlightPlan {
id
name
plannedSpeed
plannedTakeoffDatetime
isDefaultName
isPublic
isEditableByCopilot
aircraft {
id
callSign
seats
}
track {
bearing
distance
from {
...flightPlanMarker
}
to {
...flightPlanMarker
}
}
}
${FRAGMENT_FLIGHT_PLAN_MARKER}
`;
export const FRAGMENT_FLIGHT_PLAN_LIST_INFO = gql`
fragment flightPlanListInfo on FlightPlan {
id
name
plannedTakeoffDatetime
aircraft {
callSign
}
}
`;
+2
View File
@@ -8,6 +8,8 @@ fragment pointOfInterestListInfo on PointOfInterest {
name
urlSlug
isPublic
gpsLatitude
gpsLongitude
titlePhoto {
thumbnailUrl
}
+46
View File
@@ -0,0 +1,46 @@
export const useBasicVectorOperations = () => {
const vectorSize = (a) => {
return Math.sqrt(Math.pow(a[0], 2) + Math.pow(a[1], 2));
}
const vector = (pointA, pointB) => {
return [pointA.gpsLatitude - pointB.gpsLatitude, pointA.gpsLongitude - pointB.gpsLongitude];
}
const vectorDistance = (pointA, pointB) => {
const u = [pointA.gpsLatitude || pointA.lat, pointA.gpsLongitude || pointA.lng];
const v = [pointB.gpsLatitude || pointB.lat, pointB.gpsLongitude || pointB.lng];
// const v = vector(pointB);
return Math.sqrt(
Math.pow(u[0] - v[0], 2) + Math.pow(u[1] - v[1], 2)
);
}
return {vector, vectorSize, vectorDistance}
}
export const useVectorUtilities = () => {
function isPointInPolygon(clickPoint, polygonCoords) {
let count = 0;
const x = clickPoint.lng, y = clickPoint.lat;
for (let i = 0; i < polygonCoords.length; i++) {
const iNext = (i + 1) % polygonCoords.length;
const xi = polygonCoords[i].lng, yi = polygonCoords[i].lat;
const xiNext = polygonCoords[iNext].lng, yiNext = polygonCoords[iNext].lat;
const test = ((yi > y) !== (yiNext > y)) && (x < (xiNext - xi) * (y - yi) / (yiNext - yi) + xi);
if (test) count++;
}
return count % 2 === 1; // Lichý počet průsečíků = bod uvnitř
}
const vectorDistanceToKm = (vectorDistance) => {
return vectorDistance * (2*Math.PI * 6371 / 360);
}
return {isPointInPolygon, vectorDistanceToKm}
}
+28
View File
@@ -0,0 +1,28 @@
import gql from "graphql-tag";
import {FRAGMENT_FLIGHT_PLAN_FULL_INFO} from "src/graphql/fragments/flightPlan";
export const CREATE_FLIGHT_PLAN = gql`
mutation createFlightPlan($input: CreateFlightPlanInput!) {
createFlightPlan(input: $input) {
...flightPlanFullInfo
}
}
${FRAGMENT_FLIGHT_PLAN_FULL_INFO}
`;
export const EDIT_FLIGHT_PLAN = gql`
mutation editFlightPlan($id: Int!, $input: EditFlightPlanInput!) {
editFlightPlan(id: $id, input: $input) {
...flightPlanFullInfo
}
}
${FRAGMENT_FLIGHT_PLAN_FULL_INFO}
`;
export const DELETE_FLIGHT_PLAN = gql`
mutation deleteFlightPlan($id: Int!) {
deleteFlightPlan(id: $id) {
id
}
}
`;
+4 -4
View File
@@ -23,8 +23,8 @@ mutation editPhoto($id: Int!, $input: EditPhotoInput!) {
`;
export const CHANGE_PHOTO_ORIENTATION = gql`
mutation changeOrientation($id: Int!, $direction: String!) {
changeOrientation(id: $id, direction: $direction) {
mutation changeOrientation($id: Int!, $direction: String!, $copilotUploadToken: String) {
changeOrientation(id: $id, direction: $direction, copilotUploadToken: $copilotUploadToken) {
...photoFullInfo
flight {
@@ -67,8 +67,8 @@ mutation adjustPhoto($id: Int!, $adjustment: AdjustmentInput!) {
// TODO: ^^ udelat z toho asi jeden fragment a pouzit i v query GET_PHOTO
export const DELETE_PHOTO = gql`
mutation deletePhoto($id: Int!) {
deletePhoto(id: $id) {
mutation deletePhoto($id: Int!, $copilotUploadToken: String) {
deletePhoto(id: $id, copilotUploadToken: $copilotUploadToken) {
id
}
}
+4 -3
View File
@@ -1,11 +1,12 @@
import gql from "graphql-tag";
import {FRAGMENT_AIRPORT_INFO} from "src/graphql/fragments/airport";
export const AIRPORTS_LIST = gql`
query AirportsList {
airports {
id
name
icaoCode
...airportInfo
}
}
${FRAGMENT_AIRPORT_INFO}
`;
+20
View File
@@ -0,0 +1,20 @@
import gql from "graphql-tag";
// TODO: dodelat filtrovani podle typu a zobrazene zeme?
export const AIRSPACE_LIST = gql`
query AirspaceList {
airspaces {
name
type
lowerLimit
lowerAltUnit
upperLimit
upperAltUnit
mapBounds {
lat
lng
}
}
}
`;
+8 -2
View File
@@ -34,8 +34,14 @@ ${FRAGMENT_FLIGHT_LIST_INFO}
`;
export const COPILOT_PUBLIC_DETAIL = gql`
query copilot($urlSlug: String!, $pilotUsername: String) {
copilot(urlSlug: $urlSlug, pilotUsername: $pilotUsername, public: true) {
query copilot($urlSlug: String, $uploadToken: String, $uploadFlightSlug: String, $pilotUsername: String!) {
copilot(
urlSlug: $urlSlug,
uploadToken: $uploadToken,
uploadFlightSlug: $uploadFlightSlug,
pilotUsername: $pilotUsername,
public: true
) {
...copilotFullInfo
}
}
+17 -1
View File
@@ -20,13 +20,29 @@ query eventsList($limit: Int!, $offset: Int) {
${FRAGMENT_EVENT_LIST_INFO}
`;
export const EVENTS_FLIGHTS_TRACKS = gql`
query eventsFlightsTracks($eventId: Int, $eventUrlSlug: String, $onlyPublic: Boolean) {
event(id: $eventId, urlSlug: $eventUrlSlug, public: $onlyPublic) {
flights(onlyPublic: $onlyPublic) {
durationMinCalculated
gpxTrack {
coordinates {
lat
lng
}
}
}
}
}
`;
export const EVENTS_PUBLIC_LIST = gql`
query eventsPublicList($username: String, $limit: Int!, $offset: Int) {
events(username: $username, limit: $limit, offset: $offset, public: true) {
items {
...eventListInfo
flights {
flights(onlyPublic: true) {
titlePhoto {
thumbnailUrl
}
+22
View File
@@ -0,0 +1,22 @@
import gql from "graphql-tag";
import {FRAGMENT_FLIGHT_PLAN_FULL_INFO, FRAGMENT_FLIGHT_PLAN_LIST_INFO} from "src/graphql/fragments/flightPlan";
export const FLIGHT_PLANS_LIST = gql`
query flightPlansList {
flightPlans {
...flightPlanListInfo
}
}
${FRAGMENT_FLIGHT_PLAN_LIST_INFO}
`;
export const FLIGHT_PLAN_DETAIL = gql`
query flightPlansDetail($id: Int!) {
flightPlan(id: $id) {
...flightPlanFullInfo
}
}
${FRAGMENT_FLIGHT_PLAN_FULL_INFO}
`;
+7 -1
View File
@@ -2,6 +2,7 @@ import gql from "graphql-tag";
import {FRAGMENT_FLIGHT_FULL_INFO, FRAGMENT_FLIGHT_FULL_PRIVATE_INFO} from "src/graphql/fragments/flight";
import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list";
import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
export const FLIGHTS_LIST = gql`
query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $limit: Int!, $offset: Int) {
@@ -58,12 +59,17 @@ ${FRAGMENT_FLIGHT_FULL_PRIVATE_INFO}
`;
export const FLIGHT_PUBLIC_DETAIL = gql`
query FlightPublicDetail($urlSlug: String!, $username: String) {
query FlightPublicDetail($urlSlug: String, $username: String) {
flight(urlSlug: $urlSlug, username: $username, public: true) {
...flightFullInfo
copilots {
...copilotPublicInfo
}
}
}
${FRAGMENT_FLIGHT_FULL_INFO}
${FRAGMENT_COPILOT_PUBLIC_INFO}
`;
+15 -2
View File
@@ -39,8 +39,21 @@ ${FRAGMENT_FLIGHT_LIST_INFO}
`;
export const PHOTOS_PUBLIC_LIST = gql`
query publicPhotos($flightId: Int, $pointOfInterestId: Int, $aircraftId: Int, $copilotId: Int) {
photos(public: true, flightId: $flightId, pointOfInterestId: $pointOfInterestId, aircraftId: $aircraftId, copilotId: $copilotId) {
query publicPhotos(
$flightId: Int,
$pointOfInterestId: Int,
$aircraftId: Int,
$copilotId: Int,
$uploadedByCopilotId: Int
) {
photos(
public: true,
flightId: $flightId,
pointOfInterestId: $pointOfInterestId,
aircraftId: $aircraftId,
copilotId: $copilotId,
uploadedByCopilotId: $uploadedByCopilotId
) {
...photoFullInfo
}
}
+2
View File
@@ -10,6 +10,8 @@ import ChangePasswordForm from "components/profile/ChangePasswordForm.vue";
import PublicProfileInfo from "components/profile/PublicProfileInfo.vue";
useAppStore().setPageTitle("Profil");
useAppStore().setBreadcrumbs([]);
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const $q = useQuasar();
@@ -1,3 +1,13 @@
<script>
import {useAircraftsStore} from "stores/Aircrafts";
export default {
preFetch({currentRoute}) {
return useAircraftsStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import {useRoute, useRouter} from "vue-router";
import {useMeta, useQuasar} from "quasar";
@@ -6,8 +16,6 @@ import {confirmObject, successObject} from "src/composables/dialog";
import {useAppStore} from "stores/App";
import FlightsList from "components/flights/FlightsList.vue";
import {storeToRefs} from "pinia";
import {useAircraftsStore} from "stores/Aircrafts";
import {watch} from "vue";
import {metaData} from "src/composables/meta";
import PhotoGallery from "components/photos/PhotoGallery.vue";
import ContentCard from "components/ContentCard.vue";
@@ -16,26 +24,24 @@ import {useLoggedUserStore} from "stores/LoggedUser";
import {useFlightsStore} from "stores/Flights";
import {toggleVisibility} from "src/composables/form/formHandling";
import ContentCardEditButton from "components/ContentCardEditButton.vue";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import PublishButton from "components/inputs/PublishButton.vue";
import {onMounted} from "vue";
const aircraftId = parseInt(useRoute().params.id);
useAircraftsStore().fetchDetail(aircraftId);
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {aircraft, isQueryRunning} = storeToRefs(useAircraftsStore());
watch(() => aircraft.value, (newVal) => {
if (!newVal) return;
useAppStore().setPageTitle(newVal.callSign);
useMeta(() => metaData({title: newVal.callSign}));
useAppStore().setBreadcrumbs([
{label: "Letadla", to: {name: "aircrafts"}},
{label: newVal.callSign}
])
});
useAppStore().setPageTitle(aircraft.value.callSign);
useMeta(() => metaData({title: aircraft.value.callSign}));
useAppStore().setBreadcrumbs([
{label: "Letadla", to: {name: "aircrafts"}},
{label: aircraft.value.callSign}
])
const $q = useQuasar();
const $router = useRouter();
@@ -53,15 +59,12 @@ const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useAircraftsStore(), object: aircraft.value, variables
});
filters.value = {aircraftId: aircraftId};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch,
appendItems
});
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
loadNextFlightsPage(false);
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
filters.value = {aircraftId: aircraftId};
onMounted(() => {
loadPage(false);
})
</script>
<template>
@@ -113,7 +116,7 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
/>
</q-card-section>
</template>
+8 -10
View File
@@ -6,19 +6,17 @@ import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useAircraftsStore} from "stores/Aircrafts";
import {storeToRefs} from "pinia";
import {useAppStore} from "stores/App";
import FlightsList from "components/flights/FlightsList.vue";
import {ref} from "vue";
import {useFlightsStore} from "stores/Flights";
import {loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import {onMounted} from "vue";
useAppStore().setPageTitle("Letadla");
const loadNextAircraftsPage = (appendItems) => loadPage({
fetchFunc: useAircraftsStore().fetch, appendItems
});
loadNextAircraftsPage(false);
useAppStore().setBreadcrumbs([])
const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsStore());
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useAircraftsStore().fetch});
onMounted(() => {
loadPage(false);
})
</script>
<template>
@@ -42,7 +40,7 @@ const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsSto
:items-per-page="paginationLimit"
:public="true"
class="q-mb-xl"
@load-more="loadNextAircraftsPage(true)"
@load-more="loadPage(true)"
/>
<FAB mode="add" :router-link="{name: 'addAircraft'}"/>
+16 -11
View File
@@ -1,6 +1,15 @@
<script>
import {useAircraftsStore} from "stores/Aircrafts";
export default {
preFetch({currentRoute}) {
return useAircraftsStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import AircraftForm from "components/aircrafts/AircraftForm.vue";
import {useAircraftsStore} from "stores/Aircrafts";
import {useRoute, useRouter} from "vue-router";
import {useQuasar} from "quasar";
import {computed, onMounted} from "vue";
@@ -22,16 +31,12 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {aircraft, isMutationRunning, isQueryRunning} = storeToRefs(useAircraftsStore());
onMounted(() => {
useAircraftsStore().fetchDetail(aircraftId).then(() => {
useAppStore().setPageTitle(`${aircraft.value.callSign} - editace`);
useAppStore().setBreadcrumbs([
{to: {name: "aircrafts"}, label: "Letadla"},
{to: {name: "aircraftDetail", params: {id: aircraftId}}, label: aircraft.value.callSign},
{label: "Editace"}
])
});
});
useAppStore().setPageTitle(`${aircraft.value.callSign} - editace`);
useAppStore().setBreadcrumbs([
{to: {name: "aircrafts"}, label: "Letadla"},
{to: {name: "aircraftDetail", params: {id: aircraftId}}, label: aircraft.value.callSign},
{label: "Editace"}
])
const handleSave = (formData) => {
useAircraftsStore().update(aircraftId, formData).then(() => {
+24 -17
View File
@@ -1,45 +1,52 @@
<script>
import {useCopilotsStore} from "stores/Copilots";
export default {
preFetch({currentRoute}) {
return useCopilotsStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import {useRoute} from "vue-router";
import {onMounted} from "vue";
import CopilotDetailSkeleton from "components/copilots/skeletons/CopilotDetailSkeleton.vue";
import {useAppStore} from "stores/App";
import FlightsList from "components/flights/FlightsList.vue";
import {useCopilotsStore} from "stores/Copilots";
import {storeToRefs} from "pinia";
import PhotoGallery from "components/photos/PhotoGallery.vue";
import ContentCard from "components/ContentCard.vue";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import {useFlightsStore} from "stores/Flights";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {useMeta, useQuasar} from "quasar";
import {useQuasar} from "quasar";
import {useLoggedUserStore} from "stores/LoggedUser";
import PublishButton from "components/inputs/PublishButton.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import {toggleVisibility} from "src/composables/form/formHandling";
import {metaData} from "src/composables/meta";
import {onMounted} from "vue";
const {isQueryRunning, copilot} = storeToRefs(useCopilotsStore());
const copilotId = parseInt(useRoute().params.id);
useCopilotsStore().fetchDetail(copilotId).then(() => {
useAppStore().setPageTitle(copilot.value.name);
useAppStore().setBreadcrumbs([
{to: {name: "copilots"}, label: "Kopiloti"},
{label: copilot.value.name}
])
});
useAppStore().setPageTitle(copilot.value.name);
useAppStore().setBreadcrumbs([
{to: {name: "copilots"}, label: "Kopiloti"},
{label: copilot.value.name}
])
const $q = useQuasar();
const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useCopilotsStore(), object: copilot.value, variables
});
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
filters.value = {copilotId: copilotId};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch, appendItems
onMounted(() => {
loadPage(false);
});
loadNextFlightsPage(false);
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
const {isQueryRunning, copilot} = storeToRefs(useCopilotsStore());
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script>
@@ -91,7 +98,7 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
/>
<q-banner v-if="flights.length === 0" class="bg-light-blue text-white">
Zatím tu nemáš uložené žádné společné lety.
+6 -1
View File
@@ -6,9 +6,14 @@ import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useCopilotsStore} from "stores/Copilots";
import {storeToRefs} from "pinia";
import {onMounted} from "vue";
useCopilotsStore().fetch();
useAppStore().setPageTitle("Spolucestující");
useAppStore().setBreadcrumbs([])
onMounted(() => {
useCopilotsStore().fetch();
});
const {isQueryRunning, copilots} = storeToRefs(useCopilotsStore());
</script>
+11 -24
View File
@@ -5,17 +5,17 @@ import EventDetailSkeleton from "components/event/skeletons/EventDetailSkeleton.
import {useAppStore} from "stores/App";
import FAB from "components/FAB.vue";
import FlightsList from "components/flights/FlightsList.vue";
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
import {useEventsStore} from "stores/Events";
import {storeToRefs} from "pinia";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import {useFlightsStore} from "stores/Flights";
import PublishButton from "components/inputs/PublishButton.vue";
import {useLoggedUserStore} from "stores/LoggedUser";
import {toggleVisibility} from "src/composables/form/formHandling";
import {TOGGLE_FLIGHT_PUBLISH} from "src/graphql/mutations/flight";
import {useQuasar} from "quasar";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import EventFlightsMap from "components/event/EventFlightsMap.vue";
import EventBasicInfo from "components/event/EventBasicInfo.vue";
const eventId = parseInt(useRoute().params.id);
onMounted(() => {
@@ -26,7 +26,7 @@ onMounted(() => {
{label: event.value.name}
])
});
loadNextFlightsPage(false);
loadPage(false);
})
@@ -35,13 +35,10 @@ const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useEventsStore(), object: event.value, variables
});
const {isQueryRunning, event} = storeToRefs(useEventsStore());
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
filters.value = {eventId: eventId}
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch, appendItems
});
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
const loggedUser = storeToRefs(useLoggedUserStore()).user;
@@ -51,19 +48,7 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
<EventDetailSkeleton v-if="isQueryRunning || !event"/>
<template v-else>
<q-list bordered separator class="q-my-lg" v-if="event.dateFrom && event.dateTo">
<q-item>
<q-item-section>
Od: {{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
</q-item-section>
</q-item>
<q-item>
<q-item-section>
Do: {{ formatDatetime(event.dateTo, FORMAT_DATE) }}
</q-item-section>
</q-item>
</q-list>
<EventBasicInfo :event="event"/>
<PublishButton
:url-prefix="`${loggedUser.publicUsername}/udalosti/`"
@@ -75,11 +60,13 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
:route="{name: 'publicEventDetail', params: {username: loggedUser.publicUsername, eventSlug: event.urlSlug}}"
/>
<article v-html="event.description"/>
<article class="q-my-lg" v-html="event.description"/>
<EventFlightsMap :event-id="eventId" :only-public="false"/>
<h3>Lety</h3>
<q-banner v-if="event.flights.length === 0" class="bg-light-blue text-white">
<q-banner v-if="flights.length === 0" class="bg-light-blue text-white">
Zatím tu nemáš uložené žádné lety.
</q-banner>
@@ -90,7 +77,7 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
/>
<FAB mode="edit" :router-link="{name: 'eventEdit', params: {id: eventId}}"/>
</template>
+1
View File
@@ -9,6 +9,7 @@ import {useEventsStore} from "stores/Events";
import {onBeforeMount} from "vue";
useAppStore().setPageTitle("Letecké události");
useAppStore().setBreadcrumbs([])
onBeforeMount(() => {
useEventsStore().fetch({});
+38 -87
View File
@@ -1,29 +1,41 @@
<script>
import {useFlightsStore} from "stores/Flights";
export default {
preFetch({currentRoute}) {
const flightId = currentRoute.params.id
return useFlightsStore().fetchDetail(flightId);
}
};
</script>
<script setup>
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
import {computed, onMounted, ref, watch} from "vue";
import {onMounted, ref, toRefs, watch} from "vue";
import {useRoute} from "vue-router";
import {useFlightsStore} from "stores/Flights";
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
import {useAircraftsStore} from "stores/Aircrafts";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {useAirportsStore} from "stores/Airports";
import {useQuasar} from "quasar";
import {storeToRefs} from "pinia";
import {flightName} from "src/composables/flight";
import FlightFormCopilot from "components/flights/form/FlightFormCopilot.vue";
import {useCopilotsStore} from "stores/Copilots";
import FlightFormNotes from "components/flights/form/FlightFormNotes.vue";
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
import {usePhotosStore} from "stores/Photos";
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import {errorObject, successObject, warningObject} from "src/composables/dialog";
import PhotoFormDialog from "components/photos/PhotoFormDialog.vue";
import FlightFormTrack from "components/flights/form/FlightFormTrack.vue";
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
import BatchPhotoFormDialog from "components/photos/BatchPhotoFormDialog.vue";
import {useAppStore} from "stores/App";
import {useEventsStore} from "stores/Events";
import FlightEditCopilotSkeleton from "components/flights/skeletons/FlightEditCopilotSkeleton.vue";
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
import {usePhotosStore} from "stores/Photos";
import {flightName} from "src/composables/flight";
import {useAppStore} from "stores/App";
const $q = useQuasar();
const $route = useRoute();
@@ -34,29 +46,31 @@ const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
const {airports} = storeToRefs(useAirportsStore());
const {copilots} = storeToRefs(useCopilotsStore());
const {events} = storeToRefs(useEventsStore());
const {flight, isQueryRunning, isMutationRunning} = storeToRefs(useFlightsStore());
const {photos} = storeToRefs(usePhotosStore());
const {flight, isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
const formStep = ref("basic");
watch(() => $route.params.step, (val) => formStep.value = val);
onMounted(() => {
formStep.value = $route.params.step || "basic";
const flightNameFormatted = flightName(flight.value);
useAppStore().setPageTitle(`Editace - ${flightNameFormatted}`);
useAppStore().setBreadcrumbs([
{to: {name: "myFlights"}, label: "Moje lety"},
{to: {name: "flightDetail", params: {id: flight.id}}, label: flightNameFormatted},
{label: "Editace"}
]);
onMounted(() => {
photos.value = flight.value.photos;
formStep.value = $route.params.step || "basic";
useAircraftsStore().fetch({});
usePointsOfInterestStore().fetch({});
useAirportsStore().fetch();
useCopilotsStore().fetch();
useEventsStore().fetch({});
useFlightsStore().fetchDetail(flightId).then((f) => {
const flightNameFormatted = flightName(f);
useAppStore().setPageTitle(`Editace - ${flightNameFormatted}`);
useAppStore().setBreadcrumbs([
{to: {name: "myFlights"}, label: "Moje lety"},
{to: {name: "flightDetail", params: {id: flightId}}, label: flightNameFormatted},
{label: "Editace"}
]);
})
});
const handleFlightEdit = (data) => {
@@ -64,40 +78,9 @@ const handleFlightEdit = (data) => {
$q.notify(successObject("Úspěšně upraveno"));
})
}
const isRequestPending = storeToRefs(useFlightsStore()).isMutationRunning;
const uploadInProgress = ref(false);
const handlePhotosUpload = (data) => {
const promises = [];
uploadInProgress.value = true;
for (const photo of data.photos) {
promises.push(usePhotosStore().upload(flightId, {photo}).then((data) => {
flight.value.photos.push(data);
photo._uploaded = true;
}));
}
Promise.allSettled(promises).then((values) => {
const success = values.filter(v => v.status === 'fulfilled');
const failed = values.filter(v => v.status === 'rejected'); // TODO: nebo vse - success?
if (success.length > 0)
$q.notify(successObject(`Úspěšně nahráno ${success.length} fotek`));
if (failed.length > 0)
$q.notify(warningObject(`Nepovedlo se nahrát ${failed.length} fotek. Zde jsou důvody: ${failed.map(f => f.reason)}`));
data.photos = [];
}).finally(() => uploadInProgress.value = false);
}
const _findPhotoIndex = (photoId) => flight.value.photos.findIndex((object) => object.id === photoId);
const deletePhoto = (id) => {
usePhotosStore().destroy(id).then((data) => {
flight.value.photos.splice(_findPhotoIndex(data.id), 1);
});
}
const {
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
} = useBasicPhotoOperations({});
const setTitlePhoto = (photo) => {
useFlightsStore().update(flight.value.id, {titlePhotoId: photo.id}).then(f => {
@@ -105,38 +88,6 @@ const setTitlePhoto = (photo) => {
});
}
const rotatePhotos = ({photos, direction}) => {
const promises = [];
for (const photo of photos) {
promises.push(usePhotosStore().changeOrientation(photo.id, direction).then(data => {
const idx = _findPhotoIndex(data.id);
data.thumbnailUrl += "?t=" + (new Date()).getTime();
flight.value.photos[idx] = data;
}));
}
let success = 0;
Promise.allSettled(promises).then(result => {
for (const item of result) {
if (item.status === 'fulfilled') success++;
}
const error = result.length - success;
if (success > 0) $q.notify(successObject("Otočeno " + success + " fotografií"));
if (error > 0) $q.notify(errorObject(`Nepovedlo se otočit ${error} fotografi`))
});
}
const updateSinglePhoto = (id, formData) => {
formData.copilots = formData.copilots.map(c => ({id: c.id, name: c.name}));
return usePhotosStore().update(id, formData).then((data) => {
const idx = _findPhotoIndex(data.id);
flight.value.photos[idx] = data;
usePointsOfInterestStore().fetch({});
});
}
const handlePhotoUpdate = (flightId, formData) => {
const photoId = formData.id;
delete formData.id;
@@ -240,18 +191,18 @@ const handleBatchPhotoEdit = async (flightId, data) => {
<q-tab-panel name="photos">
<FlightFormPhotos
:flight="flight"
:is-request-pending="isRequestPending || uploadInProgress"
:is-request-pending="uploadInProgress"
@save="handlePhotosUpload"
/>
<template v-if="flight">
<PhotosEditorList
:title-photo="flight.titlePhoto"
:photos="flight.photos"
:photos="photos"
:show-actions="true"
@edit="openPhotoEdit"
@batch-edit="openBatchPhotoForm"
@batch-rotate="rotatePhotos"
@batch-rotate="batchRotatePhotos"
@delete="deletePhoto"
@set-title-photo="setTitlePhoto"
/>
+36 -22
View File
@@ -3,14 +3,14 @@ import {useFlightsStore} from "stores/Flights";
export default {
preFetch({currentRoute}) {
useFlightsStore().fetchDetail(currentRoute.params.id);
return useFlightsStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import {useRoute, useRouter} from "vue-router";
import {watch} from "vue";
import {onMounted, watch} from "vue";
import FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue";
import {flightName} from "src/composables/flight";
import {useAppStore} from "stores/App";
@@ -28,20 +28,20 @@ import {toggleVisibility} from "src/composables/form/formHandling";
import PublishButton from "components/inputs/PublishButton.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import {TOGGLE_FLIGHT_PUBLISH} from "src/graphql/mutations/flight";
import {useAirspacesStore} from "stores/Airspaces";
import {useAirportsStore} from "stores/Airports";
import MapWrapper from "components/map/MapWrapper.vue";
const flightId = parseInt(useRoute().params.id);
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {isQueryRunning, flight} = storeToRefs(useFlightsStore());
watch(() => flight.value, (f) => {
if (!f) return;
const flightNameFormatted = flightName(f);
useAppStore().setPageTitle(flightNameFormatted);
useAppStore().setBreadcrumbs([
{to: {name: "myFlights"}, label: "Moje lety"},
{label: flightNameFormatted}
]);
});
const flightNameFormatted = flightName(flight.value);
useAppStore().setPageTitle(flightNameFormatted);
useAppStore().setBreadcrumbs([
{to: {name: "myFlights"}, label: "Moje lety"},
{label: flightNameFormatted}
]);
const $q = useQuasar();
const $router = useRouter();
@@ -53,7 +53,14 @@ const handleDeleteAndRedirect = () => {
$q.notify(successObject(`Úspěšně smazáno`));
});
});
}
};
const {airspaces} = storeToRefs(useAirspacesStore());
const {airports} = storeToRefs(useAirportsStore());
onMounted(() => {
useAirspacesStore().fetch();
useAirportsStore().fetch();
});
const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useFlightsStore(), object: flight.value, mutation: TOGGLE_FLIGHT_PUBLISH, variables
@@ -94,8 +101,10 @@ const togglePublicVisibility = (variables) => toggleVisibility({
<FlightBasicInfo
:flight="flight"
:poi-detail-route="{name: 'poiDetail'}"
:event-detail-route="flight.event ? {name: 'eventDetail', params: {id: flight.event.id}} : null"
:aircraft-detail-route="{name: 'aircraftDetail'}"
:copilot-detail-route="{name: 'copilotDetail'}"
:logged-user="loggedUser"
/>
<ContentCardEditButton
@@ -108,7 +117,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
<q-separator/>
<article class="q-my-lg" v-html="flight.debriefingNotes"/>
<BasicInfoBanner v-if="flight.debriegingNotes === ''">
<BasicInfoBanner v-if="flight.debriefingNotes === ''">
Nezapomeň si poznamenat důležité informace pro debriefing. Tyto poznámky slouží jen pro tebe.
</BasicInfoBanner>
<q-separator/>
@@ -123,10 +132,9 @@ const togglePublicVisibility = (variables) => toggleVisibility({
>
U tohoto letu nemáš nahrané žádné fotografie.
</BasicInfoBanner>
<PhotoGallery
v-else
:photos="flight.photos"
/>
<q-no-ssr v-else>
<PhotoGallery :photos="flight.photos"/>
</q-no-ssr>
<q-separator/>
@@ -140,11 +148,17 @@ const togglePublicVisibility = (variables) => toggleVisibility({
>
Nemáš nastavenou žádnou trasu.
</BasicInfoBanner>
<FlightTrackMap
v-else
:flight="flight"
:photos="flight.photos"
/>
<q-no-ssr v-else>
<FlightTrackMap
:airspaces-data="airspaces"
:airports="airports"
:show-info-window="true"
:show-layer-controls="true"
:flight="flight"
:photos="flight.photos"
/>
</q-no-ssr>
</q-card-section>
</template>
</ContentCard>
+7 -6
View File
@@ -6,14 +6,16 @@ import {useAppStore} from "stores/App";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useFlightsStore} from "stores/Flights";
import {storeToRefs} from "pinia";
import {loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import {onMounted} from "vue";
useAppStore().setPageTitle("Moje lety");
useAppStore().setBreadcrumbs([]);
const loadNextPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch, appendItems
const {loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
onMounted(() => {
loadPage(false);
});
loadNextPage(false);
const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore());
</script>
@@ -35,8 +37,7 @@ const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore()
:is-query-running="isQueryRunning"
:items-per-page="paginationLimit"
:total-items-count="totalItemsCount"
@load-more="loadNextPage(true)"
@load-more="loadPage(true)"
/>
<FAB mode="add" :router-link="{name: 'addFlight'}"/>
+11 -11
View File
@@ -11,16 +11,16 @@ import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
useAppStore().setPageTitle("Nový let");
useAppStore().setBreadcrumbs([
{to: {name: "myFlights"}, label: "Moje lety"},
{label: "Nový"}
]);
onMounted(() => {
useAircraftsStore().fetch({})
usePointsOfInterestStore().fetch({})
useAirportsStore().fetch()
useAppStore().setPageTitle("Nový let");
useAppStore().setBreadcrumbs([
{to: {name: "myFlights"}, label: "Moje lety"},
{label: "Nový"}
]);
});
const {isMutationRunning} = storeToRefs(useFlightsStore());
@@ -46,11 +46,11 @@ const handleSave = (formData) => {
<FlightEditSkeleton step="basic" v-if="loading"/>
<template v-else>
<FlightFormBasic
@save="handleSave"
:is-request-pending="isMutationRunning"
:aircrafts="aircrafts"
:airports="airports"
:locations="pointsOfInterest"
@save="handleSave"
:is-request-pending="isMutationRunning"
:aircrafts="aircrafts"
:airports="airports"
:locations="pointsOfInterest"
/>
</template>
</template>
@@ -0,0 +1,199 @@
<script>
import {useFlightPlansStore} from "stores/FlightPlans";
export default {
preFetch({currentRoute}) {
return useFlightPlansStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import {computed, onMounted, ref} from "vue";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {storeToRefs} from "pinia";
import {useAirspacesStore} from "stores/Airspaces";
import {useAirportsStore} from "stores/Airports";
import {commonProps} from "src/composables/inputs";
import {useAppStore} from "stores/App";
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
import FlightPlanMap from "components/flightPlans/FlightPlanMap.vue";
import FlightPlanRoute from "components/flightPlans/FlightPlanRoute.vue";
import FlightPlanSideControls from "components/flightPlans/FlightPlanSideControls.vue";
import DatetimePicker from "components/inputs/DatetimePicker.vue";
import {useAircraftsStore} from "stores/Aircrafts";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import FormWrapper from "components/FormWrapper.vue";
import {successObject} from "src/composables/dialog";
import {useQuasar} from "quasar";
import {useCopilotsStore} from "stores/Copilots";
const {flightPlan} = storeToRefs(useFlightPlansStore());
const flightPlanName = computed(() => flightPlan.value.name || "Nepojmenovaný plán");
useAppStore().setPageTitle(`${flightPlanName.value} - plánovnání`);
useAppStore().setBreadcrumbs([
{to: {name: "flightPlans"}, label: "Moje plány"},
{label: flightPlanName.value},
{label: "Editace"}
]);
onMounted(() => {
useAirspacesStore().fetch();
useAircraftsStore().fetch({});
usePointsOfInterestStore().fetch({limit: 10000});
useAirportsStore().fetch();
});
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
const {airspaces: airspacesData, areAirspacesLoading: isQueryRunning} = storeToRefs(useAirspacesStore())
const {airports} = storeToRefs(useAirportsStore());
const {aircrafts: aircraftsData} = storeToRefs(useAircraftsStore());
const aircrafts = computed(() => aircraftsData.value.map(a => ({id: a.id, name: a.callSign})));
const {getMarkersFrom} = useFlightPlanUtils();
const {markerObject} = useFlightPlanObjects();
const formData = ref({
name: flightPlan.value?.name || null,
isDefaultName: flightPlan.value ? flightPlan.value.isDefaultName : true,
isEditableByCopilot: flightPlan.value?.isEditableByCopilot || false,
isPublic: flightPlan.value?.isPublic || false,
plannedTakeoffDatetime: flightPlan.value?.plannedTakeoffDatetime || null,
aircraft: flightPlan.value && flightPlan.value.aircraft ? {id: flightPlan.value.aircraft.id, name: flightPlan.value.aircraft.callSign} : null,
plannedSpeed: flightPlan.value?.plannedSpeed || 150,
})
const openedTab = ref("route");
const flightPlannerDialogOpen = ref(false);
const flightPlanMarkers = ref(getMarkersFrom(flightPlan.value));
const handleMarkersChanges = (markers) => {
flightPlanMarkers.value = markers;
useFlightPlansStore().update(flightPlan.value.id, {
name: formData.value.name,
markers: markers.map(m => markerObject({
name: m.name,
type: m.type,
gpsLatitude: m.gpsLatitude,
gpsLongitude: m.gpsLongitude,
}))
});
};
const $q = useQuasar();
const handleFlightPlanInfoSave = () => {
useFlightPlansStore().update(flightPlan.value.id, formData.value).then(() => {
$q.notify(successObject("Ulozeno"));
})
}
const insertAtPosition = ref(flightPlanMarkers.value.length);
const fullscreen = ref(false);
</script>
<template>
<q-tabs v-model="openedTab">
<q-tab name="route">
<q-icon name="route"/>
Trasa
</q-tab>
<q-tab name="info">
<q-icon name="info"/>
Informace
</q-tab>
</q-tabs>
<q-tab-panels v-model="openedTab">
<q-tab-panel name="route">
<div :class="{row: true, fullscreen}">
<div class="col" style="min-width: 1px">
<FlightPlanMap
:insert-position="insertAtPosition"
:airspace-data="airspacesData"
:airports="airports"
:points-of-interest="pointsOfInterest"
:flight-plan="flightPlan"
@change-insert-position="insertAtPosition = $event"
@markers-changed="handleMarkersChanges"
/>
</div>
<div class="col-12 col-md-3 col-lg-2 markers-control">
<FlightPlanSideControls
:is-fullscreen="fullscreen"
:insert-position="insertAtPosition"
:flight-plan="flightPlan"
@change-insert-position="insertAtPosition = $event"
@open="flightPlannerDialogOpen=true"
@fullscreen-toggle="fullscreen = !fullscreen"
/>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="info">
<div class="row">
<div class="col">
<FormWrapper v-model="formData" @save="handleFlightPlanInfoSave">
<q-input v-model="formData.name" label="Jméno letu" v-if="!formData.isDefaultName" v-bind="commonProps"/>
<q-checkbox v-model="formData.isDefaultName" label="Použít automatické jméno"/>
<!-- <q-checkbox v-model="formData.isPublic" label="Zveřejnit"/>-->
<!-- <q-checkbox v-model="formData.isEditableByCopilot" label="Kopilot může upravovat"/>-->
<DatetimePicker v-model="formData.plannedTakeoffDatetime" label="Plánovaný datum a čas vzletu" v-bind="commonProps"/>
<ComboboxInput v-model="formData.aircraft" :options="aircrafts" label="Letadlo" v-bind="commonProps"/>
<q-input v-model.number="formData.plannedSpeed" type="number" step="5" label="Plánovaná rychlost" v-bind="commonProps">
<template #append>km/h</template>
</q-input>
<!-- <q-btn @click="handleFlightPlanInfoSave" icon="save">Ulozit</q-btn>-->
</FormWrapper>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
<q-dialog full-width v-model="flightPlannerDialogOpen">
<q-card>
<q-card-section>
<div class="text-h6">Přidat otočný bod</div>
</q-card-section>
<q-card-section class="q-pt-none">
<FlightPlanRoute
:markers="flightPlanMarkers"
:points-of-interest="pointsOfInterest"
:airports="airports"
@markers-changed="handleMarkersChanges"
/>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Zavřít" v-close-popup/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<style lang="scss">
.fullscreen {
position: fixed;
top: 0;
bottom: 0;
right: 0;
left: 0;
z-index: 99999;
.markers-control {
background: white;
}
}
</style>
@@ -0,0 +1,38 @@
<script setup>
import {useFlightPlansStore} from "stores/FlightPlans";
import {storeToRefs} from "pinia";
import {onMounted} from "vue";
import {useAppStore} from "stores/App";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import FAB from "components/FAB.vue";
import FlightPlansList from "components/flightPlans/FlightPlansList.vue";
useAppStore().setPageTitle("Nový plán");
useAppStore().setBreadcrumbs([
{to: {name: "myFlights"}, label: "Moje lety"},
{to: {name: "flightPlans"}, label: "Plány"},
{label: "Nový"}
]);
onMounted(() => {
useFlightPlansStore().fetch();
});
const {flightPlans, isQueryRunning} = storeToRefs(useFlightPlansStore());
</script>
<template>
<BasicListSkeleton :items=5 v-if="isQueryRunning || !flightPlans"/>
<BasicInfoBanner
v-else-if="!isQueryRunning && flightPlans.length === 0"
:route-add='{name: "newFlightPlan"}'
>
Aktuálně nemáš žádné naplánované lety.
</BasicInfoBanner>
<FlightPlansList :items="flightPlans" v-else/>
<FAB :router-link="{name: 'newFlightPlan'}" mode="add"/>
</template>
@@ -0,0 +1,24 @@
<script setup>
import {onMounted} from "vue";
import {useFlightPlansStore} from "stores/FlightPlans";
import {useRouter} from "vue-router";
const $router = useRouter();
onMounted(async () => {
await useFlightPlansStore().create({
name: "",
isDefaultName: true,
isPublic: false,
isEditableByCopilot: false,
plannedSpeed: 150,
markers: []
}).then(fp => {
return $router.replace({name: "editFlightPlan", params: {id: fp.id}});
})
})
</script>
<template>
<q-spinner/>
</template>
@@ -1,3 +1,13 @@
<script>
import {useOrganizationsStore} from "stores/Organizations";
export default {
preFetch({currentRoute}) {
return useOrganizationsStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import {useRoute} from "vue-router";
import {computed, onMounted} from "vue";
@@ -8,26 +18,19 @@ import {useLoggedUserStore} from "stores/LoggedUser";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import AircraftsList from "components/aircrafts/AircraftsList.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useQuasar} from "quasar";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import {useOrganizationsStore} from "stores/Organizations";
const organizationId = parseInt(useRoute().params.id);
const {isQueryRunning, organization} = storeToRefs(useOrganizationsStore());
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const $q = useQuasar();
onMounted(() => {
useOrganizationsStore().fetchDetail(organizationId).then((o) => {
useAppStore().setPageTitle(o.name);
useAppStore().setBreadcrumbs([
{to: {name: "organizations"}, label: "Organizace"},
{label: o.name}
])
});
});
useAppStore().setPageTitle(organization.value.name);
useAppStore().setBreadcrumbs([
{to: {name: "organizations"}, label: "Organizace"},
{label: organization.value.name}
]);
const myOrganizationIds = computed(() => (loggedUser.value && loggedUser.value.organizations?.map(o => o.id)) || []);
const {isQueryRunning, organization} = storeToRefs(useOrganizationsStore());
</script>
<template>
@@ -11,6 +11,7 @@ import {useLoggedUserStore} from "stores/LoggedUser";
useOrganizationsStore().fetch();
useAppStore().setPageTitle("Organizace");
useAppStore().setBreadcrumbs([]);
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const myOrganizationIds = computed(() => (loggedUser.value && loggedUser.value.organizations?.map(o => o.id)) || []);
@@ -1,8 +1,17 @@
<script>
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
export default {
preFetch({currentRoute}) {
return usePointsOfInterestStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import POIEditSkeleton from "components/poi/skeletons/POIEditSkeleton.vue";
import {computed, onMounted} from "vue";
import {computed} from "vue";
import {useRoute, useRouter} from "vue-router";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {useAppStore} from "stores/App";
import {useQuasar} from "quasar";
import {successObject} from "src/composables/dialog";
@@ -11,30 +20,25 @@ import {storeToRefs} from "pinia";
import POIForm from "components/poi/POIForm.vue";
import {useLoggedUserStore} from "stores/LoggedUser";
import AccessDeniedBanner from "components/banners/AccessDeniedBanner.vue";
import PhotoEditorSkeleton from "components/photos/skeletons/PhotoEditorSkeleton.vue";
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const poiId = parseInt(useRoute().params.id);
const {isQueryRunning, pointOfInterest, isMutationRunning} = storeToRefs(usePointsOfInterestStore());
usePointsOfInterestStore().fetchDetail(poiId).then((p) => {
if (!p) return;
useAppStore().setPageTitle(`${p.name} - editace`);
useAppStore().setBreadcrumbs([
{to: {name: "poi"}, label: "Zajímavá místa"},
{to: {name: "poiDetail", params: {id: poiId}}, label: p.name},
{label: "Editace"},
]);
});
useAppStore().setPageTitle(`${pointOfInterest.value.name} - editace`);
useAppStore().setBreadcrumbs([
{to: {name: "poi"}, label: "Zajímavá místa"},
{to: {name: "poiDetail", params: {id: poiId}}, label: pointOfInterest.value.name},
{label: "Editace"},
]);
usePointsOfInterestTypeStore().fetch();
const poiTypes = storeToRefs(usePointsOfInterestTypeStore()).types;
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {isQueryRunning, pointOfInterest, isMutationRunning} = storeToRefs(usePointsOfInterestStore());
const $q = useQuasar();
const router = useRouter();
@@ -56,8 +60,8 @@ const formStep = computed(() => useRoute().params.step || "info");
<template>
<AccessDeniedBanner
v-if="pointOfInterest && !isQueryRunning && pointOfInterest.createdById !== loggedUser?.id"
:route-back="{name: 'poiDetail', params: {id: poiId}}"
v-if="pointOfInterest && !isQueryRunning && pointOfInterest.createdById !== loggedUser?.id"
:route-back="{name: 'poiDetail', params: {id: poiId}}"
/>
<template v-else>
@@ -74,11 +78,11 @@ const formStep = computed(() => useRoute().params.step || "info");
Toto místo není označené na žádné fotografii.
</BasicInfoBanner>
<PhotosEditorList
v-else
:photos="pointOfInterest.photos"
:title-photo="pointOfInterest.titlePhoto"
:show-actions="false"
@set-title-photo="setTitlePhoto"
v-else
:photos="pointOfInterest.photos"
:title-photo="pointOfInterest.titlePhoto"
:show-actions="false"
@set-title-photo="setTitlePhoto"
/>
</template>
</template>
@@ -1,5 +1,5 @@
<script setup>
import {useRoute, useRouter} from "vue-router";
import {useRoute} from "vue-router";
import POIDetailSkeleton from "components/poi/skeletons/POIDetailSkeleton.vue";
import {useAppStore} from "stores/App";
import EmbeddedMap from "components/map/EmbeddedMap.vue";
@@ -11,12 +11,11 @@ import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import PhotoGallery from "components/photos/PhotoGallery.vue";
import ContentCard from "components/ContentCard.vue";
import {toggleVisibility} from "src/composables/form/formHandling";
import {copyToClipboard, useQuasar} from "quasar";
import {successObject} from "src/composables/dialog";
import { useQuasar} from "quasar";
import ContentCardEditButton from "components/ContentCardEditButton.vue";
import {useFlightsStore} from "stores/Flights";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import PublishButton from "components/inputs/PublishButton.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
@@ -39,12 +38,9 @@ const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: usePointsOfInterestStore(), object: pointOfInterest.value, variables
});
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
filters.value = {pointOfInterestId: poiId};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch,
appendItems
});
loadNextFlightsPage(false);
loadPage(false);
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
const {isQueryRunning, pointOfInterest} = storeToRefs(usePointsOfInterestStore());
</script>
@@ -114,7 +110,7 @@ const {isQueryRunning, pointOfInterest} = storeToRefs(usePointsOfInterestStore()
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
/>
<BasicInfoBanner v-else>
Na tomto místě jsi zatím nebyl/a.
+10 -7
View File
@@ -6,20 +6,23 @@ import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {storeToRefs} from "pinia";
import {filters, loadPage, paginationLimit, paginationOffset} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import ListFilters from "components/ListFilters.vue";
import {onMounted} from "vue";
useAppStore().setPageTitle("Zajímavá místa");
useAppStore().setBreadcrumbs([]);
paginationLimit.value = 15;
const loadNextPage = (appendItems) => loadPage({
fetchFunc: usePointsOfInterestStore().fetch, appendItems
const {filters, loadPage, paginationLimit, paginationOffset} = usePagination({
fetchFunction: usePointsOfInterestStore().fetch
});
loadNextPage(false);
onMounted(() => {
loadPage(false);
})
const handleFiltersChanged = (newFilters) => {
paginationOffset.value = 0;
filters.value = newFilters;
loadNextPage(false);
loadPage(false);
}
const {isQueryRunning, totalItemsCount, pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
@@ -45,7 +48,7 @@ const {isQueryRunning, totalItemsCount, pointsOfInterest} = storeToRefs(usePoint
:total-items-count="totalItemsCount"
:is-query-running="isQueryRunning"
:items-per-page="paginationLimit"
@load-more="loadNextPage(true)"
@load-more="loadPage(true)"
/>
<FAB :router-link="{name: 'addPOI'}" mode="add"/>
</template>
+2 -1
View File
@@ -3,10 +3,11 @@ import {useAppStore} from "stores/App";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
import ContentCard from "components/ContentCard.vue";
import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
useAppStore().setPageTitle("O projektu");
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
...homepageBreadcrumbs(),
{label: "O projektu"}
]);
useMeta(() => metaData({
+2 -1
View File
@@ -6,10 +6,11 @@ import {commonProps} from "src/composables/inputs";
import {useQuasar} from "quasar";
import {errorObject, successObject} from "src/composables/dialog";
import {api, axios} from "boot/axios";
import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
useAppStore().setPageTitle("Kontakt");
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
...homepageBreadcrumbs(),
{label: "Kontakt"}
]);
+2 -1
View File
@@ -1,10 +1,11 @@
<script setup>
import {useAppStore} from "stores/App";
import ContentCard from "components/ContentCard.vue";
import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
useAppStore().setPageTitle("Co Polétání.cz umí?");
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
...homepageBreadcrumbs(),
{label: "Funkce"}
]);
+9 -8
View File
@@ -4,6 +4,7 @@ import {useAppStore} from "stores/App";
import {onMounted, ref} from "vue";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
const $route = useRoute();
const help = {
@@ -15,31 +16,31 @@ const help = {
},
"organizace": {
title: "Co jsou to organizace?",
content: "Organizace slouží ke sdílení letadel, např. v rámci aeroklubu. "
content: "Organizace slouží ke sdílení letadel, např. v rámci aeroklubu (nebo jiného provozovatele)."
},
"udalosti": {
title: "Co jsou to události?",
content: ""
content: "<p>Události slouží pro sloučení souvisejících letů. Letecké soustředění, výcvik, vícedenní výlet...</p>"
},
"uvodni-fotografie": {
title: "K čemu potřebuji úvodní fotografii?",
content: ""
content: "Úvodní fotografie slouží k upoutání pozornosti nejen v prostředí Polétání.cz ale i na sociálních sítích. Pokud pošleš odkaz"
},
"url": {
title: "Proč k veřejným datům musím uvádět URL část?",
content: ""
content: "Protože všechna veřejná data potřebují unikátní adresu, na které budou k dispozici ostatním uživatelům internetu. Zadáním vlastní URL adresy si můžeš vytvořit přehledné a čitelné adresy, které se snáze pamatují."
},
"poi": {
title: "Jak funguje databáze zajímavých míst?",
content: ""
content: "Každý uživatel Polétání.cz může zveřejnit nějaké zajímavé místo na mapě. Toto místo pak mohou ostatní použít pro plánování svých letů, organizaci fotek u svých výletů. Všechna tato data se zobrazují na veřejné stránce s detailem tohoto zvoleného místa a mohou tak sloužit i jako inspirace pro ostatní."
},
"editor-fotek": {
title: "Co umí editor fotek?",
content: ""
content: "Polétání.cz ti dovoluje provádět základní úpravy tvých fotek."
},
"debriefing": {
title: "Co jsou poznámky k debriefingu?",
content: "Prostě soukromé poznámky. Vidíš je jen ty."
content: "Prostě soukromé poznámky. Vidíš je jen ty. Můžeš si sem poznamenat co bylo dobře, co nebylo úplně ok. Nezapomeň, že debriefing je součást každého letu a pomáhá při zlepšování schopností pilotů!"
},
"verejny-profil": {
title: "Musím mít veřejný profil?",
@@ -57,7 +58,7 @@ useMeta(() => metaData({
title: "Nápověda",
}));
useAppStore().setBreadcrumbs([
{label: "Polétání.cz", to: {name: "homepage"}},
...homepageBreadcrumbs(),
{label: "Nápověda"},
]);
</script>
+2 -1
View File
@@ -1,10 +1,11 @@
<script setup>
import ContentCard from "components/ContentCard.vue";
import {useAppStore} from "stores/App";
import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
useAppStore().setPageTitle("Správa osobních údajů");
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
...homepageBreadcrumbs(),
{label: "Správa osobních údajů"}
]);
</script>
+2 -1
View File
@@ -1,10 +1,11 @@
<script setup>
import ContentCard from "components/ContentCard.vue";
import {useAppStore} from "stores/App";
import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
useAppStore().setPageTitle("Podmínky používání");
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
...homepageBreadcrumbs(),
{label: "Podmínky používání"}
]);
</script>
@@ -8,8 +8,7 @@ export default {
}
</script>
<script setup>
import {useRouter} from "vue-router";
import {useMeta, useQuasar} from "quasar";
import {useMeta} from "quasar";
import AircraftDetailSkeleton from "components/aircrafts/skeletons/AircraftDetailSkeleton.vue";
import {useAppStore} from "stores/App";
import FlightsList from "components/flights/FlightsList.vue";
@@ -24,10 +23,8 @@ import {useFlightsStore} from "stores/Flights";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
const $q = useQuasar();
const $router = useRouter();
import {usePagination} from "src/composables/pagination";
import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
const {aircraft, isQueryRunning} = storeToRefs(useAircraftsStore());
const {photos, isQueryRunning: arePhotosLoading} = storeToRefs(usePhotosStore());
@@ -43,19 +40,15 @@ useMeta(() => metaData({
description: aircraft.value.description || `Detail letadla ${aircraft.value.callSign}.`
}));
useAppStore().setBreadcrumbs([
{label: "Polétání.cz", to: {name: "homepage"}},
...homepageBreadcrumbs(),
{label: "Letadla"},
{label: aircraft.value.callSign}
]);
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {aircraftId: aircraft.value.id};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetchPublic, appendItems
});
onMounted(() => {
loadNextFlightsPage(false);
loadPage(false);
usePhotosStore().fetchPublic({aircraftId: aircraft.value.id});
});
</script>
@@ -84,7 +77,7 @@ onMounted(() => {
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
:public="true"
/>
</q-card-section>
@@ -3,7 +3,10 @@ import {useCopilotsStore} from "stores/Copilots";
export default {
preFetch({currentRoute}) {
return useCopilotsStore().fetchPublicDetail(currentRoute.params.copilotSlug, currentRoute.params.username);
return useCopilotsStore().fetchPublicDetail({
urlSlug: currentRoute.params.copilotSlug,
username: currentRoute.params.username
});
}
}
</script>
@@ -20,8 +23,9 @@ import PhotoGallery from "components/photos/PhotoGallery.vue";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {usePagination} from "src/composables/pagination";
import {publicCopilotBreadcrumbs} from "src/composables/breadcrumbs";
const props = defineProps({
pilot: {
@@ -35,11 +39,7 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
const {photos, isQueryRunning: arePhotosLoading} = storeToRefs(usePhotosStore());
useAppStore().setPageTitle(`${props.pilot.name} + ${copilot.value.name}`);
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
{to: {name: "publicProfileDetail", params: {username: props.pilot.publicUsername}}, label: `@${props.pilot.publicUsername}`},
{label: copilot.value.name}
]);
useAppStore().setBreadcrumbs(publicCopilotBreadcrumbs(props.pilot));
useMeta(() => metaData({
title: copilot.value.name,
@@ -48,14 +48,10 @@ useMeta(() => metaData({
image: copilot.value.titleImage?.url
}));
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {copilotId: copilot.value.id};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetchPublic, appendItems
});
onMounted(() => {
loadNextFlightsPage(false);
loadPage(false);
usePhotosStore().fetchPublic({copilotId: copilot.value.id})
})
</script>
@@ -79,7 +75,7 @@ onMounted(() => {
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
:public="true"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
/>
</q-card-section>
</template>
@@ -0,0 +1,100 @@
<script>
import {useCopilotsStore} from "stores/Copilots";
import {useFlightsStore} from "stores/Flights";
export default {
preFetch({currentRoute}) {
return Promise.allSettled([
useFlightsStore().fetchPublicDetail({
username: currentRoute.params.username,
urlSlug: currentRoute.params.flightSlug
}),
useCopilotsStore().fetchPublicDetail({
username: currentRoute.params.username,
uploadToken: currentRoute.query.token,
uploadFlightSlug: currentRoute.params.flightSlug
})
]);
}
}
</script>
<script setup>
import {storeToRefs} from "pinia";
import ContentCard from "components/ContentCard.vue";
import {useAppStore} from "stores/App";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
import {flightName} from "src/composables/flight";
import {usePhotosStore} from "stores/Photos";
import {onMounted} from "vue";
import PhotosEditorListSimple from "components/photos/PhotosEditorListSimple.vue";
import {publicCopilotBreadcrumbs} from "src/composables/breadcrumbs";
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useRoute} from "vue-router";
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
const props = defineProps({
pilot: {
type: Object,
required: false
},
});
const {copilot} = storeToRefs(useCopilotsStore());
const {flight} = storeToRefs(useFlightsStore());
const {photos, isQueryRunning: arePhotosLoading} = storeToRefs(usePhotosStore())
useAppStore().setPageTitle(`Nahraj fotky - ${props.pilot.name} - ${flightName(flight.value)}`);
useAppStore().setBreadcrumbs([
...publicCopilotBreadcrumbs(props.pilot, flight.value, flight.value.event, copilot.value),
{label: "Nahraj fotky"},
]);
useMeta(() => metaData({
title: `Nahraj fotky: ${copilot.value.name}`,
index: false,
image: copilot.value.titleImage?.url
}));
onMounted(() => {
usePhotosStore().fetchPublic({
flightId: flight.value.id,
uploadedByCopilotId: copilot.value.id
});
});
const $route = useRoute();
const copilotUploadToken = $route.query.token;
const {
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto
} = useBasicPhotoOperations({copilotUploadToken});
</script>
<template>
<ContentCard :title="copilot.name">
<template #content>
<q-card-section>
Nahraj fotky k letu {{ flightName(flight) }}
<FlightFormPhotos
:is-request-pending="uploadInProgress"
:flight="flight"
@save="handlePhotosUpload"
/>
<BasicInfoBanner v-if="photos.length === 0 && !arePhotosLoading">
Zatím tu od tebe nejsou nahrané žádné fotky.
</BasicInfoBanner>
<PhotosEditorListSimple
v-else
style="margin-bottom: 150px"
:photos="photos"
@batch-rotate="batchRotatePhotos"
@delete="deletePhoto"
/>
</q-card-section>
</template>
</ContentCard>
</template>
@@ -11,14 +11,17 @@ export default {
<script setup>
import {useAppStore} from "stores/App";
import EventDetailSkeleton from "components/event/skeletons/EventDetailSkeleton.vue";
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
import FlightsList from "components/flights/FlightsList.vue";
import {storeToRefs} from "pinia";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import {useFlightsStore} from "stores/Flights";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
import EventBasicInfo from "components/event/EventBasicInfo.vue";
import EventFlightsMap from "components/event/EventFlightsMap.vue";
import {publicEventBreadcrumbs} from "src/composables/breadcrumbs";
import {onMounted} from "vue";
const props = defineProps({
pilot: {
@@ -30,22 +33,18 @@ const props = defineProps({
const {isQueryRunning, event} = storeToRefs(useEventsStore());
useAppStore().setPageTitle(event.value.name);
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
{to: {name: "publicProfileDetail", params: {username: props.pilot.publicUsername}}, label: `@${props.pilot.publicUsername}`},
{to: {name: "publicEventsList", params: {username: props.pilot.publicUsername}}, label: "Události"},
{label: event.value.name}
]);
useAppStore().setBreadcrumbs(publicEventBreadcrumbs(props.pilot, event.value, false));
useMeta(() => metaData({
title: event.value.name,
description: event.value.description
}))
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {username: props.pilot.publicUsername, eventId: event.value.id};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetchPublic, appendItems
});
loadNextFlightsPage(false);
onMounted(() => {
loadPage(false);
})
const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
</script>
@@ -55,21 +54,14 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
<EventDetailSkeleton v-if="isQueryRunning || !event"/>
<template v-else>
<q-list bordered separator class="q-my-lg" v-if="event.dateFrom && event.dateTo">
<q-item>
<q-item-section>
Od: {{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
</q-item-section>
</q-item>
<EventBasicInfo :event="event"/>
<q-item>
<q-item-section>
Do: {{ formatDatetime(event.dateTo, FORMAT_DATE) }}
</q-item-section>
</q-item>
</q-list>
<article class="q-my-lg" v-html="event.description"/>
<q-no-ssr>
<EventFlightsMap :event-url-slug="event.urlSlug" :only-public="true"/>
</q-no-ssr>
<article v-html="event.description"/>
<h3>Lety</h3>
<q-banner v-if="event.flights.length === 0" class="bg-light-blue text-white">
@@ -85,9 +77,8 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
:public="true"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
/>
</template>
</div>
</template>
@@ -7,6 +7,7 @@ import {useEventsStore} from "stores/Events";
import {storeToRefs} from "pinia";
import {useMeta} from "quasar";
import {commonKeywords, metaData} from "src/composables/meta";
import {publicProfileBreadcrumbs} from "src/composables/breadcrumbs";
const props = defineProps({
pilot: {
@@ -23,8 +24,7 @@ useMeta(() => metaData({
useAppStore().setPageTitle(`${props.pilot.publicUsername}`);
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
{to: {name: "publicProfileDetail", params: {username: props.pilot.publicUsername}}, label: `@${props.pilot.publicUsername}`},
...publicProfileBreadcrumbs(props.pilot),
{label: "Události"}
]);
@@ -19,9 +19,11 @@ import PhotoGallery from "components/photos/PhotoGallery.vue";
import {storeToRefs} from "pinia";
import {flightName} from "src/composables/flight";
import {useMeta} from "quasar";
import {aircraftKeywords, metaData} from "src/composables/meta";
import {aircraftKeywords, flightEventKeywords, flightKeywords, metaData} from "src/composables/meta";
import ContentCard from "components/ContentCard.vue";
import {usePhotosStore} from "stores/Photos";
import {computed} from "vue";
import {publicFlightBreadcrumbs} from "src/composables/breadcrumbs";
const {isQueryRunning, flight} = storeToRefs(useFlightsStore());
@@ -36,16 +38,19 @@ usePhotosStore().fetchPublic({flightId: flight.value.id});
const {isQueryRunning: arePhotosLoading, photos: flightPhotos} = storeToRefs(usePhotosStore());
const flightNameFormatted = flightName(flight.value);
const event = computed(() => flight.value.event);
useAppStore().setPageTitle(flightNameFormatted);
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
{to: {name: "publicProfileDetail", params: {username: props.pilot.publicUsername}}, label: `@${props.pilot.publicUsername}`},
{label: flightNameFormatted}
]);
useAppStore().setBreadcrumbs(publicFlightBreadcrumbs(props.pilot, flight.value, event.value, false));
useMeta(() => metaData({
title: `${flightNameFormatted} - ${props.pilot.name}`,
keywords: ["let", "výlet", "letadlo", props.pilot.name, ...aircraftKeywords(flight.value.aircraft)],
keywords: [
...flightKeywords(flight.value),
props.pilot.name,
...aircraftKeywords(flight.value.aircraft),
...flightEventKeywords(event.value)
],
description: flight.value.description,
image: flight.value.titlePhoto?.url
}
@@ -63,6 +68,7 @@ useMeta(() => metaData({
:flight="flight"
:poi-detail-route="{name: 'publicPOIDetail'}"
:aircraft-detail-route="{name: 'publicAircraftDetail'}"
:event-detail-route="event && event.isPublic ? {name: 'publicEventDetail', params: {username: pilot.publicUsername, eventSlug: event.urlSlug}} : null"
:copilot-detail-route="{name: 'publicCopilotDetail', params: {username: pilot.publicUsername}}"
/>
@@ -70,10 +76,13 @@ useMeta(() => metaData({
<q-no-ssr>
<q-inner-loading v-if="arePhotosLoading"/>
<PhotoGallery :photos="flightPhotos" v-else/>
<PhotoGallery :photos="flightPhotos" v-else-if="!arePhotosLoading && flightPhotos.length > 0"/>
<FlightTrackMap
:key="flight.id"
:flight="flight"
:showLayerControls="false"
:photos="flightPhotos"/>
</q-no-ssr>
<FlightTrackMap :key="flight.id" :flight="flight" :photos="flightPhotos"/>
</q-card-section>
</template>
</ContentCard>
@@ -7,7 +7,9 @@ import {useFlightsStore} from "stores/Flights";
import {storeToRefs} from "pinia";
import {commonKeywords, metaData} from "src/composables/meta";
import {useMeta} from "quasar";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import {publicProfileBreadcrumbs} from "src/composables/breadcrumbs";
import {onMounted} from "vue";
const props = defineProps({
pilot: {
@@ -24,17 +26,13 @@ useMeta(() => metaData({
}));
useAppStore().setPageTitle(props.pilot.publicUsername);
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
{label: `@${props.pilot.publicUsername}`},
])
useAppStore().setBreadcrumbs(publicProfileBreadcrumbs(props.pilot, false))
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {username: props.pilot.publicUsername};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetchPublic,
appendItems
onMounted(() => {
loadPage(false);
});
loadNextFlightsPage(false);
const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
</script>
@@ -55,6 +53,6 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
:public="true"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
/>
</template>
@@ -23,7 +23,8 @@ import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleto
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {onMounted} from "vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import {publicPOIBaseBreadcrumbs} from "src/composables/breadcrumbs";
const {pointOfInterest, isQueryRunning} = storeToRefs(usePointsOfInterestStore());
const {photos, isQueryRunning: arePhotosLoading} = storeToRefs(usePhotosStore());
@@ -36,22 +37,16 @@ useMeta(() => metaData({
description: pointOfInterest.value.description,
}));
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
{to: {name: 'publicPOIs'}, label: "Místa"},
...publicPOIBaseBreadcrumbs(),
{label: pointOfInterest.value.name}
]);
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {pointOfInterestId: pointOfInterest.value.id};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetchPublic,
appendItems
});
onMounted(() => {
usePhotosStore().fetchPublic({pointOfInterestId: pointOfInterest.value.id});
loadNextFlightsPage(false);
loadPage(false);
});
</script>
<template>
@@ -84,7 +79,7 @@ onMounted(() => {
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
:public="true"
@load-more="loadNextFlightsPage(true)"
@load-more="loadPage(true)"
/>
</q-card-section>
</template>
+8 -10
View File
@@ -6,27 +6,25 @@ import POIList from "components/poi/POIList.vue";
import {useAppStore} from "stores/App";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
import {filters, loadPage, paginationLimit, paginationOffset} from "src/composables/pagination";
import {usePagination} from "src/composables/pagination";
import ListFilters from "components/ListFilters.vue";
import {homepageBreadcrumbs, publicPOIBaseBreadcrumbs} from "src/composables/breadcrumbs";
useAppStore().setPageTitle("Místa");
useAppStore().setBreadcrumbs([
{to: {name: 'homepage'}, label: "Polétání.cz"},
{label: "Místa"},
]);
useAppStore().setBreadcrumbs(publicPOIBaseBreadcrumbs());
useMeta(() => metaData({
title: "Místa"
}))
const loadNextPage = (appendItems) => loadPage({
fetchFunc: usePointsOfInterestStore().fetchPublic, appendItems
const {filters, loadPage, paginationOffset, paginationLimit} = usePagination({
fetchFunction: usePointsOfInterestStore().fetchPublic
});
loadNextPage(false);
loadPage(false);
const handleFiltersChanged = (newFilters) => {
paginationOffset.value = 0;
filters.value = newFilters;
loadNextPage(false);
loadPage(false);
}
const {pointsOfInterest, totalItemsCount, isQueryRunning} = storeToRefs(usePointsOfInterestStore());
@@ -44,7 +42,7 @@ const {pointsOfInterest, totalItemsCount, isQueryRunning} = storeToRefs(usePoint
:total-items-count="totalItemsCount"
:is-query-running="isQueryRunning"
:items-per-page="paginationLimit"
@load-more="loadNextPage(true)"
@load-more="loadPage(true)"
/>
</div>
</template>
@@ -15,6 +15,7 @@ const $q = useQuasar();
useAppStore().setPageTitle("Zapomenuté heslo");
useMeta(() => metaData({
title: "Zapomenuté heslo",
index: false
}));
const requestPasswordChange = () => {
+1
View File
@@ -14,6 +14,7 @@ const $q = useQuasar();
useAppStore().setPageTitle("Přihlášení");
useMeta(() => metaData({
title: "Přihlášení",
index: false
}));
const logUser = () => {
@@ -17,6 +17,7 @@ const $q = useQuasar();
useAppStore().setPageTitle("Změna hesla");
useMeta(() => metaData({
title: "Změna hesla",
index: false
}));
const tokenInfo = reactive({
+1 -1
View File
@@ -18,7 +18,7 @@ export default route(function ({ store }) {
Router.beforeEach(async (to, from) => {
if (from.name !== to.name) {
useAppStore(store).resetAfterRouteChange();
useAppStore(store).resetBeforeRouteChange();
}
});
+10 -1
View File
@@ -27,6 +27,7 @@ const routes = [
children: [
{path: '', name: "publicProfileDetail", component: () => import('pages/public/pilotProfile/FlightsListPage.vue')},
{path: ':flightSlug', name: "publicFlightDetail", component: () => import('pages/public/pilotProfile/FlightDetailPage.vue')},
{path: ':flightSlug/nahraj', name: "publicCopilotUploadPhoto", component: () => import('pages/public/pilotProfile/CopilotUploadPhotoPage.vue')},
{path: 'a/:copilotSlug', name: "publicCopilotDetail", component: () => import('pages/public/pilotProfile/CopilotDetailPage.vue')},
{path: 'udalosti', name: "publicEventsList", component: () => import('pages/public/pilotProfile/EventsListPage.vue')},
{path: 'udalosti/:eventSlug', name: "publicEventDetail", component: () => import('pages/public/pilotProfile/EventDetailPage.vue')},
@@ -42,18 +43,26 @@ const routes = [
},
children: [
{path: '', name: "myFlights", component: () => import('pages/profile/flight/FlightsPage.vue')},
{path: 'nastaveni', name: "profile", component: () => import('pages/profile/ProfilePage.vue')},
{path: 'lety/novy', name: "addFlight", component: () => import('pages/profile/flight/NewFlightPage.vue')},
{path: 'lety/:id', name: "flightDetail", component: () => import('pages/profile/flight/FlightDetailPage.vue')},
{path: 'lety/:id/edit/:step?', name: "flightEdit", component: () => import('pages/profile/flight/EditFlightPage.vue')},
{path: 'nastaveni', name: "profile", component: () => import('pages/profile/ProfilePage.vue')},
{path: 'plany', name: "flightPlans", component: () => import('pages/profile/flightPlanner/FlightPlansPage.vue')},
{path: 'plany/novy', name: "newFlightPlan", component: () => import('pages/profile/flightPlanner/NewFlightPlanPage.vue')},
{path: 'plany/:id', name: "editFlightPlan", component: () => import('pages/profile/flightPlanner/FlightPlanEditPage.vue')},
{path: 'poi', name: "poi", component: () => import('pages/profile/poi/PointsOfInterestPage.vue')},
{path: 'poi/novy', name: "addPOI", component: () => import('pages/profile/poi/NewPointOfInterestPage.vue')},
{path: 'poi/:id', name: "poiDetail", component: () => import('pages/profile/poi/PointOfInterestDetailPage.vue')},
{path: 'poi/:id/edit/:step?', name: "poiEdit", component: () => import('pages/profile/poi/EditPointOfInterestPage.vue')},
{path: 'kopiloti', name: "copilots", component: () => import('pages/profile/copilot/CopilotsPage.vue')},
{path: 'kopiloti/novy', name: "addCopilot", component: () => import('pages/profile/copilot/NewCopilotPage.vue')},
{path: 'kopiloti/:id', name: "copilotDetail", component: () => import('pages/profile/copilot/CopilotDetailPage.vue')},
{path: 'kopiloti/:id/edit/:step?', name: "copilotEdit", component: () => import('pages/profile/copilot/EditCopilotPage.vue')},
{path: 'letadla', name: "aircrafts", component: () => import('pages/profile/aircraft/AircraftsPage.vue')},
{path: 'letadla/nove', name: "addAircraft", component: () => import('pages/profile/aircraft/NewAircraftsPage.vue')},
{path: 'letadla/:id', name: "aircraftDetail", component: () => import('pages/profile/aircraft/AircraftDetailPage.vue')},
+18
View File
@@ -0,0 +1,18 @@
import {defineStore} from 'pinia'
import {AIRSPACE_LIST} from "src/graphql/queries/airspace";
import {ref} from "vue";
import {query} from "stores/helpers";
export const useAirspacesStore = defineStore('Airspaces', () => {
const airspaces = ref([])
const isQueryRunning = ref(false);
const fetch = () => {
return query({
isQueryRunning,
query: AIRSPACE_LIST,
}).then(items => airspaces.value = items);
}
return {airspaces, isQueryRunning, fetch};
})
+4 -9
View File
@@ -1,20 +1,15 @@
import {defineStore} from 'pinia'
import {ref} from "vue";
import {paginationLimit, paginationOffset} from "src/composables/pagination";
export const useAppStore = defineStore('App', () => {
const pageTitle = ref(null);
const breadcrumbs = ref(null);
const appStatus = ref(200);
const resetAfterRouteChange = () => {
pageTitle.value = null;
const resetBeforeRouteChange = () => {
// pageTitle.value = null;
// appStatus.value = 200; // tohle dela problem pri renderu 404 na serveru
breadcrumbs.value = null;
// reset strankovani po prechodu na strankach
paginationLimit.value = 10;
paginationOffset.value = 0;
// breadcrumbs.value = null;
};
const setPageTitle = (value) => {
@@ -28,6 +23,6 @@ export const useAppStore = defineStore('App', () => {
return {
pageTitle, breadcrumbs, appStatus,
resetAfterRouteChange, setPageTitle, setBreadcrumbs
resetBeforeRouteChange, setPageTitle, setBreadcrumbs
}
})
+2 -2
View File
@@ -18,11 +18,11 @@ export const useCopilotsStore = defineStore('Copilots', () => {
}).then(item => copilot.value = item);
};
const fetchPublicDetail = (urlSlug, username) => {
const fetchPublicDetail = ({urlSlug, username, uploadToken, uploadFlightSlug}) => {
return query({
isQueryRunning,
query: COPILOT_PUBLIC_DETAIL,
variables: {urlSlug, pilotUsername: username},
variables: {urlSlug, pilotUsername: username, uploadToken, uploadFlightSlug},
}).then(item => {
copilot.value = item;
return item;
+11 -4
View File
@@ -2,7 +2,7 @@ import {defineStore} from 'pinia'
import {CREATE_EVENT, EDIT_EVENT} from "src/graphql/mutations/event";
import {mutate, query, rewriteObject, setItems, updateInItems} from "stores/helpers";
import {ref} from "vue";
import {EVENT_DETAIL, EVENT_PUBLIC_DETAIL, EVENTS_LIST, EVENTS_PUBLIC_LIST} from "src/graphql/queries/event";
import {EVENT_DETAIL, EVENT_PUBLIC_DETAIL, EVENTS_FLIGHTS_TRACKS, EVENTS_LIST, EVENTS_PUBLIC_LIST} from "src/graphql/queries/event";
export const useEventsStore = defineStore('Events', () => {
const event = ref(null);
@@ -19,7 +19,7 @@ export const useEventsStore = defineStore('Events', () => {
}).then(item => event.value = item);
};
const fetchPublicDetail = (urlSlug, username) => {
const fetchPublicDetail = (urlSlug, username) => {
return query({
isQueryRunning,
query: EVENT_PUBLIC_DETAIL,
@@ -27,6 +27,13 @@ export const useEventsStore = defineStore('Events', () => {
}).then(item => event.value = item);
};
const fetchFlightsTracks = ({eventId, eventUrlSlug, onlyPublic}) => {
return query({
query: EVENTS_FLIGHTS_TRACKS,
variables: {eventId, onlyPublic, eventUrlSlug}
})
}
const fetch = ({limit = 100, offset, appendItems}) => {
return query({
isQueryRunning,
@@ -35,7 +42,7 @@ export const useEventsStore = defineStore('Events', () => {
}).then(data => setItems(data, appendItems, events, totalItemsCount));
}
const fetchPublic = (username, limit = 100, offset, appendItems) => {
const fetchPublic = (username, limit = 100, offset, appendItems) => {
return query({
isQueryRunning,
query: EVENTS_PUBLIC_LIST,
@@ -65,6 +72,6 @@ export const useEventsStore = defineStore('Events', () => {
return {
event, events, isMutationRunning, isQueryRunning, fetch, fetchDetail, create, update,
fetchPublic, fetchPublicDetail, totalItemsCount
fetchPublic, fetchPublicDetail, totalItemsCount, fetchFlightsTracks
}
})
+55
View File
@@ -0,0 +1,55 @@
import {defineStore} from 'pinia'
import {FLIGHT_PLAN_DETAIL, FLIGHT_PLANS_LIST} from "src/graphql/queries/flightPlan";
import {CREATE_FLIGHT_PLAN, EDIT_FLIGHT_PLAN} from "src/graphql/mutations/flightPlan";
import {mutate, query, updateInItems} from "stores/helpers";
import {ref} from "vue";
export const useFlightPlansStore = defineStore('FlightPlans', () => {
const flightPlan = ref(null);
const flightPlans = ref([]);
const isMutationRunning = ref(false);
const isQueryRunning = ref(false);
const fetchDetail = (id) => {
return query({
isQueryRunning,
query: FLIGHT_PLAN_DETAIL,
variables: {id},
}).then(item => flightPlan.value = item);
};
const fetch = () => {
return query({
isQueryRunning,
query: FLIGHT_PLANS_LIST,
}).then(items => flightPlans.value = items);
}
const create = (input) => {
return mutate({
isMutationRunning,
mutation: CREATE_FLIGHT_PLAN,
variables: {input},
}).then(item => {
flightPlans.value.push(item);
return item;
})
}
const update = (id, input) => {
return mutate({
isMutationRunning,
mutation: EDIT_FLIGHT_PLAN,
variables: {id, input}
}).then(item => {
updateInItems(flightPlans.value, 'id', item);
flightPlan.value = item;
return item;
})
}
return {
flightPlan, flightPlans, isMutationRunning, isQueryRunning,
fetch, fetchDetail, create, update
}
})

Some files were not shown because too many files have changed in this diff Show More