43 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
Michal Kváček 1a5c8f09bf Napoveda, fix filtru pro nacitani letu u objektu 2024-02-13 23:00:37 +01:00
Michal Kváček 9c9bbc9baa Reset hesla 2024-02-08 16:25:41 +01:00
Michal Kváček fe47c1f7d6 Neindexovat 404 2024-02-02 09:34:03 +01:00
Michal Kváček b22773b2ae Snad fix 404ky 2024-02-01 17:32:12 +01:00
Michal Kváček d69ceac14b Vyhledavani i ve verejnych POI 2024-02-01 14:48:05 +01:00
Michal Kváček 418469d81c Vyhledavani v POI 2024-02-01 08:32:27 +01:00
Michal Kváček c28feade90 Drobna uprava refreshe loginu 2024-02-01 07:21:03 +01:00
Michal Kváček 5b94f2c4b3 Kontaktni form 2024-02-01 07:20:54 +01:00
Michal Kváček 08a3cbf3d9 Avatar do admina, pridani debrief informaci 2024-01-20 22:08:49 +01:00
Michal Kváček b0aa6b5628 Uprava setovani 404 (asi porad nefunguje), ruzne podminky, priprava na zapomenute heslo, sjednoceni nacitani letu v profilu 2024-01-20 15:40:31 +01:00
Michal Kváček 0c89884b23 Uprava publikovani objektu, bugfixing 2024-01-18 06:52:39 +01:00
Michal Kváček 5a5c7cb645 Slugy, webp, hromada refaktoringu 2024-01-13 23:29:51 +01:00
Michal Kváček e7cde2f425 Zmena favicon, uprava HP 2024-01-02 11:30:53 +01:00
Michal Kváček 966d582b63 Uprava error stranek, indexu 2023-12-19 20:34:06 +01:00
Michal Kváček 9aebc352cd Fix nacitani letu u eventu 2023-12-12 23:54:19 +01:00
Michal Kváček c8ea17c1ec Aktualizace balicku, strnakovani, uprava hlavni stranky 2023-12-12 23:42:16 +01:00
Michal Kváček 8c7e237053 Verejny seznam POI 2023-12-10 23:21:33 +01:00
Michal Kváček 3216de4cb7 Bugfixing 2023-12-10 23:11:11 +01:00
182 changed files with 5949 additions and 1884 deletions
+4 -3
View File
@@ -33,11 +33,12 @@ services:
- "9001:9100" - "9001:9100"
volumes: volumes:
- ./src:/app/src - ./src:/app/src
- ./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://localhost:8000" API_URL: "http://127.0.0.1:8000"
# nginx: # nginx:
# image: # image:
+9 -9
View File
@@ -15,28 +15,28 @@
"@vue/apollo-composable": "4.0.0-beta.2", "@vue/apollo-composable": "4.0.0-beta.2",
"@vue/apollo-option": "^4.0.0-beta.5", "@vue/apollo-option": "^4.0.0-beta.5",
"apollo-upload-client": "^17.0.0", "apollo-upload-client": "^17.0.0",
"axios": "^1.4.0", "axios": "^1.6.2",
"chart.js": "^4.4.0", "chart.js": "^4.4.1",
"graphql": "16.6.0", "graphql": "16.6.0",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"photoswipe": "^5.4.2", "photoswipe": "^5.4.2",
"photoswipe-dynamic-caption-plugin": "^1.2.7", "photoswipe-dynamic-caption-plugin": "^1.2.7",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"quasar": "^2.14.1", "quasar": "^2.14.1",
"vue": "^3.2.47", "vue": "^3.3.11",
"vue-chartjs": "^5.2.0", "vue-chartjs": "^5.2.0",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",
"vue-router": "^4.1.6", "vue-router": "^4.2.5",
"vue3-google-map": "^0.15.0" "vue3-google-map": "^0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@quasar/app-vite": "^1.7.1", "@quasar/app-vite": "^1.7.1",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.16",
"eslint": "^8.10.0", "eslint": "^8.55.0",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.10.0",
"eslint-plugin-vue": "^9.0.0", "eslint-plugin-vue": "^9.19.2",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"prettier": "^2.5.1" "prettier": "^2.8.8"
}, },
"engines": { "engines": {
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Disallow: /login/
Disallow: /moje/
+3 -1
View File
@@ -119,7 +119,9 @@ module.exports = configure(function (ctx) {
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations // https://v2.quasar.dev/options/animations
animations: [], animations: [
"fadeIn", "fadeOut"
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
sourceFiles: { sourceFiles: {
+6 -3
View File
@@ -28,9 +28,12 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
} else if (err.code === 404) { } else if (err.code === 404) {
// hmm, Vue Router could not find the requested route // hmm, Vue Router could not find the requested route
console.log("404 v renderu")
// Should reach here only if no "catch-all" route // Should reach here only if no "catch-all" route
// is defined in /src/routes // is defined in /src/routes
res.status(404).send('404 | Page Not Found') // res.status(404).send('404 | Page Not Found')
res.redirect(resolve.urlPath('404'))
} else if (process.env.DEV) { } else if (process.env.DEV) {
// well, we treat any other code as error; // well, we treat any other code as error;
// if we're in dev mode, then we can use Quasar CLI // if we're in dev mode, then we can use Quasar CLI
@@ -39,6 +42,7 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
// serve.error is available on dev only // serve.error is available on dev only
serve.error({ err, req, res }) serve.error({ err, req, res })
} else { } else {
// we're in production, so we should have another method // we're in production, so we should have another method
// to display something to the client when we encounter an error // to display something to the client when we encounter an error
@@ -47,8 +51,7 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
// Render Error Page on production or // Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it // create a route (/src/routes) for an error page and redirect to it
res.status(500).send('500 | Internal Server Error') res.redirect(resolve.urlPath('500'))
// console.error(err.stack)
} }
}) })
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup> <script setup>
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import {computed} from "vue"; import {computed} from "vue";
import ErrorNotFound from "pages/ErrorNotFound.vue"; import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+15 -10
View File
@@ -1,17 +1,18 @@
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core' import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core'
import {onError} from "@apollo/client/link/error"; import {onError} from "@apollo/client/link/error";
import {useUserStore} from "stores/User";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import {Notify} from "quasar"; import {Notify} from "quasar";
import {errorObject, warningObject} from "src/composables/dialog"; import {errorObject, warningObject} from "src/composables/dialog";
import {createUploadLink} from "apollo-upload-client"; import {createUploadLink} from "apollo-upload-client";
import {useAuthStore} from "stores/Auth";
import {useRouter} from "vue-router";
// import createUploadLink from "apollo-upload-client/createUploadLink.mjs"; // import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
const handleRefreshToken = (store, router, forward, operation) => { const handleRefreshToken = (store, router, forward, operation) => {
if (!operation.query?._refreshUnauthorizedUser) return; if (!operation.query?._refreshUnauthorizedUser) return;
console.log("refreshing token, ", operation.query); console.log("refreshing token, ", operation.query);
return fromPromise(useUserStore(store).refreshAccessToken() return fromPromise(useAuthStore(store).refreshAccessToken()
.catch((error) => { .catch((error) => {
Notify.create(warningObject("Tvoje přihlášení vypřešlo. Přihlaš se prosím znovu.")); Notify.create(warningObject("Tvoje přihlášení vypřešlo. Přihlaš se prosím znovu."));
router.push({name: "login"}) router.push({name: "login"})
@@ -39,8 +40,7 @@ export default (({store, router}) => {
}) })
const authLink = new ApolloLink((operation, forward) => { const authLink = new ApolloLink((operation, forward) => {
const accessToken = useUserStore(store).getAccessToken(); const accessToken = useAuthStore(store).getAccessToken();
if (accessToken) { if (accessToken) {
operation.setContext({ operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`}, headers: {"Authorization": `Bearer ${accessToken}`},
@@ -50,17 +50,16 @@ export default (({store, router}) => {
}); });
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => { const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
console.log(networkError, graphQLErrors);
if (networkError?.statusCode === 401) { if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation); return handleRefreshToken(store, router, forward, operation);
} }
if (graphQLErrors) { if (graphQLErrors) {
for (const error of graphQLErrors) { for (const error of graphQLErrors) {
if (error.message === "Not found") { if (error.message.includes("401") || error.message.includes("Not authorized")) {
useAppStore(store).appStatus = 404;
} else if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation); return handleRefreshToken(store, router, forward, operation);
} else if (error.message.includes("404") || error.message.includes("Not found")) {
useAppStore(store).appStatus = 404;
} else { } else {
Notify.create(errorObject("API error: " + error.message)); Notify.create(errorObject("API error: " + error.message));
} }
@@ -80,9 +79,15 @@ export default (({store, router}) => {
}) })
.then((response) => response) .then((response) => response)
.catch(err => { .catch(err => {
if (!err.toString().includes("401") && !err.toString().includes("Not authorized")) const errStr = err.toString();
if (errStr.includes('Not found') || errStr.includes("No row was found")) {
useAppStore(store).appStatus = 404;
return Promise.resolve();
}
else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err) console.error("Error during query: ", err)
return err; }
return Promise.reject(err);
}) })
}; };
apolloWrapper.mutate = (mutation, variables) => { apolloWrapper.mutate = (mutation, variables) => {
+8 -9
View File
@@ -1,6 +1,6 @@
import {boot} from 'quasar/wrappers' import {boot} from 'quasar/wrappers'
import axios from 'axios' import axios from 'axios'
import {useUserStore} from "stores/User"; import {useAuthStore} from "stores/Auth";
const api = axios.create({ const api = axios.create({
@@ -15,17 +15,17 @@ const api = axios.create({
export default boot(({store}) => { export default boot(({store}) => {
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = useUserStore(store).getAccessToken(); const token = useAuthStore(store).getAccessToken();
if (token) { if (token) {
config.headers['Authorization'] = `Bearer ${token}` config.headers['Authorization'] = `Bearer ${token}`
} }
if (['/refresh', '/login'].includes(config.url)) { if (['/refresh', '/login'].includes(config.url)) {
delete config.headers['Authorization']; delete config.headers['Authorization'];
} }
return config; return config;
}) })
// Response interceptor for API calls // Response interceptor for API calls
api.interceptors.response.use( api.interceptors.response.use(
@@ -33,8 +33,7 @@ export default boot(({store}) => {
async function (error) { async function (error) {
const originalRequest = error.config; const originalRequest = error.config;
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) { if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
const newToken = await useUserStore(store).refreshAccessToken().catch(err => { const newToken = await useAuthStore(store).refreshAccessToken().catch(err => {
console.error(err);
return Promise.reject("Logged out"); return Promise.reject("Logged out");
}) })
api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken; api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
+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>
+69
View File
@@ -0,0 +1,69 @@
<script setup>
import {ref} from "vue";
const date = new Date();
const year = ref(date.getFullYear());
</script>
<template>
<q-footer class="q-pa-lg q-mt-xl">
<div class="row justify-center">
<div class="col-xs-12 col-md-4 text-center q-py-lg" style="font-size: 130%">
<q-img fit="contain" width="32px" src="/icons/favicon-32x32.png"/>
<div class="text-h5">Polétání.cz</div>
Vytvořeno s <q-icon name="favorite" color="red"/> nejen pro létání.
</div>
<div class="col-xs-6 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'about'}">O projektu</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'contact'}">Kontakt</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'features'}">Funkce</router-link>
</q-item>
</q-list>
</div>
<div class="col-xs-6 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'login'}">Login</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'registration'}">Registrace</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'forgottenPassword'}">Zapomenuté heslo</router-link>
</q-item>
</q-list>
</div>
<div class="col-xs-12 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'termsAndConditions'}">Podmínky použití</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'personalInformation'}">Správa osobních údajů</router-link>
</q-item>
</q-list>
</div>
</div>
<div class="row q-mt-lg">
<div class="col text-center">
&copy; {{ year }} Polétání.cz
</div>
</div>
</q-footer>
</template>
<style scoped>
a {
color: white;
font-size: 110%;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup>
import {commonProps} from "src/composables/inputs";
import {onMounted, ref, watch} from "vue";
import {debounce} from "quasar";
import {useRoute, useRouter} from "vue-router";
const props = defineProps({
emitDebounce: {
type: Number,
required: false,
default: 200
}
})
const emits = defineEmits(["filtersChanged"]);
const filters = ref({
search: ""
});
const $route = useRoute();
const $router = useRouter();
watch((() => filters.value), debounce(() => {
emits("filtersChanged", filters.value);
const query = {...$route.query};
for (const key in filters.value) {
if (filters.value[key] === "" || filters.value['key'] === null) {
delete query[key];
} else {
query[key] = filters.value[key];
}
}
$router.replace({query});
}, props.emitDebounce), {deep: true});
onMounted(() => {
const search = $route.query.search;
if (search) {
filters.value.search = search;
}
})
</script>
<template>
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
<template #prepend>
<q-icon name="search"/>
</template>
</q-input>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
const props = defineProps({
totalItemsCount: {
type: Number,
required: true
},
itemsLength: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
})
const emits = defineEmits(["load-more"]);
const loadMore = () => {
emits("load-more", props.itemsPerPage);
}
</script>
<template>
<div>
<slot name="items"/>
<slot name="skeleton" v-if="isQueryRunning"/>
<div class="text-center q-my-md">
<q-btn color="primary" :loading="isQueryRunning" rounded size="lg" icon="expand_more" @click="loadMore()" v-if="totalItemsCount > itemsLength">
<slot name="loadMoreText"/>
</q-btn>
</div>
</div>
</template>
+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 {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; 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({ const props = defineProps({
defaultValues: { defaultValues: {
type: Object, 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 = { const rules = {
callSign: [isRequired, minLength(4), maxLength(8)], callSign: [isRequired, minLength(4), maxLength(8)],
seats: [isRequired, isNumber] seats: [isRequired, isNumber]
@@ -54,48 +54,48 @@ onMounted(() => {
<q-img :src="props.defaultValues.photoUrl" v-if="props.defaultValues.photoUrl"/> <q-img :src="props.defaultValues.photoUrl" v-if="props.defaultValues.photoUrl"/>
<q-input <q-input
type="text" type="text"
:rules="rules.callSign" :rules="rules.callSign"
v-model="formData.callSign" v-model="formData.callSign"
label="Volací značka" label="Volací značka"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-input <q-input
type="number" type="number"
v-model.number="formData.seats" v-model.number="formData.seats"
label="Počet míst (včetně pilota)" label="Počet míst (včetně pilota)"
min="1" min="1"
:rules="rules.seats" :rules="rules.seats"
v-bind="commonProps" v-bind="commonProps"
/> />
<ComboboxInput <ComboboxInput
:options="organizations" :options="organizations"
label="Organizace" label="Organizace"
v-model="formData.organization" v-model="formData.organization"
v-bind="commonProps" v-bind="commonProps"
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace." hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
/> />
<q-input <q-input
type="text" type="text"
v-model="formData.manufacturer" v-model="formData.manufacturer"
label="Výrobce" label="Výrobce"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-input <q-input
type="text" type="text"
v-model="formData.model" v-model="formData.model"
label="Model" label="Model"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-input <q-input
type="textarea" type="textarea"
v-model="formData.description" v-model="formData.description"
label="Popis" label="Popis"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/> <q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/>
+56 -34
View File
@@ -1,46 +1,68 @@
<script setup> <script setup>
import LoadMoreList from "components/LoadMoreList.vue";
import {useAppStore} from "stores/App"; import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: true required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
} }
}); });
useAppStore().setPageTitle("Letadla");
</script> </script>
<template> <template>
<q-list bordered separator> <LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<q-item <template #items>
clickable <q-list bordered separator>
v-ripple <q-item
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}" clickable
v-for="aircraft of props.items" :key="aircraft" v-ripple
> :to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
<q-item-section avatar> v-for="aircraft of props.items" :key="aircraft"
<q-avatar v-if="aircraft.titlePhoto"> >
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/> <q-item-section avatar>
</q-avatar> <q-avatar v-if="aircraft.titlePhoto">
<q-avatar color="teal" text-color="white" icon="flight" v-else/> <img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
</q-item-section> </q-avatar>
<q-item-section> <q-avatar color="teal" text-color="white" icon="flight" v-else/>
<q-item-label label> </q-item-section>
{{ aircraft.callSign }} <q-item-section>
</q-item-label> <q-item-label label>
<q-item-label caption> {{ aircraft.callSign }}
{{ aircraft.model }} </q-item-label>
<template v-if="aircraft.manufacturer"> <q-item-label caption>
({{ aircraft.manufacturer }}) {{ aircraft.model }}
</template> <template v-if="aircraft.manufacturer">
</q-item-label> ({{ aircraft.manufacturer }})
<q-item-label caption v-if="aircraft.organization"> </template>
Provozovatel: {{ aircraft.organization.name }} </q-item-label>
</q-item-label> <q-item-label caption v-if="aircraft.organization">
</q-item-section> Provozovatel: {{ aircraft.organization.name }}
</q-item-label>
</q-item> </q-item-section>
</q-list> </q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
+27 -5
View File
@@ -5,26 +5,34 @@ import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules"; import {isRequired} from "src/composables/form/rules";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
copilot: { copilot: {
type: Object type: Object
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}) })
const formData = reactive({ const formData = reactive({
name: null, name: null,
urlSlug: "",
isPublic: false,
}); });
const rules = { const rules = {
name: [isRequired] name: [isRequired],
urlSlug: [],
} }
const loggedUser = storeToRefs(useLoggedUserStore()).user;
onMounted(() => { onMounted(() => {
if (props.copilot) { if (props.copilot) {
setDefaultData(formData, props.copilot); setDefaultData(formData, props.copilot);
@@ -41,5 +49,19 @@ onMounted(() => {
:rules="rules.name" :rules="rules.name"
v-model="formData.name" v-model="formData.name"
/> />
<template v-if="loggedUser?.publicUsername">
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser.publicUsername}/a/`"
:slug-source="formData.name"
:rules="rules.urlSlug"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
</FormWrapper> </FormWrapper>
</template> </template>
+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>
+21 -10
View File
@@ -8,20 +8,25 @@ import {isRequired} from "src/composables/form/rules";
import RichTextEditor from "components/inputs/RichTextEditor.vue"; import RichTextEditor from "components/inputs/RichTextEditor.vue";
import DatetimePicker from "components/inputs/DatetimePicker.vue"; import DatetimePicker from "components/inputs/DatetimePicker.vue";
import {FORMAT_DATE, FORMAT_DATETIME_LONG} from "src/composables/datetimeUtils"; import {FORMAT_DATE, FORMAT_DATETIME_LONG} from "src/composables/datetimeUtils";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
event: { event: {
type: Object type: Object,
required: false,
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}) })
const formData = reactive({ const formData = reactive({
name: null, name: "",
urlSlug: "",
isPublic: false, isPublic: false,
description: "", description: "",
dateFrom: null, dateFrom: null,
@@ -38,6 +43,7 @@ onMounted(() => {
} }
}) })
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script> </script>
<template> <template>
@@ -48,7 +54,15 @@ onMounted(() => {
:rules="rules.name" :rules="rules.name"
v-model="formData.name" v-model="formData.name"
/> />
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser?.publicUsername}/udalosti/`"
:slug-source="formData.name"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<div class="row"> <div class="row">
<div class="col q-pr-sm"> <div class="col q-pr-sm">
@@ -59,9 +73,6 @@ onMounted(() => {
</div> </div>
</div> </div>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/> <RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</FormWrapper> </FormWrapper>
</template> </template>
+7 -8
View File
@@ -8,8 +8,7 @@ const props = defineProps({
}, },
routeDetail: { routeDetail: {
type: Object, type: Object,
required: false, required: true,
default: () => ({name: 'eventDetail', params: {}})
}, },
}); });
@@ -19,20 +18,20 @@ const getCoverPhotos = (item) => item.flights.filter(f => !!f.titlePhoto).map(f
<template> <template>
<q-list bordered separator> <q-list bordered separator>
<q-item <q-item
v-for="event of items" :key="event" v-for="eventDetail of items" :key="eventDetail"
clickable clickable
:to="{name: routeDetail.name, params: {...routeDetail.params, id: event.id}}" :to="{name: props.routeDetail.name, params: {...props.routeDetail.params, eventSlug: eventDetail.urlSlug, id: eventDetail.id}}"
v-ripple v-ripple
> >
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
<q-avatar icon="event" color="primary" text-color="white" v-if="!event.flights || event.flights.length === 0"/> <q-avatar icon="event" color="primary" text-color="white" v-if="!eventDetail.flights || eventDetail.flights.length === 0"/>
<AvatarsGroup :avatars="getCoverPhotos(event)" v-else/> <AvatarsGroup :avatars="getCoverPhotos(eventDetail)" v-else/>
{{ event.name }} {{ eventDetail.name }}
</q-item-label> </q-item-label>
<q-item-label lines="4" caption> <q-item-label lines="4" caption>
<article v-html="event.description"/> <article v-html="eventDetail.description"/>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
+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++;
}
+62 -14
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils"; import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
import WeatherInfo from "components/weather/WeatherInfo.vue"; import WeatherInfo from "components/weather/WeatherInfo.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -21,8 +22,17 @@ const props = defineProps({
type: Object, type: Object,
required: false, required: false,
default: null default: null
},
eventDetailRoute: {
type: Object,
required: false,
default: null
},
loggedUser: {
type: Object,
required: false,
default: null
} }
}) })
</script> </script>
@@ -34,8 +44,8 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<router-link <router-link
:to="{...aircraftDetailRoute, params: {...aircraftDetailRoute.params, id: flight.aircraft.id}}" :to="{...aircraftDetailRoute, params: {id: flight.aircraft.id, callSign: flight.aircraft.callSign}}"
v-if="aircraftDetailRoute" v-if="flight.aircraft.isPublic"
> >
{{ flight.aircraft.callSign }} {{ flight.aircraft.callSign }}
</router-link> </router-link>
@@ -62,7 +72,9 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<div> <div>
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}), <template v-if="flight.takeoffAirport">
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}),
</template>
čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }} čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/> <WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/>
@@ -78,8 +90,8 @@ const props = defineProps({
<q-item-section> <q-item-section>
<template v-if="poi.pointOfInterest"> <template v-if="poi.pointOfInterest">
<router-link <router-link
:to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, id: poi.pointOfInterest.id}}" :to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, slug: poi.pointOfInterest.urlSlug, id: poi.pointOfInterest.id}}"
v-if="poi.pointOfInterest.isPublic" v-if="poi.pointOfInterest.isPublic"
> >
{{ poi.pointOfInterest.name }} {{ poi.pointOfInterest.name }}
</router-link> </router-link>
@@ -87,7 +99,11 @@ const props = defineProps({
</template> </template>
<span v-if="poi.airport"> <span v-if="poi.airport">
<strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}), <strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}),
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }} <template v-if="poi.landingDuration > 0">
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }}
</template>
<template v-else>touch and go</template>
</span> </span>
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -98,7 +114,9 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<div> <div>
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}), <template v-if="flight.landingAirport">
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}),
</template>
čas přistání: {{ formatDatetime(flight.landingDatetime, FORMAT_TIME) }} čas přistání: {{ formatDatetime(flight.landingDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/> <WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/>
</div> </div>
@@ -115,6 +133,18 @@ const props = defineProps({
{{ minutesToHumanReadable(flight.durationMinCalculated) }} {{ minutesToHumanReadable(flight.durationMinCalculated) }}
</q-item-section> </q-item-section>
</q-item> </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-item v-if="flight.copilots?.length > 0">
<q-list> <q-list>
<q-item v-for="copilot in flight.copilots" :key="copilot.id"> <q-item v-for="copilot in flight.copilots" :key="copilot.id">
@@ -122,14 +152,32 @@ const props = defineProps({
<q-icon name="people"/> <q-icon name="people"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<router-link <template v-if="copilot.isPublic">
v-if="copilotDetailRoute" <div>
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, id: copilot.id}}" <router-link
> :to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
{{ copilot.name }} >
</router-link> {{ copilot.name }}
</router-link>
</div>
</template>
<template v-else> {{ copilot.name }}</template> <template v-else> {{ copilot.name }}</template>
</q-item-section> </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-item>
</q-list> </q-list>
</q-item> </q-item>
+16 -12
View File
@@ -20,7 +20,7 @@ const poisWithGPS = computed(() => props.flight.track.map(poi => poi.pointOfInte
const mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]); const mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]);
const pointsOfInterests = computed(() => mapMarkers const pointsOfInterests = computed(() => mapMarkers
.filter(marker => marker.gpsLatitude && marker.gpsLongitude) .filter(marker => marker && marker.gpsLatitude && marker.gpsLongitude)
.map( .map(
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}}) marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
)); ));
@@ -101,16 +101,20 @@ const chartOptions = {
</script> </script>
<template> <template>
<MapWithMarkers <q-no-ssr>
v-if="displayMap" <q-intersection>
style="width: 100%; height: 500px;" <MapWithMarkers
:gpx-track="flight.gpxTrack?.coordinates || []" v-if="displayMap"
:markers="pointsOfInterests" style="width: 100%; height: 500px;"
:highlight-position="currentPositionMarker" :gpx-track="flight.gpxTrack?.coordinates || []"
:photos="mapPhotos" :markers="pointsOfInterests"
/> :highlight-position="currentPositionMarker"
:photos="mapPhotos"
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/> v-bind="$attrs"
<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> </template>
+81 -41
View File
@@ -2,6 +2,10 @@
import {minutesToHumanReadable} from "src/composables/datetimeUtils"; import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {flightName} from "src/composables/flight"; import {flightName} from "src/composables/flight";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue"; 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({ const props = defineProps({
items: { items: {
@@ -12,60 +16,96 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true required: true
}, },
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
}); });
const routeDetail = (flight) => { const routeDetail = (flight) => {
if (props.public) if (props.public)
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, id: flight.id}} return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
else else
return {name: "flightDetail", params: {id: flight.id}} return {name: "flightDetail", params: {id: flight.id}}
}; };
const loadMoreCount = computed(() => {
return Math.min(props.itemsPerPage, props.totalItemsCount - props.items.length);
});
</script> </script>
<template> <template>
<q-list bordered separator> <LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<q-item <template #items>
v-for="flight of items" :key="flight" <q-list bordered separator>
ripple <q-item
clickable v-for="flight of items" :key="flight"
:to="routeDetail(flight)" ripple
> clickable
<q-item-section avatar> :to="routeDetail(flight)"
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/> >
</q-item-section> <q-item-section avatar>
<q-item-section> <AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div> <q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
</q-item-section>
<q-item-section>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<div class="text-secondary" v-if="props.public"> <div class="text-secondary" v-if="props.public">
<q-icon name="support_agent"/> <q-icon name="support_agent"/>
@{{flight.pilot.publicUsername}} @{{ flight.pilot.publicUsername }}
</div> </div>
<div class="text-secondary" v-if="flight.copilot"> <div class="text-secondary" v-if="flight.copilots">
<q-icon name="person"/> <q-icon name="person"/>
{{ flight.pilot.name }} {{ flight.pilot.name }}
{{ flight.copilot.name }} {{ flight.copilots }}
</div> </div>
<div class="text-secondary"> <div class="text-secondary">
<q-icon name="schedule"/> <q-icon name="schedule"/>
{{ minutesToHumanReadable(flight.durationMinCalculated) }} {{ minutesToHumanReadable(flight.durationMinCalculated) }}
</div> </div>
<div class="text-secondary"> <div class="text-secondary">
<q-icon name="flight"/> <q-icon name="flight"/>
{{ flight.aircraft.callSign }} {{ flight.aircraft.callSign }}
</div> </div>
<div class="text-secondary" v-if="flight.event"> <div class="text-secondary" v-if="flight.event">
<q-icon name="event"/> <q-icon name="event"/>
{{ flight.event.name }} {{ flight.event.name }}
</div> </div>
</q-item-section> </q-item-section>
<q-item-section side top v-if="!props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility" v-if="flight.isPublic"/>
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
<q-tooltip>
Let je sice publikovaný, nemá ale nastavenou URL adresu!
</q-tooltip>
</q-icon>
</q-item-section>
</q-item>
</q-list>
</template>
<template #skeleton>
<FlightsListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Načíst {{ pluralize(loadMoreCount, 'jeden let', `další ${loadMoreCount} lety`, `dalších ${loadMoreCount} letů`) }}
</template>
</LoadMoreList>
<q-item-section side top v-if="flight.isPublic && !props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
</q-list>
</template> </template>
+75 -68
View File
@@ -21,10 +21,10 @@ const props = defineProps({
required: false required: false
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}); });
const airportName = (airport) => `${airport.icaoCode} (${airport.name})`; const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
@@ -32,8 +32,13 @@ const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
const airports = computed(() => props.airports.map(a => ({id: a.id, name: airportName(a)}))); const airports = computed(() => props.airports.map(a => ({id: a.id, name: airportName(a)})));
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign}))); const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
const manualInput = ref(false);
const allowGPXUpload = ref(true);
const formData = reactive({ const formData = reactive({
aircraft: null, aircraft: null,
gpxTrackFile: null,
takeoffAirport: null, takeoffAirport: null,
landingAirport: null, landingAirport: null,
takeoffDatetime: null, takeoffDatetime: null,
@@ -52,8 +57,14 @@ watch(() => props.flight, (newVal) => {
if (newVal) { if (newVal) {
setDefaultData(formData, newVal); setDefaultData(formData, newVal);
formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign}; formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign};
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)} if (newVal.takeoffAirport)
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
if (newVal.landingAirport)
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)}
allowGPXUpload.value = false;
manualInput.value = true;
} }
}, {immediate: true}); }, {immediate: true});
watch(() => formData.takeoffDatetime, () => { watch(() => formData.takeoffDatetime, () => {
@@ -67,79 +78,75 @@ watch(() => formData.takeoffAirport, () => {
formData.landingAirport = {...formData.takeoffAirport}; formData.landingAirport = {...formData.takeoffAirport};
} }
}); });
</script> </script>
<template> <template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending"> <FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<q-card class="q-my-sm"> <ComboboxInput
<q-card-section> :rules="rules.aircraft"
<div class="text-h6">Vyber letadlo</div> v-bind="commonProps"
</q-card-section> :options="aircrafts"
v-model="formData.aircraft"
<q-card-section> label="Letadlo"
<ComboboxInput />
:rules="rules.aircraft"
v-bind="commonProps"
:options="aircrafts"
v-model="formData.aircraft"
label="Letadlo"
/>
</q-card-section>
</q-card>
</div> </div>
</div> </div>
<div class="row">
<div class="col col-12 col-md-6">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<div class="text-h6">Vzlet</div>
</q-card-section>
<q-card-section>
<DatetimePicker
v-model="formData.takeoffDatetime"
:rules="rules.takeoffDatetime"
label="Datum a čas vzletu: "
v-bind="commonProps"
/>
<ComboboxInput <div class="row" v-if="!manualInput">
:options="airports" <div class="col">
:rules="rules.takeoffAirport" <div class="text-h6">Mám trasu v GPX, chci importovat data</div>
v-model="formData.takeoffAirport" <q-file v-bind="commonProps" v-model="formData.gpxTrackFile" label="Trasa v GPX" hint="Z trasy se načtou letiště a časy vzletu a přistání. Tyto hodnoty je možné později upravit."/>
label="Letiště vzletu"
v-bind="commonProps"
/>
</q-card-section>
</q-card>
</div>
<div class="col col-12 col-md-6"> <div class="text-center">
<q-btn @click="manualInput = true" rounded flat>Zadat informace ručně</q-btn>
<q-card class="q-ml-md-sm q-my-sm"> </div>
<q-card-section>
<div class="text-h6">Přistání</div>
</q-card-section>
<q-card-section>
<DatetimePicker
v-model="formData.landingDatetime"
:min-date="formData.takeoffDatetime"
:rules="rules.landingDatetime"
label="Datum a čas přistání"
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
v-model="formData.landingAirport"
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
/>
</q-card-section>
</q-card>
</div> </div>
</div> </div>
<div class="row" v-if="manualInput">
<div class="col col-12 col-md-6 q-pr-md-sm">
<div class="text-h6">Vzlet</div>
<DatetimePicker
v-model="formData.takeoffDatetime"
:rules="rules.takeoffDatetime"
label="Datum a čas vzletu: "
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
:rules="rules.takeoffAirport"
v-model="formData.takeoffAirport"
label="Letiště vzletu"
v-bind="commonProps"
/>
</div>
<div class="col col-12 col-md-6 q-pl-md-sm">
<div class="text-h6">Přistání</div>
<DatetimePicker
v-model="formData.landingDatetime"
:min-date="formData.takeoffDatetime"
:rules="rules.landingDatetime"
label="Datum a čas přistání"
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
v-model="formData.landingAirport"
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
/>
</div>
<div class="col-12 text-center" v-if="allowGPXUpload">
<q-btn @click="manualInput = false" rounded flat>Mám GPX, načíst informace z trasy</q-btn>
</div>
</div>
</FormWrapper> </FormWrapper>
</template> </template>
@@ -1,10 +1,13 @@
<script setup> <script setup>
import {reactive, watch} from "vue"; import {reactive, ref, watch} from "vue";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling"; import {setDefaultData} from "src/composables/form/formHandling";
import RichTextEditor from "components/inputs/RichTextEditor.vue"; import RichTextEditor from "components/inputs/RichTextEditor.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -16,18 +19,24 @@ const props = defineProps({
required: false, required: false,
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}); });
const formData = reactive({ const formData = reactive({
name: "", name: "",
event: null, event: null,
description: "" description: "",
debriefingNotes: "",
urlSlug: "",
isPublic: null
}) })
const loggedUser = storeToRefs(useLoggedUserStore()).user;
watch(() => props.flight, (newVal) => { watch(() => props.flight, (newVal) => {
if (newVal) { if (newVal) {
setDefaultData(formData, newVal); setDefaultData(formData, newVal);
@@ -38,12 +47,40 @@ watch(() => props.flight, (newVal) => {
} }
}, {immediate: true}); }, {immediate: true});
const descriptionTab = ref("description");
</script> </script>
<template> <template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending"> <FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/> <ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/>
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/> <q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/> <template v-if="loggedUser?.publicUsername">
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser.publicUsername}/`"
:slug-source="formData.name"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
<q-tabs v-model="descriptionTab" active-color="primary">
<q-tab name="description">Popis</q-tab>
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
</q-tabs>
<q-tab-panels v-model="descriptionTab">
<q-tab-panel name="description">
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
</q-tab-panel>
<q-tab-panel name="debriefing">
<RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/>
</q-tab-panel>
</q-tab-panels>
</FormWrapper> </FormWrapper>
</template> </template>
@@ -1,8 +1,6 @@
<script setup> <script setup>
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {reactive} from "vue"; import {reactive} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {roundWithPrecision} from "../../../composables/utils";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -3,6 +3,7 @@ import FormWrapper from "components/FormWrapper.vue";
import {reactive, ref, watch} from "vue"; import {reactive, ref, watch} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -24,7 +25,7 @@ const props = defineProps({
const formData = reactive({ const formData = reactive({
track: [], track: [],
gpxTrack: null gpxTrackFile: null
}); });
const removeTrackItem = (index) => { const removeTrackItem = (index) => {
formData.track.splice(index, 1); formData.track.splice(index, 1);
@@ -51,6 +52,7 @@ watch(poi, (newVal) => {
}); });
watch(() => props.flight.track, (newVal) => { watch(() => props.flight.track, (newVal) => {
formData.track = [];
for (const i in newVal) { for (const i in newVal) {
const trackInfo = newVal[i]; const trackInfo = newVal[i];
const info = {landingDuration: trackInfo.landingDuration}; const info = {landingDuration: trackInfo.landingDuration};
@@ -81,7 +83,7 @@ watch(() => props.flight.track, (newVal) => {
<q-file <q-file
accept=".gpx" accept=".gpx"
v-model="formData.gpxTrack" v-model="formData.gpxTrackFile"
v-bind="commonProps" v-bind="commonProps"
label="Nahrát trasu v GPX" label="Nahrát trasu v GPX"
hint="Slouží pro vykreslení kompletní a detailní trasy" hint="Slouží pro vykreslení kompletní a detailní trasy"
@@ -91,6 +93,8 @@ watch(() => props.flight.track, (newVal) => {
</template> </template>
</q-file> </q-file>
<h5>Body na mapě</h5>
<ComboboxInput <ComboboxInput
v-bind="commonProps" v-bind="commonProps"
v-model="poi" v-model="poi"
@@ -103,7 +107,7 @@ watch(() => props.flight.track, (newVal) => {
/> />
<div class="row"> <div class="row">
<div class="col"> <div class="col-12 col-md-8">
<ComboboxInput <ComboboxInput
v-bind="commonProps" v-bind="commonProps"
v-model="airport" v-model="airport"
@@ -111,16 +115,16 @@ watch(() => props.flight.track, (newVal) => {
:options="airports" :options="airports"
clearable clearable
label="Letiště pro mezipřistání" label="Letiště pro mezipřistání"
hint="Nastav letiště a dobu mezispřistání" hint="Nastav letiště a dobu mezipřistání"
/> />
</div> </div>
<div class="col" v-if="airport?.name"> <div class="col-md-4 col-12" v-if="airport?.name">
<q-input <q-input
type="number" type="number"
v-bind="commonProps" v-bind="commonProps"
v-model="landingDuration" v-model="landingDuration"
label="Délka mezipřistání (minuty)" label="Délka mezipřistání (minuty)"
class="q-mx-md" class="q-mx-md-md"
> >
<template #after> <template #after>
<q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn> <q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn>
@@ -136,7 +140,7 @@ watch(() => props.flight.track, (newVal) => {
<q-avatar color="primary" text-color="white" icon="flight_takeoff"/> <q-avatar color="primary" text-color="white" icon="flight_takeoff"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ flight.takeoffAirport.name }} {{ flight.takeoffAirport?.name || '???' }}
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -153,7 +157,11 @@ watch(() => props.flight.track, (newVal) => {
</q-badge> </q-badge>
</span> </span>
<span v-if="item.airport"> <span v-if="item.airport">
{{ item.airport.name }}, mezipřistání: {{ item.landingDuration }} minut {{ item.airport.name }},
<template v-if="item.landingDuration > 0">
mezipřistání {{ minutesToHumanReadable(item.landingDuration) }}
</template>
<template v-else>touch and go</template>
</span> </span>
</q-item-section> </q-item-section>
@@ -167,7 +175,7 @@ watch(() => props.flight.track, (newVal) => {
<q-avatar color="primary" text-color="white" icon="flight_land"/> <q-avatar color="primary" text-color="white" icon="flight_land"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ flight.landingAirport.name }} {{ flight.landingAirport?.name || '???' }}
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -1,10 +1,18 @@
<script setup> <script setup>
import {randomInt} from "src/composables/random"; import {randomInt} from "src/composables/random";
const props = defineProps({
items: {
type: Number,
required: false,
default: 10,
}
})
</script> </script>
<template> <template>
<q-list bordered separator> <q-list bordered separator>
<q-item v-for="i in 10" :key="i" ripple> <q-item v-for="i in items" :key="i" ripple>
<q-item-section avatar> <q-item-section avatar>
<q-avatar size="100px"> <q-avatar size="100px">
<q-skeleton type="QAvatar" size="100px"></q-skeleton> <q-skeleton type="QAvatar" size="100px"></q-skeleton>
@@ -12,9 +20,9 @@ import {randomInt} from "src/composables/random";
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-skeleton :width="randomInt(30,50)+'%'"/> <q-skeleton :width="randomInt(30,50)+'%'"/>
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/> <q-skeleton type="text" :width="randomInt(10,15)+'%'"/>
<q-skeleton type="text" :width="randomInt(5,10)+'%'"/> <q-skeleton type="text" :width="randomInt(5,10)+'%'"/>
<q-skeleton type="text" width="8%"/> <q-skeleton type="text" width="8%"/>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -0,0 +1,33 @@
<script setup>
import {flightName} from "src/composables/flight";
const props = defineProps({
flight: {
type: Object,
required: true
},
textColor: {
type: String,
required: false,
default: "white"
}
})
</script>
<template>
<div class="text-h4">
<router-link
:class="`text-${props.textColor}`"
:to="{name: 'publicFlightDetail', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
>
{{ flightName(flight) }}
</router-link>
</div>
<router-link
:class="`text-${props.textColor}`"
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
>
@{{ flight.pilot.publicUsername }}
</router-link>
</template>
@@ -0,0 +1,48 @@
<script setup>
import {ref} from "vue";
import {flightName} from "src/composables/flight";
import LatestFlightSliderInfo from "components/flights/slider/LatestFlightSliderInfo.vue";
const props = defineProps({
items: {
type: Array,
required: true
}
});
const slide = ref(0);
</script>
<template>
<div class="bg-blue-1 q-py-lg">
<h3 class="text-center">Poslední lety</h3>
<q-carousel
v-model="slide"
swipeable
animated
control-color="primary"
navigation
padding
arrows
height="500px"
class="bg-blue-1 text-white"
>
<q-carousel-slide :name="idx" v-for="(flight, idx) in items" :key="idx" class="column no-wrap flex-center text-center">
<q-img
:src="flight.titlePhoto.url"
fit="cover"
ratio="16/9"
style="max-width: 1000px;"
height="400px"
v-if="flight.titlePhoto"
>
<div class="absolute-bottom">
<LatestFlightSliderInfo :flight="flight"/>
</div>
</q-img>
<LatestFlightSliderInfo v-else :flight="flight" text-color="primary"/>
</q-carousel-slide>
</q-carousel>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup>
</script>
<template>
<div class="row q-mx-md">
<div class="col-12 col-md-6 col-lg-6 self-center info-box">
<div class="text-h3 text-center q-my-lg">Fotogalerie ze vzduchu</div>
<p>Polétání.cz je aplikace pro piloty, kteří si chtějí spravovat fotografie ze svých výletů, zobrazovat je na mapě a ukládat si trasy svých výletů.</p>
<div class="text-center q-my-lg">
<q-btn size="xl" :to="{name: 'registration'}" rounded color="secondary">Založit účet</q-btn>
</div>
</div>
<div class="col-12 col-md-6 col-lg-6 text-center">
<div>
<q-img src="hp/mobile-preview-200.webp" height="401px" width="200px" class="q-ma-lg" fit="contain" alt="Nahled aplikace"/>
<q-img src="hp/desktop-preview-350.webp" height="310px" width="350px" fit="contain" class="xs-hide sm-hide" alt="Nahled aplikace"/>
</div>
</div>
</div>
</template>
<style>
.info-box {
font-size: 150%;
}
</style>
@@ -0,0 +1,37 @@
<script setup>
</script>
<template>
<q-timeline color="primary" class="q-my-lg">
<q-timeline-entry title="Zaznamenej si let">
<div>
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
</div>
</q-timeline-entry>
<q-timeline-entry title="Ulož trasu">
<div>
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
</div>
</q-timeline-entry>
<q-timeline-entry title="Vyber a nahraj fotky">
<div>A pokud mají v sobě uloženou informaci o poloze, p
Máš nějaké fotky z letu? Nahraj je ke svému letu! Polétání.cz ti zobrazí i místo kde fotka vznikla.
</div>
</q-timeline-entry>
<q-timeline-entry title="Popiš zážitek">
<div>
Určitě se po ceste stalo něco vzrušujícího. Proč o tom nenapsat pár vět?
</div>
</q-timeline-entry>
<q-timeline-entry title="Sdílej">
<div>
Létat v sóle je super, ale ještě lepší je se o zážitek podělit. Jedním kliknutím můžeš svůj let zveřejnit a
sdílet odkaz kde chceš. Třeba s tvým kopilotem.
</div>
</q-timeline-entry>
</q-timeline>
</template>
+1 -1
View File
@@ -100,7 +100,7 @@ const filterFn = (val, update, abort) => {
clear-icon="clear" clear-icon="clear"
:v-bind="$attrs" :v-bind="$attrs"
> >
<template v-slot:no-option> <template #no-option>
<q-item v-if="inputValue"> <q-item v-if="inputValue">
<q-item-section avatar> <q-item-section avatar>
<q-avatar text-color="green" size="32px" icon="add"/> <q-avatar text-color="green" size="32px" icon="add"/>
+43
View File
@@ -0,0 +1,43 @@
<script setup>
import {copyToClipboard, useQuasar} from "quasar";
import {successObject} from "src/composables/dialog";
import {useRouter} from "vue-router";
const props = defineProps({
route: {
type: Object,
required: true
},
icon: {
type: String,
required: false,
default: "link"
},
label: {
type: String,
required: false,
default: "Získat odkaz"
}
})
const $router = useRouter();
const $q = useQuasar();
const copyLinkToClipboard = () => {
const url = $router.resolve(props.route);
copyToClipboard(window.location.origin + url.href).then(() => {
$q.notify(successObject("Odkaz zkopírován"))
});
};
</script>
<template>
<q-btn
:icon="icon"
:label="label"
v-bind="$attrs"
flat
@click="copyLinkToClipboard()"
/>
</template>
+24 -24
View File
@@ -1,14 +1,9 @@
<script setup> <script setup>
import {Marker} from "vue3-google-map"; import {Marker} from "vue3-google-map";
import {onMounted, ref} from "vue"; import {onMounted, ref} from "vue";
import axios from "axios";
import MapWrapper from "components/map/MapWrapper.vue"; import MapWrapper from "components/map/MapWrapper.vue";
import {useGeocode} from "src/composables/geocoding";
const center = ref({ import axios from "axios";
lat: 50.1314,
lng: 14.5256
});
const zoom = ref(10);
const props = defineProps({ const props = defineProps({
gpsLatitude: { gpsLatitude: {
@@ -24,35 +19,30 @@ const props = defineProps({
required: false required: false
} }
}); });
const emits = defineEmits(["update:gpsLatitude", "update:gpsLongitude", "notFound"]); const emits = defineEmits(["update:gpsLatitude", "update:gpsLongitude", "notFound"]);
const emitModels = (lat, lng) => { const emitModels = (lat, lng) => {
emits("update:gpsLatitude", lat); emits("update:gpsLatitude", lat);
emits("update:gpsLongitude", lng); emits("update:gpsLongitude", lng);
} }
const {searchGeocode, center} = useGeocode(emits);
const markerDropped = (position) => { const markerDropped = (position) => {
emitModels(position.latLng.lat(), position.latLng.lng()); emitModels(position.latLng.lat(), position.latLng.lng());
} }
const setCoordinatesFromGeocode = (address) => { const zoom = ref(8);
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;
});
}
onMounted(() => { onMounted(() => {
if (props.gpsLatitude && props.gpsLongitude) { if (props.gpsLatitude && props.gpsLongitude) {
center.value.lng = props.gpsLongitude; center.value.lng = props.gpsLongitude;
center.value.lat = props.gpsLatitude center.value.lat = props.gpsLatitude
} else if (props.label) { } 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 { } else {
console.error("Set either label or gps coordinates."); console.error("Set either label or gps coordinates.");
} }
@@ -61,7 +51,17 @@ onMounted(() => {
</script> </script>
<template> <template>
<MapWrapper style="width: 100%; height: 500px" :center="center" :zoom="zoom"> <MapWrapper
<Marker :options="{ position: center, title: label, draggable: true }" @dragend="markerDropped"/> :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> </MapWrapper>
</template> </template>
+64
View File
@@ -0,0 +1,64 @@
<script setup>
import {useQuasar} from "quasar";
import {ref, watch} from "vue";
import {slugify} from "src/composables/slugify";
const props = defineProps({
requireUrlSlug: {
type: Boolean,
required: true
},
isPublic: {
type: Boolean,
required: true
},
urlPrefix: {
type: String,
required: true
}
});
const emits = defineEmits(["changeVisibility"]);
const $q = useQuasar();
const urlSlugModel = ref("");
watch(() => urlSlugModel.value, (newVal) => {
const slug = slugify(newVal);
if (urlSlugModel.value !== slug)
urlSlugModel.value = slug;
})
const inputPrompt = () =>
$q.dialog({
title: 'URL',
message: props.urlPrefix+"...",
prompt: {
model: urlSlugModel,
placeholder: "URL fragment",
isValid: val => val.length > 1,
type: 'text' // optional
},
cancel: true,
persistent: true
})
const togglePublicVisibility = () => {
if (props.requireUrlSlug && !props.isPublic) {
inputPrompt().onOk((data) => {
emits("changeVisibility", {isPublic: !props.isPublic, urlSlug: slugify(urlSlugModel.value)});
})
} else {
emits("changeVisibility", {isPublic: !props.isPublic});
}
}
</script>
<template>
<q-btn
@click="togglePublicVisibility"
:icon="isPublic ? 'visibility_off' : 'visibility'"
flat
:label="isPublic ? 'Skrýt' : 'Zveřejnit'"
/>
</template>
+8 -1
View File
@@ -56,5 +56,12 @@ const toolbar = [
</script> </script>
<template> <template>
<q-editor v-model="content" :definitions="definitions" :toolbar="toolbar" :height="height" :label="label" :rules="rules"/> <q-editor
v-model="content"
:definitions="definitions"
:toolbar="toolbar"
:height="height"
v-bind="$attrs"
:rules="rules"
/>
</template> </template>
+62
View File
@@ -0,0 +1,62 @@
<script setup>
import {ref, watch} from "vue";
import {slugify} from "src/composables/slugify";
const props = defineProps({
modelValue: {
type: String
},
slugSource: {
type: String,
required: false,
},
url : {
type: String,
required: false,
default: null
},
label: {
type: String,
required: false,
default: "URL"
},
rules: {
type: Array,
required: false,
default: () => ([])
}
});
const emit = defineEmits(['update:modelValue']);
const model = ref("");
const emitValue = (val) => {
emit("update:modelValue", val);
}
watch(() => model.value, (newVal) => {
model.value = slugify(newVal);
emitValue(newVal);
})
watch(() => props.slugSource, (newVal, oldVal) => {
if (model.value === "" || slugify(oldVal) === model.value) {
// pokud puvodni vychozi hodnota je stejna jako aktualne nastaveny slug, zmenime model
model.value = slugify(newVal);
}
}, {immediate: true});
</script>
<template>
<q-input
v-model="model"
:label="label"
:rules="rules"
hint="Veřejná URL adresa"
>
<template #prepend>
{{ url }}
</template>
</q-input>
</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> <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>
@@ -44,10 +85,15 @@ const bounds = computed(() => {
</Marker> </Marker>
<template v-if="gpxTrack && gpxTrack.length > 0"> <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> </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>
</template> </template>
+216 -11
View File
@@ -1,37 +1,242 @@
<script setup> <script setup>
import {GoogleMap} from "vue3-google-map"; import {GoogleMap, CustomControl, CustomMarker, Polygon} from "vue3-google-map";
import {ref, watch} from "vue"; 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({ const props = defineProps({
bounds: { bounds: {
type: Array, type: Array,
required: false 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 key = process.env.GOOGLE_MAPS_API_KEY;
const zoomLevel = ref(null);
const mapRef = ref(null); const mapRef = ref(null);
watch( watch(
() => mapRef.value?.ready, () => mapRef.value?.ready,
(ready) => { (ready) => {
if (!props.bounds || !ready) return; if (!ready) return;
const map = mapRef.value.map;
const api = mapRef.value.api;
const bounds = new api.LatLngBounds(); if (props.bounds) {
for (let i = 0; i < props.bounds.length; i++) { const bounds = new mapRef.value.api.LatLngBounds();
bounds.extend(props.bounds[i]); 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> </script>
<template> <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/> <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> </GoogleMap>
</template> </template>
@@ -6,8 +6,8 @@ import {randomInt} from "src/composables/random";
<template> <template>
<q-breadcrumbs class="q-mx-md q-my-md"> <q-breadcrumbs class="q-mx-md q-my-md">
<q-breadcrumbs-el> <q-skeleton :width="`${randomInt(30, 60)}px`" type="rect" /> </q-breadcrumbs-el> <q-breadcrumbs-el> <q-skeleton :width="`${randomInt(100, 190)}px`" type="rect" /> </q-breadcrumbs-el>
<q-breadcrumbs-el> <q-skeleton :width="`${randomInt(40, 80)}px`" type="rect" /> </q-breadcrumbs-el> <q-breadcrumbs-el> <q-skeleton :width="`${randomInt(200, 280)}px`" type="rect" /> </q-breadcrumbs-el>
</q-breadcrumbs> </q-breadcrumbs>
+18 -23
View File
@@ -9,12 +9,12 @@
v-if="icon" v-if="icon"
avatar avatar
> >
<q-icon :name="icon" /> <q-icon :name="icon"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ title }}</q-item-label> <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-section>
</q-item> </q-item>
</template> </template>
@@ -24,26 +24,21 @@ import {computed} from "vue";
import {useRoute, useRouter} from "vue-router"; import {useRoute, useRouter} from "vue-router";
const props = defineProps({ const props = defineProps({
title: { title: {
type: String, type: String,
required: true required: true
}, },
to: {
caption: { type: Object,
type: String, required: true
default: '' },
}, caption: {
type: String,
to: { },
type: Object, icon: {
required: true type: String,
}, }
});
icon: {
type: String,
default: ''
}
});
const $route = useRoute(); const $route = useRoute();
const $router = useRouter(); const $router = useRouter();
@@ -51,7 +46,7 @@ const isButtonActive = computed(() => {
const btnResolvedPath = $router.resolve(props.to).path; const btnResolvedPath = $router.resolve(props.to).path;
if (props.to.name === 'myFlights') { if (props.to.name === 'myFlights') {
return $route.name.toLowerCase().includes("flight"); return ["myFlights", "addFlight", "flightDetail", "flightEdit"].includes($route.name);
} else { } else {
return $route.fullPath === btnResolvedPath || $route.fullPath.startsWith(btnResolvedPath); return $route.fullPath === btnResolvedPath || $route.fullPath.startsWith(btnResolvedPath);
} }
@@ -6,9 +6,15 @@ const linksList = [
{ {
title: 'Moje lety', title: 'Moje lety',
icon: "flight_takeoff", 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', 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 PhotoCaption from "components/photos/PhotoCaption.vue";
import PhotoSwipeLightbox from "../../../node_modules/photoswipe/dist/photoswipe-lightbox.esm" import PhotoSwipeLightbox from "../../../node_modules/photoswipe/dist/photoswipe-lightbox.esm"
import PhotoSwipe from "../../../node_modules/photoswipe/dist/photoswipe.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 {usePhotosStore} from "stores/Photos";
import {useQuasar} from "quasar";
const props = defineProps({ const props = defineProps({
galleryId: { galleryId: {
@@ -21,9 +22,8 @@ const props = defineProps({
} }
}) })
const route = useRoute();
const $router = useRouter(); const $router = useRouter();
const $q = useQuasar();
let $lightbox = ref(null); let $lightbox = ref(null);
@@ -46,18 +46,15 @@ const initLightbox = () => {
$lightbox.value.init(); $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(() => { onMounted(() => {
initLightbox(); isCollapsed.value = props.photos.length >= photosCollapseLimit.value;
// 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();
};
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -69,38 +66,85 @@ onUnmounted(() => {
const openedPhoto = computed(() => usePhotosStore().openedPhoto); const openedPhoto = computed(() => usePhotosStore().openedPhoto);
watch(() => [openedPhoto.value, $lightbox.value], () => { 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); const idx = props.photos.findIndex(p => p.id === openedPhoto.value.id);
if (idx >= 0) { if (idx >= 0) {
$lightbox.value.loadAndOpen(idx); $lightbox.value.loadAndOpen(idx);
usePhotosStore().setOpenedPhoto(null); usePhotosStore().setOpenedPhoto(null);
} }
}, {immediate: true}) }, {immediate: true});
</script> </script>
<template> <template>
<div :id="galleryId" class="row"> <div :class="{'photogallery--wrapper': true, collapsed: isCollapsed}" :id="`${galleryId}-wrapper`">
<div class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" v-for="photo in photos" :key="photo.id"> <div :id="galleryId" class="row photogallery--content">
<a <div
:href="photo.url" v-for="photo in photos" :key="photo.id"
:data-pswp-width="photo.width" style="min-height: 100px"
:data-pswp-height="photo.height" class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm"
target="_blank"
rel="noreferrer"
> >
<q-img <a
spinner-color="primary" :data-pswp-src="photo.url"
class="rounded-borders" :data-pswp-width="photo.width"
:src="photo.thumbnailUrl" :data-pswp-height="photo.height"
alt="" target="_blank"
@click="initLightbox"
rel="noreferrer"
> >
<div class="absolute-bottom" v-if="photo.name !== ''"> <q-img
{{ photo.name }} spinner-color="primary"
</div> class="rounded-borders"
</q-img> :src="photo.thumbnailUrl"
<PhotoCaption :photo="photo"/> alt=""
</a> >
<div class="absolute-bottom" v-if="photo.name !== ''">
{{ photo.name }}
</div>
</q-img>
<PhotoCaption :photo="photo"/>
</a>
</div>
</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> </div>
</template> </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>
+14 -90
View File
@@ -2,6 +2,7 @@
import {computed, onMounted, reactive, ref, watch} from "vue"; import {computed, onMounted, reactive, ref, watch} from "vue";
import {useQuasar} from "quasar"; import {useQuasar} from "quasar";
import {confirmObject} from "src/composables/dialog"; import {confirmObject} from "src/composables/dialog";
import {usePhotoBatchManagement} from "src/composables/photos/batch";
const props = defineProps({ const props = defineProps({
photos: { photos: {
@@ -19,100 +20,23 @@ const props = defineProps({
const $q = useQuasar(); 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 emits = defineEmits(["edit", "delete", "set-title-photo", "batch-edit", "batch-rotate"])
const openEdit = (photo) => { const openEdit = (photo) => {
emits("edit", photo); emits("edit", photo);
}; };
const confirmPhotoDelete = (id) => { const confirmPhotoDelete = (id) => {
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?")) $q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
.onOk(() => { .onOk(() => {
emits("delete", id); emits("delete", id);
}); });
} }
const confirmBatchPhotoDelete = () => { const {
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?")) openBatchEdit, batchEdit, handleImageClick, confirmBatchPhotoDelete, batchOptionsProps,
.onOk(() => { isBatchEditAllowed, batchRotate, checkAll
for (const id in batchEdit.value) { } = usePhotoBatchManagement({photos: props.photos, emits});
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 setFlightCover = (titlePhoto) => { const setFlightCover = (titlePhoto) => {
emits("set-title-photo", titlePhoto); emits("set-title-photo", titlePhoto);
@@ -127,7 +51,7 @@ const showLarge = (displayedPhoto) => {
</script> </script>
<template> <template>
<div class="row" style="margin-bottom: 150px"> <div class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id"> <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-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-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)" style="cursor: pointer">
@@ -136,10 +60,10 @@ const showLarge = (displayedPhoto) => {
<q-btn icon="fullscreen" color="grey-6" round @click.stop.prevent="showLarge(photo)"/> <q-btn icon="fullscreen" color="grey-6" round @click.stop.prevent="showLarge(photo)"/>
</div> </div>
<q-btn <q-btn
icon="star" icon="star"
:color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'" :color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'"
round round
@click.stop.prevent="setFlightCover(photo)" @click.stop.prevent="setFlightCover(photo)"
/> />
</div> </div>
@@ -183,7 +107,7 @@ const showLarge = (displayedPhoto) => {
<q-dialog v-model="openPhoto" full-width> <q-dialog v-model="openPhoto" full-width>
<q-img :src="photo.url"> <q-img :src="photo.url">
<div class="absolute-top bg-transparent text-right"> <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> </div>
</q-img> </q-img>
</q-dialog> </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,7 +1,17 @@
<script setup> <script setup>
const props = defineProps({
photos: {
type: Number,
required: false,
default: 9
}
});
</script> </script>
<template> <template>
gallery skeleton <div class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" v-for="i in props.photos" :key="i">
<q-skeleton type="rect" width="100%" height="200px" />
</div>
</div>
</template> </template>
+20 -6
View File
@@ -8,6 +8,8 @@ import {errorObject} from "src/composables/dialog";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules"; import {isRequired} from "src/composables/form/rules";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {useRouter} from "vue-router";
import SlugInput from "components/inputs/SlugInput.vue";
const props = defineProps({ const props = defineProps({
poi: { poi: {
@@ -23,7 +25,8 @@ const props = defineProps({
}) })
const formData = reactive({ const formData = reactive({
name: null, name: "",
urlSlug: "",
description: "", description: "",
gpsLatitude: null, gpsLatitude: null,
gpsLongitude: null, gpsLongitude: null,
@@ -47,7 +50,8 @@ const resetMap = () => {
} }
const rules = { const rules = {
name: [isRequired] name: [isRequired],
urlSlug: []
} }
const zoom = ref(10); const zoom = ref(10);
@@ -72,6 +76,18 @@ const handlePositionNotFound = () => {
</template> </template>
</q-input> </q-input>
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
url="/mista/"
:slug-source="formData.name"
:rules="rules.urlSlug"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<MapGPSInput <MapGPSInput
v-if="poiNameForMap" v-if="poiNameForMap"
:key="poiNameForMap" :key="poiNameForMap"
@@ -82,10 +98,8 @@ const handlePositionNotFound = () => {
@not-found="handlePositionNotFound" @not-found="handlePositionNotFound"
/> />
<q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis" /> <q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis"/>
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps" /> <ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</FormWrapper> </FormWrapper>
</template> </template>
+58 -21
View File
@@ -1,30 +1,67 @@
<script setup> <script setup>
import LoadMoreList from "components/LoadMoreList.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: false required: false
},
public: {
type: Boolean,
required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
} }
}) });
const getRoute = (item) => {
if (props.public)
return {name: "publicPOIDetail", params: {slug: item.urlSlug}}
else
return {name: "poiDetail", params: {id: item.id}}
};
</script> </script>
<template> <template>
<q-list bordered separator> <LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<q-item clickable v-ripple v-for="poi of items" :key="poi" :to="{name: 'poiDetail', params: {id: poi.id}}"> <template #items>
<q-item-section avatar> <q-list bordered separator>
<q-avatar v-if="poi.titlePhoto"> <q-item clickable v-ripple v-for="poi of items" :key="poi" :to="getRoute(poi)">
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/> <q-item-section avatar>
</q-avatar> <q-avatar v-if="poi.titlePhoto">
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/> <img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
</q-item-section> </q-avatar>
<q-item-section> <q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
{{ poi.name }} </q-item-section>
<div class="text-secondary" v-if="poi.type"> <q-item-section>
{{ poi.type.name }} {{ poi.name }}
<div class="text-secondary" v-if="poi.type">
</div> {{ poi.type.name }}
</q-item-section> </div>
<q-item-section side top v-if="poi.isPublic"> </q-item-section>
<q-icon name="visibility"></q-icon> <q-item-section side top v-if="!props.public && poi.isPublic">
</q-item-section> <q-icon name="visibility"></q-icon>
</q-item> </q-item-section>
</q-list> </q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
+16 -18
View File
@@ -1,11 +1,13 @@
<script setup> <script setup>
import {onMounted, reactive, watch} from "vue"; import {onMounted, reactive, watch} from "vue";
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import RichTextEditor from "components/inputs/RichTextEditor.vue"; import RichTextEditor from "components/inputs/RichTextEditor.vue";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling"; import {setDefaultData} from "src/composables/form/formHandling";
import {slugify} from "src/composables/slugify";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const props = defineProps({ const props = defineProps({
user: { user: {
@@ -13,12 +15,11 @@ const props = defineProps({
required: false, required: false,
default: null default: null
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}) })
const formData = reactive({ const formData = reactive({
@@ -29,22 +30,19 @@ const formData = reactive({
watch(() => props.user, (newVal) => { watch(() => props.user, (newVal) => {
setDefaultData(formData, props.user) setDefaultData(formData, props.user)
}, {immediate: true}); }, {immediate: true});
watch(() => formData.publicUsername, (newVal) => {
formData.publicUsername = slugify(newVal);
}, {immediate: true})
</script> </script>
<template> <template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending"> <FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<q-list> <BasicInfoBanner style="padding: 0 !important" v-if="formData.publicUsername === ''">
<q-item v-if="user.publicUsername"> Nastav si veřejné uživatelské jméno pro zpřístupnění profilu a sdílení záznamů s ostatními
<router-link target="_blank" :to="{name: 'publicProfileDetail', params: {username: user.publicUsername}}"> </BasicInfoBanner>
@{{ user.publicUsername }}
</router-link>
</q-item>
<q-item v-else>
<q-icon name="" color="red"/>
Nastav si veřejné uživatelské jméno pro zpřístupnění profilu a sdílení záznamů s ostatními
</q-item>
</q-list>
<q-input v-model="formData.name" label="Jméno" v-bind="commonProps"/> <q-input v-model="formData.name" label="Jméno" v-bind="commonProps"/>
<q-input <q-input
+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` 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)
} }
+1 -1
View File
@@ -8,6 +8,6 @@ export const flightName = (flight) => {
const track = flight.track ? flight.track.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : []; const track = flight.track ? flight.track.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : [];
const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - '; const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - ';
return `${flight.takeoffAirport.icaoCode} ${trackStr} ${flight.landingAirport.icaoCode} (${date})`; return `${flight.takeoffAirport?.icaoCode || '???'} ${trackStr} ${flight.landingAirport?.icaoCode || "???"} (${date})`;
} }
} }
+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}
}
+5 -7
View File
@@ -13,11 +13,9 @@ export const setDefaultData = (formData, defaultData) => {
} }
} }
export const toggleVisibility = ({store, $q, object}) => { export const toggleVisibility = ({store, $q, object, variables, mutation = undefined}) => {
console.log(object); store.update(object.id, variables, mutation)
const newVisibility = !object.isPublic; .then(() => {
store.update(object.id, {isPublic: newVisibility}) $q.notify(successObject(variables.isPublic ? "Úspěšně publikováno" : "Úspěšně skryto"));
.then(() => { });
$q.notify(successObject(newVisibility ? "Úspěšně publikováno" : "Úspěšně skryto"));
});
} }
+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}
}
+41 -28
View File
@@ -1,41 +1,54 @@
const stripHtml = (str) => { const stripHtml = (str) => {
if (!str) if (!str)
return ''; return '';
else else
str = str.toString(); str = str.toString();
return str.replace(/<[^>]+>/ig, ''); return str.replace(/<[^>]+>/ig, '');
} }
export const commonKeywords = [ export const commonKeywords = [
"zápisník", "létání", "ultralight", "letectví", "fotogalerie", "výlet", "vyhlídkový let", "navigační let" "zápisník", "létání", "ultralight", "letectví", "fotogalerie", "výlet", "vyhlídkový let", "navigační let"
]; ];
export const metaData = ({title, description, keywords, image}) => { export const aircraftKeywords = (aircraft) => {
return [aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
};
const filteredKeywords = (keywords || []).filter(kw => !!kw); export const flightEventKeywords = (event) => {
const filteredDescription = stripHtml(description); if (!event) return [];
return [event.name];
}
return { export const flightKeywords = (flight) => {
title: title || "", return ["let", "výlet", "letadlo"]
titleTemplate(title) { }
return title !== '' ? `${title} - Polétání.cz` : 'Polétání.cz'
}, export const metaData = ({title, description, keywords, image, index = true}) => {
meta: { const filteredKeywords = (keywords || []).filter(kw => !!kw);
description: {name: "description", content: filteredDescription || ""}, const filteredDescription = stripHtml(description);
keywords: {name: "keywords", content: (filteredKeywords || commonKeywords).join(", ")},
ogImage: image ? {property: "og:image", content: image} : undefined, return {
ogTitle: { title: title,
property: "og:title", titleTemplate(title) {
content: title, return title !== '' ? `${title} - Polétání.cz: fotogalerie ze vzduchu` : 'Polétání.cz'
template(ogTitle) { },
return (!ogTitle || ogTitle === '') ? "Polétání.cz" : `${ogTitle} - Polétání.cz` meta: {
} description: {name: "description", content: filteredDescription || ""},
}, keywords: {name: "keywords", content: (filteredKeywords || commonKeywords).join(", ")},
ogDescription: { ogImage: image ? {property: "og:image", content: image} : undefined,
property: "og:description", content: filteredDescription, ogTitle: {
} property: "og:title",
content: title,
template(ogTitle) {
return (!ogTitle || ogTitle === '') ? "Polétání.cz" : `${ogTitle} - Polétání.cz`
} }
},
ogDescription: {
property: "og:description", content: filteredDescription,
},
robots: {name: "robots", content: index ? "index" : "noindex"},
} }
}
} }
+27
View File
@@ -0,0 +1,27 @@
import {ref} from "vue";
export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
if (typeof fetchFunction === "undefined") {
console.error("Set fetchFunction to usePagination!");
}
const paginationLimit = ref(itemsPerPage);
const paginationOffset = ref(0);
const filters = ref({});
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
}
};
+11
View File
@@ -0,0 +1,11 @@
export const slugify = (text) => {
// @see https://byby.dev/js-slugify-string
return String(text)
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9 -]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
+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;
}
+6
View File
@@ -6,3 +6,9 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.q-tab-panel {
@media (max-width: $breakpoint-sm) {
padding: 0;
}
}
+1 -5
View File
@@ -9,6 +9,7 @@ fragment aircraftListInfo on Aircraft {
model model
manufacturer manufacturer
isPublic isPublic
seats
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
@@ -60,13 +61,8 @@ fragment aircraftFullPrivateInfo on Aircraft {
name name
} }
} }
flights {
...flightListInfo
}
} }
${FRAGMENT_AIRCRAFT_FULL_INFO} ${FRAGMENT_AIRCRAFT_FULL_INFO}
${FRAGMENT_PHOTO_FULL_INFO} ${FRAGMENT_PHOTO_FULL_INFO}
${FRAGMENT_FLIGHT_LIST_INFO}
`; `;
+6
View File
@@ -7,5 +7,11 @@ export const FRAGMENT_AIRPORT_INFO = gql`
icaoCode icaoCode
gpsLatitude gpsLatitude
gpsLongitude gpsLongitude
reportingPoints {
id
gpsLatitude
gpsLongitude
name
}
} }
`; `;
+14 -9
View File
@@ -11,27 +11,31 @@ fragment copilotListInfo on Copilot {
} }
`; `;
export const FRAGMENT_COPILOT_FULL_INFO = gql` export const FRAGMENT_COPILOT_PUBLIC_INFO = gql`
fragment copilotFullInfo on Copilot { fragment copilotPublicInfo on Copilot {
id id
name name
isPublic
urlSlug
}
`
export const FRAGMENT_COPILOT_FULL_INFO = gql`
fragment copilotFullInfo on Copilot {
...copilotPublicInfo
titlePhoto { titlePhoto {
id id
url url
} }
} }
${FRAGMENT_COPILOT_PUBLIC_INFO}
`; `;
export const FRAGMENT_COPILOT_FULL_PRIVATE_INFO = gql` export const FRAGMENT_COPILOT_FULL_PRIVATE_INFO = gql`
fragment copilotFullPrivateInfo on Copilot { fragment copilotFullPrivateInfo on Copilot {
id ...copilotFullInfo
name
titlePhoto {
id
url
}
photos { photos {
...photoFullInfo ...photoFullInfo
@@ -49,4 +53,5 @@ fragment copilotFullPrivateInfo on Copilot {
} }
${FRAGMENT_PHOTO_FULL_INFO} ${FRAGMENT_PHOTO_FULL_INFO}
${FRAGMENT_COPILOT_FULL_INFO}
`; `;
+2
View File
@@ -6,6 +6,7 @@ fragment eventListInfo on Event {
name name
description description
isPublic isPublic
urlSlug
} }
`; `;
@@ -15,6 +16,7 @@ fragment eventFullInfo on Event {
name name
description description
isPublic isPublic
urlSlug
dateFrom dateFrom
dateTo dateTo
} }
+23 -16
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_AIRPORT_INFO} from "src/graphql/fragments/airport";
import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo"; import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
import {FRAGMENT_POI_FULL_INFO} from "src/graphql/fragments/poi"; 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` export const FRAGMENT_WEATHER_FULL_INFO = gql`
fragment weatherFullInfo on WeatherInfo { fragment weatherFullInfo on WeatherInfo {
@@ -21,6 +22,7 @@ export const FRAGMENT_FLIGHT_FULL_INFO = gql`
fragment flightFullInfo on Flight { fragment flightFullInfo on Flight {
id id
name name
urlSlug
description description
takeoffDatetime takeoffDatetime
landingDatetime landingDatetime
@@ -50,13 +52,10 @@ fragment flightFullInfo on Flight {
...weatherFullInfo ...weatherFullInfo
} }
copilots {
id
name
}
event { event {
id id
isPublic
urlSlug
name name
} }
@@ -100,21 +99,29 @@ ${FRAGMENT_WEATHER_FULL_INFO}
export const FRAGMENT_FLIGHT_FULL_PRIVATE_INFO = gql` export const FRAGMENT_FLIGHT_FULL_PRIVATE_INFO = gql`
fragment flightFullPrivateInfo on Flight { fragment flightFullPrivateInfo on Flight {
...flightFullInfo ...flightFullInfo
debriefingNotes
copilots {
...copilotPublicInfo
token
}
photos { photos {
...photoFullInfo ...photoFullInfo
pointOfInterest { pointOfInterest {
id id
name name
}
copilots {
id
name
}
} }
copilots {
id
name
}
}
} }
${FRAGMENT_FLIGHT_FULL_INFO} ${FRAGMENT_FLIGHT_FULL_INFO}
${FRAGMENT_PHOTO_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
@@ -6,6 +6,7 @@ export const FRAGMENT_FLIGHT_LIST_INFO = gql`
fragment flightListInfo on Flight { fragment flightListInfo on Flight {
id id
name name
urlSlug
takeoffDatetime takeoffDatetime
landingDatetime landingDatetime
durationMinCalculated durationMinCalculated
@@ -13,6 +14,7 @@ fragment flightListInfo on Flight {
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
url
} }
track { track {
-1
View File
@@ -12,7 +12,6 @@ export const FRAGMENT_PHOTO_FULL_INFO = gql`
aircraftId aircraftId
width width
height height
isFlightCover
gpsLatitude gpsLatitude
gpsLongitude gpsLongitude
gpsAltitude gpsAltitude

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