Mapa s trasami letu u eventu, drobna uprava vizualu eventu

This commit is contained in:
Michal Kváček
2024-02-28 09:41:15 +01:00
parent 92678ec829
commit 7a45a0133e
14 changed files with 211 additions and 84 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ services:
- ./src-ssr:/app/src-ssr - ./src-ssr:/app/src-ssr
- ./public:/app/public - ./public:/app/public
# - ./node_modules:/app/node_modules:delegated # - ./node_modules:/app/node_modules:delegated
command: quasar dev -m ssr command: quasar dev # -m ssr
environment: environment:
API_URL: "http://127.0.0.1:8000" API_URL: "http://127.0.0.1:8000"
+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>
+62
View File
@@ -0,0 +1,62 @@
<script setup>
import MapWithMarkers from "components/map/MapWithMarkers.vue";
import {ref} from "vue";
import {useEventsStore} from "stores/Events";
import {minutesToHumanReadable} from "../../composables/datetimeUtils";
import {pluralize} from "../../composables/utils";
const props = defineProps({
event: {
type: Object,
required: true
},
onlyPublic: {
type: Boolean,
required: true
}
})
const tracks = ref([]);
const totalTime = ref(0);
const totalFlights = ref(0);
useEventsStore().fetchFlightsTracks({
eventId: props.event.id,
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>
+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
}
+2 -9
View File
@@ -5,6 +5,7 @@ import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue"; import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {computed} from "vue"; import {computed} from "vue";
import LoadMoreList from "components/LoadMoreList.vue"; import LoadMoreList from "components/LoadMoreList.vue";
import {pluralize} from "../../composables/utils";
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -37,16 +38,8 @@ const routeDetail = (flight) => {
else else
return {name: "flightDetail", params: {id: flight.id}} 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 loadMoreCount = computed(() => {
const remaining = props.totalItemsCount - props.items.length; return Math.min(props.itemsPerPage, props.totalItemsCount - props.items.length);
return Math.min(props.itemsPerPage, remaining);
}); });
</script> </script>
+52 -5
View File
@@ -1,14 +1,20 @@
<script setup> <script setup>
import MapWrapper from "components/map/MapWrapper.vue"; import MapWrapper from "components/map/MapWrapper.vue";
import {Marker, Polyline, InfoWindow} from "vue3-google-map"; import {Marker, Polyline, InfoWindow} from "vue3-google-map";
import {computed, onMounted, ref} from "vue"; import {computed} from "vue";
import {axios} from "boot/axios";
const props = defineProps({ const props = defineProps({
gpxTrack: { gpxTrack: {
type: Object, type: Object,
required: false, required: false,
default: () => {
}
},
tracks: {
type: Array,
required: false,
default: () => []
}, },
markers: { markers: {
type: Array, type: Array,
@@ -26,12 +32,47 @@ const props = defineProps({
} }
}) })
const track = ref([]);
const bounds = computed(() => { const bounds = computed(() => {
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}})); const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
const allData = [...props.markers, ...track.value, ...photosTransformed]; const coordinates = [
return allData.filter(i => !!i.position).map(i => i.position); ...[...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> </script>
<template> <template>
@@ -47,6 +88,12 @@ const bounds = computed(() => {
<Polyline :options="{path: gpxTrack, geodesic: true, strokeColor: '#ff0000', strokeOpacity: 0.3, strokeWeight: 5}"/> <Polyline :options="{path: gpxTrack, geodesic: true, strokeColor: '#ff0000', strokeOpacity: 0.3, strokeWeight: 5}"/>
</template> </template>
<template v-if="tracks && tracks.length > 0">
<template v-for="(track, i) in tracks" :key="track">
<Polyline :options="{path: track, geodesic: false, strokeColor: getColor(i), strokeOpacity: 1, strokeWeight: 3}"/>
</template>
</template>
<Marker v-if="highlightPosition" :options="{position: highlightPosition, icon: '/mapIcons/currentPosition32x32.png'}"/> <Marker v-if="highlightPosition" :options="{position: highlightPosition, icon: '/mapIcons/currentPosition32x32.png'}"/>
</MapWrapper> </MapWrapper>
-28
View File
@@ -14,34 +14,6 @@ export const minutesToHumanReadable = (totalMinutes) => {
return `${hoursPart}${minutes}min` 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) => { export const parseDate = (datetimeStr, format = FORMAT_ISO) => {
return date.extractDate(datetimeStr, format) return date.extractDate(datetimeStr, format)
} }
+6
View File
@@ -2,3 +2,9 @@ export const roundWithPrecision = (number, precision) => {
const factor = Math.pow(10, precision); const factor = Math.pow(10, precision);
return Math.round(number * factor) / factor; 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;
}
+17 -1
View File
@@ -20,13 +20,29 @@ query eventsList($limit: Int!, $offset: Int) {
${FRAGMENT_EVENT_LIST_INFO} ${FRAGMENT_EVENT_LIST_INFO}
`; `;
export const EVENTS_FLIGHTS_TRACKS = gql`
query eventsFlightsTracks($eventId: Int!, $onlyPublic: Boolean) {
event(id: $eventId) {
flights(onlyPublic: $onlyPublic) {
durationMinCalculated
gpxTrack {
coordinates {
lat
lng
}
}
}
}
}
`;
export const EVENTS_PUBLIC_LIST = gql` export const EVENTS_PUBLIC_LIST = gql`
query eventsPublicList($username: String, $limit: Int!, $offset: Int) { query eventsPublicList($username: String, $limit: Int!, $offset: Int) {
events(username: $username, limit: $limit, offset: $offset, public: true) { events(username: $username, limit: $limit, offset: $offset, public: true) {
items { items {
...eventListInfo ...eventListInfo
flights { flights(onlyPublic: true) {
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
} }
+7 -18
View File
@@ -5,7 +5,6 @@ import EventDetailSkeleton from "components/event/skeletons/EventDetailSkeleton.
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import FAB from "components/FAB.vue"; import FAB from "components/FAB.vue";
import FlightsList from "components/flights/FlightsList.vue"; import FlightsList from "components/flights/FlightsList.vue";
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
import {useEventsStore} from "stores/Events"; import {useEventsStore} from "stores/Events";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {filters, loadPage, paginationLimit} from "src/composables/pagination"; import {filters, loadPage, paginationLimit} from "src/composables/pagination";
@@ -13,9 +12,10 @@ import {useFlightsStore} from "stores/Flights";
import PublishButton from "components/inputs/PublishButton.vue"; import PublishButton from "components/inputs/PublishButton.vue";
import {useLoggedUserStore} from "stores/LoggedUser"; import {useLoggedUserStore} from "stores/LoggedUser";
import {toggleVisibility} from "src/composables/form/formHandling"; import {toggleVisibility} from "src/composables/form/formHandling";
import {TOGGLE_FLIGHT_PUBLISH} from "src/graphql/mutations/flight";
import {useQuasar} from "quasar"; import {useQuasar} from "quasar";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue"; 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); const eventId = parseInt(useRoute().params.id);
onMounted(() => { onMounted(() => {
@@ -35,7 +35,6 @@ const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useEventsStore(), object: event.value, variables $q, store: useEventsStore(), object: event.value, variables
}); });
const {isQueryRunning, event} = storeToRefs(useEventsStore()); const {isQueryRunning, event} = storeToRefs(useEventsStore());
filters.value = {eventId: eventId} filters.value = {eventId: eventId}
@@ -51,19 +50,7 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
<EventDetailSkeleton v-if="isQueryRunning || !event"/> <EventDetailSkeleton v-if="isQueryRunning || !event"/>
<template v-else> <template v-else>
<q-list bordered separator class="q-my-lg" v-if="event.dateFrom && event.dateTo"> <EventBasicInfo :event="event"/>
<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>
<PublishButton <PublishButton
:url-prefix="`${loggedUser.publicUsername}/udalosti/`" :url-prefix="`${loggedUser.publicUsername}/udalosti/`"
@@ -75,11 +62,13 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
:route="{name: 'publicEventDetail', params: {username: loggedUser.publicUsername, eventSlug: event.urlSlug}}" :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="event" :only-public="false" />
<h3>Lety</h3> <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. Zatím tu nemáš uložené žádné lety.
</q-banner> </q-banner>
@@ -94,7 +94,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
<FlightBasicInfo <FlightBasicInfo
:flight="flight" :flight="flight"
:poi-detail-route="{name: 'poiDetail'}" :poi-detail-route="{name: 'poiDetail'}"
:event-detail-route="{name: 'eventDetail', params: {id: flight.event.id}}" :event-detail-route="flight.event ? {name: 'eventDetail', params: {id: flight.event.id}} : null"
:aircraft-detail-route="{name: 'aircraftDetail'}" :aircraft-detail-route="{name: 'aircraftDetail'}"
:copilot-detail-route="{name: 'copilotDetail'}" :copilot-detail-route="{name: 'copilotDetail'}"
/> />
@@ -11,7 +11,6 @@ export default {
<script setup> <script setup>
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import EventDetailSkeleton from "components/event/skeletons/EventDetailSkeleton.vue"; import EventDetailSkeleton from "components/event/skeletons/EventDetailSkeleton.vue";
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
import FlightsList from "components/flights/FlightsList.vue"; import FlightsList from "components/flights/FlightsList.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {filters, loadPage, paginationLimit} from "src/composables/pagination"; import {filters, loadPage, paginationLimit} from "src/composables/pagination";
@@ -19,6 +18,8 @@ import {useFlightsStore} from "stores/Flights";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue"; import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {useMeta} from "quasar"; import {useMeta} from "quasar";
import {metaData} from "src/composables/meta"; import {metaData} from "src/composables/meta";
import EventBasicInfo from "components/event/EventBasicInfo.vue";
import EventFlightsMap from "components/event/EventFlightsMap.vue";
const props = defineProps({ const props = defineProps({
pilot: { pilot: {
@@ -55,21 +56,12 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
<EventDetailSkeleton v-if="isQueryRunning || !event"/> <EventDetailSkeleton v-if="isQueryRunning || !event"/>
<template v-else> <template v-else>
<q-list bordered separator class="q-my-lg" v-if="event.dateFrom && event.dateTo"> <EventBasicInfo :event="event"/>
<q-item>
<q-item-section>
Od: {{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
</q-item-section>
</q-item>
<q-item> <article class="q-my-lg" v-html="event.description"/>
<q-item-section>
Do: {{ formatDatetime(event.dateTo, FORMAT_DATE) }} <EventFlightsMap :event="event" :only-public="true"/>
</q-item-section>
</q-item>
</q-list>
<article v-html="event.description"/>
<h3>Lety</h3> <h3>Lety</h3>
<q-banner v-if="event.flights.length === 0" class="bg-light-blue text-white"> <q-banner v-if="event.flights.length === 0" class="bg-light-blue text-white">
@@ -87,7 +79,6 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
:public="true" :public="true"
@load-more="loadNextFlightsPage(true)" @load-more="loadNextFlightsPage(true)"
/> />
</template> </template>
</div> </div>
</template> </template>
+9 -2
View File
@@ -2,7 +2,7 @@ import {defineStore} from 'pinia'
import {CREATE_EVENT, EDIT_EVENT} from "src/graphql/mutations/event"; import {CREATE_EVENT, EDIT_EVENT} from "src/graphql/mutations/event";
import {mutate, query, rewriteObject, setItems, updateInItems} from "stores/helpers"; import {mutate, query, rewriteObject, setItems, updateInItems} from "stores/helpers";
import {ref} from "vue"; 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', () => { export const useEventsStore = defineStore('Events', () => {
const event = ref(null); const event = ref(null);
@@ -27,6 +27,13 @@ export const useEventsStore = defineStore('Events', () => {
}).then(item => event.value = item); }).then(item => event.value = item);
}; };
const fetchFlightsTracks = ({eventId, onlyPublic}) => {
return query({
query: EVENTS_FLIGHTS_TRACKS,
variables: {eventId, onlyPublic}
})
}
const fetch = ({limit = 100, offset, appendItems}) => { const fetch = ({limit = 100, offset, appendItems}) => {
return query({ return query({
isQueryRunning, isQueryRunning,
@@ -65,6 +72,6 @@ export const useEventsStore = defineStore('Events', () => {
return { return {
event, events, isMutationRunning, isQueryRunning, fetch, fetchDetail, create, update, event, events, isMutationRunning, isQueryRunning, fetch, fetchDetail, create, update,
fetchPublic, fetchPublicDetail, totalItemsCount fetchPublic, fetchPublicDetail, totalItemsCount, fetchFlightsTracks
} }
}) })
+9 -1
View File
@@ -39,7 +39,9 @@ export const query = ({isQueryRunning, query, variables, refreshUnauthorized}) =
query._refreshUnauthorizedUser = typeof refreshUnauthorized === 'undefined' ? true : refreshUnauthorized; query._refreshUnauthorizedUser = typeof refreshUnauthorized === 'undefined' ? true : refreshUnauthorized;
if (isQueryRunning)
isQueryRunning.value = true; isQueryRunning.value = true;
const queryName = query.definitions[0].selectionSet.selections[0].name.value; const queryName = query.definitions[0].selectionSet.selections[0].name.value;
return apolloWrapper.query(query, variables) return apolloWrapper.query(query, variables)
.then((result) => { .then((result) => {
@@ -49,12 +51,18 @@ export const query = ({isQueryRunning, query, variables, refreshUnauthorized}) =
return result.data[queryName] return result.data[queryName]
}) })
.catch(e => Promise.reject(e)) .catch(e => Promise.reject(e))
.finally(() => isQueryRunning.value = false); .finally(() => {
if (isQueryRunning)
isQueryRunning.value = false
});
} }
export const mutate = ({isMutationRunning, mutation, variables}) => { export const mutate = ({isMutationRunning, mutation, variables}) => {
isMutationRunning.value = true; isMutationRunning.value = true;
let mutationName = getOperationName(mutation); let mutationName = getOperationName(mutation);
// TODO: pridat refresh tokenu!
return apolloWrapper.mutate(mutation, variables) return apolloWrapper.mutate(mutation, variables)
.then(result => { .then(result => {
if (typeof result.data[mutationName] === 'undefined') { if (typeof result.data[mutationName] === 'undefined') {