19 Commits
Author SHA1 Message Date
Michal Kváček cda07239ef Zakladni nastrel sitemapy 2024-02-21 06:31:32 +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
138 changed files with 3384 additions and 1358 deletions
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+9 -3
View File
@@ -24,9 +24,7 @@ http {
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
gzip on;
include /etc/nginx/conf.d/*.conf;
map $sent_http_content_type $expires {
@@ -34,6 +32,10 @@ http {
text/html 0;
}
map $host $api_url {
default "$API_URL";
}
server {
listen 80 default;
server_name poletani.cz;
@@ -46,5 +48,9 @@ http {
expires $expires;
try_files $uri /index.html;
}
location /sitemap.xml {
try_files $uri $api_url;
}
}
}
+2
View File
@@ -10,3 +10,5 @@ services:
ports:
# - 9000:80
- 9000:3000
environment:
API_URL: "https://api.poletani.cz"
+4 -3
View File
@@ -33,11 +33,12 @@ services:
- "9001:9100"
volumes:
- ./src:/app/src
- ./src-ssr:/app/src-ssr
- ./public:/app/public
- ./node_modules:/app/node_modules:delegated
command: quasar dev -m ssr
# - ./node_modules:/app/node_modules:delegated
command: quasar dev # -m ssr
environment:
API_URL: "http://localhost:8000"
API_URL: "http://127.0.0.1:8000"
# nginx:
# image:
+9 -9
View File
@@ -15,28 +15,28 @@
"@vue/apollo-composable": "4.0.0-beta.2",
"@vue/apollo-option": "^4.0.0-beta.5",
"apollo-upload-client": "^17.0.0",
"axios": "^1.4.0",
"chart.js": "^4.4.0",
"axios": "^1.6.2",
"chart.js": "^4.4.1",
"graphql": "16.6.0",
"graphql-tag": "2.12.6",
"photoswipe": "^5.4.2",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"pinia": "^2.0.33",
"quasar": "^2.14.1",
"vue": "^3.2.47",
"vue": "^3.3.11",
"vue-chartjs": "^5.2.0",
"vue-picture-cropper": "^0.7.0",
"vue-router": "^4.1.6",
"vue-router": "^4.2.5",
"vue3-google-map": "^0.15.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.7.1",
"autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-vue": "^9.0.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-vue": "^9.19.2",
"postcss": "^8.4.14",
"prettier": "^2.5.1"
"prettier": "^2.8.8"
},
"engines": {
"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.

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
// https://v2.quasar.dev/options/animations
animations: [],
animations: [
"fadeIn", "fadeOut"
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
sourceFiles: {
+6 -3
View File
@@ -28,9 +28,12 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
} else if (err.code === 404) {
// hmm, Vue Router could not find the requested route
console.log("404 v renderu")
// Should reach here only if no "catch-all" route
// 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) {
// well, we treat any other code as error;
// 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({ err, req, res })
} else {
// we're in production, so we should have another method
// 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
// create a route (/src/routes) for an error page and redirect to it
res.status(500).send('500 | Internal Server Error')
// console.error(err.stack)
res.redirect(resolve.urlPath('500'))
}
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup>
import {useRoute} from "vue-router";
import {computed} from "vue";
import ErrorNotFound from "pages/ErrorNotFound.vue";
import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
import {storeToRefs} from "pinia";
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 {onError} from "@apollo/client/link/error";
import {useUserStore} from "stores/User";
import {useAppStore} from "stores/App";
import {Notify} from "quasar";
import {errorObject, warningObject} from "src/composables/dialog";
import {createUploadLink} from "apollo-upload-client";
import {useAuthStore} from "stores/Auth";
import {useRouter} from "vue-router";
// import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
const handleRefreshToken = (store, router, forward, operation) => {
if (!operation.query?._refreshUnauthorizedUser) return;
console.log("refreshing token, ", operation.query);
return fromPromise(useUserStore(store).refreshAccessToken()
return fromPromise(useAuthStore(store).refreshAccessToken()
.catch((error) => {
Notify.create(warningObject("Tvoje přihlášení vypřešlo. Přihlaš se prosím znovu."));
router.push({name: "login"})
@@ -39,8 +40,7 @@ export default (({store, router}) => {
})
const authLink = new ApolloLink((operation, forward) => {
const accessToken = useUserStore(store).getAccessToken();
const accessToken = useAuthStore(store).getAccessToken();
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
@@ -50,17 +50,16 @@ export default (({store, router}) => {
});
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
console.log(networkError, graphQLErrors);
if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation);
}
if (graphQLErrors) {
for (const error of graphQLErrors) {
if (error.message === "Not found") {
useAppStore(store).appStatus = 404;
} else if (error.message.includes("401") || error.message.includes("Not authorized")) {
if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation);
} else if (error.message.includes("404") || error.message.includes("Not found")) {
useAppStore(store).appStatus = 404;
} else {
Notify.create(errorObject("API error: " + error.message));
}
@@ -80,9 +79,15 @@ export default (({store, router}) => {
})
.then((response) => response)
.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)
return err;
}
return Promise.reject(err);
})
};
apolloWrapper.mutate = (mutation, variables) => {
+8 -9
View File
@@ -1,6 +1,6 @@
import {boot} from 'quasar/wrappers'
import axios from 'axios'
import {useUserStore} from "stores/User";
import {useAuthStore} from "stores/Auth";
const api = axios.create({
@@ -15,17 +15,17 @@ const api = axios.create({
export default boot(({store}) => {
api.interceptors.request.use((config) => {
const token = useUserStore(store).getAccessToken();
const token = useAuthStore(store).getAccessToken();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
if (['/refresh', '/login'].includes(config.url)) {
delete config.headers['Authorization'];
}
if (['/refresh', '/login'].includes(config.url)) {
delete config.headers['Authorization'];
}
return config;
})
return config;
})
// Response interceptor for API calls
api.interceptors.response.use(
@@ -33,8 +33,7 @@ export default boot(({store}) => {
async function (error) {
const originalRequest = error.config;
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
const newToken = await useUserStore(store).refreshAccessToken().catch(err => {
console.error(err);
const newToken = await useAuthStore(store).refreshAccessToken().catch(err => {
return Promise.reject("Logged out");
})
api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
+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>
+56 -34
View File
@@ -1,46 +1,68 @@
<script setup>
import {useAppStore} from "stores/App";
import LoadMoreList from "components/LoadMoreList.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({
items: {
type: Array,
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>
<template>
<q-list bordered separator>
<q-item
clickable
v-ripple
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
v-for="aircraft of props.items" :key="aircraft"
>
<q-item-section avatar>
<q-avatar v-if="aircraft.titlePhoto">
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
</q-item-section>
<q-item-section>
<q-item-label label>
{{ aircraft.callSign }}
</q-item-label>
<q-item-label caption>
{{ aircraft.model }}
<template v-if="aircraft.manufacturer">
({{ aircraft.manufacturer }})
</template>
</q-item-label>
<q-item-label caption v-if="aircraft.organization">
Provozovatel: {{ aircraft.organization.name }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<template #items>
<q-list bordered separator>
<q-item
clickable
v-ripple
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
v-for="aircraft of props.items" :key="aircraft"
>
<q-item-section avatar>
<q-avatar v-if="aircraft.titlePhoto">
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
</q-item-section>
<q-item-section>
<q-item-label label>
{{ aircraft.callSign }}
</q-item-label>
<q-item-label caption>
{{ aircraft.model }}
<template v-if="aircraft.manufacturer">
({{ aircraft.manufacturer }})
</template>
</q-item-label>
<q-item-label caption v-if="aircraft.organization">
Provozovatel: {{ aircraft.organization.name }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template>
+27 -5
View File
@@ -5,26 +5,34 @@ import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs";
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({
copilot: {
type: Object
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
name: null,
urlSlug: "",
isPublic: false,
});
const rules = {
name: [isRequired]
name: [isRequired],
urlSlug: [],
}
const loggedUser = storeToRefs(useLoggedUserStore()).user;
onMounted(() => {
if (props.copilot) {
setDefaultData(formData, props.copilot);
@@ -41,5 +49,19 @@ onMounted(() => {
:rules="rules.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>
</template>
+20 -7
View File
@@ -8,20 +8,24 @@ import {isRequired} from "src/composables/form/rules";
import RichTextEditor from "components/inputs/RichTextEditor.vue";
import DatetimePicker from "components/inputs/DatetimePicker.vue";
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({
event: {
type: Object
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
name: null,
name: "",
urlSlug: "",
isPublic: false,
description: "",
dateFrom: null,
@@ -38,6 +42,7 @@ onMounted(() => {
}
})
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script>
<template>
@@ -48,7 +53,15 @@ onMounted(() => {
:rules="rules.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="col q-pr-sm">
@@ -61,7 +74,7 @@ onMounted(() => {
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</FormWrapper>
</template>
+7 -8
View File
@@ -8,8 +8,7 @@ const props = defineProps({
},
routeDetail: {
type: Object,
required: false,
default: () => ({name: 'eventDetail', params: {}})
required: true,
},
});
@@ -19,20 +18,20 @@ const getCoverPhotos = (item) => item.flights.filter(f => !!f.titlePhoto).map(f
<template>
<q-list bordered separator>
<q-item
v-for="event of items" :key="event"
v-for="eventDetail of items" :key="eventDetail"
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
>
<q-item-section>
<q-item-label>
<q-avatar icon="event" color="primary" text-color="white" v-if="!event.flights || event.flights.length === 0"/>
<AvatarsGroup :avatars="getCoverPhotos(event)" v-else/>
<q-avatar icon="event" color="primary" text-color="white" v-if="!eventDetail.flights || eventDetail.flights.length === 0"/>
<AvatarsGroup :avatars="getCoverPhotos(eventDetail)" v-else/>
{{ event.name }}
{{ eventDetail.name }}
</q-item-label>
<q-item-label lines="4" caption>
<article v-html="event.description"/>
<article v-html="eventDetail.description"/>
</q-item-label>
</q-item-section>
</q-item>
+17 -10
View File
@@ -22,7 +22,6 @@ const props = defineProps({
required: false,
default: null
}
})
</script>
@@ -34,8 +33,8 @@ const props = defineProps({
</q-item-section>
<q-item-section>
<router-link
:to="{...aircraftDetailRoute, params: {...aircraftDetailRoute.params, id: flight.aircraft.id}}"
v-if="aircraftDetailRoute"
:to="{...aircraftDetailRoute, params: {id: flight.aircraft.id, callSign: flight.aircraft.callSign}}"
v-if="flight.aircraft.isPublic"
>
{{ flight.aircraft.callSign }}
</router-link>
@@ -62,7 +61,9 @@ const props = defineProps({
</q-item-section>
<q-item-section>
<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) }}
<WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/>
@@ -78,8 +79,8 @@ const props = defineProps({
<q-item-section>
<template v-if="poi.pointOfInterest">
<router-link
:to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, id: poi.pointOfInterest.id}}"
v-if="poi.pointOfInterest.isPublic"
:to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, slug: poi.pointOfInterest.urlSlug, id: poi.pointOfInterest.id}}"
v-if="poi.pointOfInterest.isPublic"
>
{{ poi.pointOfInterest.name }}
</router-link>
@@ -87,7 +88,11 @@ const props = defineProps({
</template>
<span v-if="poi.airport">
<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>
</q-item-section>
</q-item>
@@ -98,7 +103,9 @@ const props = defineProps({
</q-item-section>
<q-item-section>
<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) }}
<WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/>
</div>
@@ -123,8 +130,8 @@ const props = defineProps({
</q-item-section>
<q-item-section>
<router-link
v-if="copilotDetailRoute"
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, id: copilot.id}}"
v-if="copilot.isPublic"
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
>
{{ copilot.name }}
</router-link>
+13 -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 pointsOfInterests = computed(() => mapMarkers
.filter(marker => marker.gpsLatitude && marker.gpsLongitude)
.filter(marker => marker && marker.gpsLatitude && marker.gpsLongitude)
.map(
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
));
@@ -101,16 +101,17 @@ const chartOptions = {
</script>
<template>
<MapWithMarkers
v-if="displayMap"
style="width: 100%; height: 500px;"
:gpx-track="flight.gpxTrack?.coordinates || []"
:markers="pointsOfInterests"
:highlight-position="currentPositionMarker"
:photos="mapPhotos"
/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
<q-no-ssr>
<MapWithMarkers
v-if="displayMap"
style="width: 100%; height: 500px;"
:gpx-track="flight.gpxTrack?.coordinates || []"
:markers="pointsOfInterests"
:highlight-position="currentPositionMarker"
:photos="mapPhotos"
/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
</q-no-ssr>
</template>
+88 -41
View File
@@ -2,6 +2,9 @@
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {flightName} from "src/composables/flight";
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";
const props = defineProps({
items: {
@@ -12,60 +15,104 @@ const props = defineProps({
type: Boolean,
required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
});
const routeDetail = (flight) => {
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
return {name: "flightDetail", params: {id: flight.id}}
};
const pluralize = (count, singular, plural, pluralMore) => {
if (count === 1) return singular;
else if (count < 5) return plural;
else return pluralMore;
}
const loadMoreCount = computed(() => {
const remaining = props.totalItemsCount - props.items.length;
return Math.min(props.itemsPerPage, remaining);
});
</script>
<template>
<q-list bordered separator>
<q-item
v-for="flight of items" :key="flight"
ripple
clickable
:to="routeDetail(flight)"
>
<q-item-section avatar>
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
</q-item-section>
<q-item-section>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<template #items>
<q-list bordered separator>
<q-item
v-for="flight of items" :key="flight"
ripple
clickable
:to="routeDetail(flight)"
>
<q-item-section avatar>
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
<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">
<q-icon name="support_agent"/>
@{{flight.pilot.publicUsername}}
</div>
<div class="text-secondary" v-if="props.public">
<q-icon name="support_agent"/>
@{{ flight.pilot.publicUsername }}
</div>
<div class="text-secondary" v-if="flight.copilot">
<q-icon name="person"/>
{{ flight.pilot.name }}
{{ flight.copilot.name }}
</div>
<div class="text-secondary" v-if="flight.copilots">
<q-icon name="person"/>
{{ flight.pilot.name }}
{{ flight.copilots }}
</div>
<div class="text-secondary">
<q-icon name="schedule"/>
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
</div>
<div class="text-secondary">
<q-icon name="flight"/>
{{ flight.aircraft.callSign }}
</div>
<div class="text-secondary">
<q-icon name="schedule"/>
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
</div>
<div class="text-secondary">
<q-icon name="flight"/>
{{ flight.aircraft.callSign }}
</div>
<div class="text-secondary" v-if="flight.event">
<q-icon name="event"/>
{{ flight.event.name }}
</div>
</q-item-section>
<div class="text-secondary" v-if="flight.event">
<q-icon name="event"/>
{{ flight.event.name }}
</div>
</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>
+75 -68
View File
@@ -21,10 +21,10 @@ const props = defineProps({
required: false
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
type: Boolean,
required: false,
default: false,
}
});
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 aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
const manualInput = ref(false);
const allowGPXUpload = ref(true);
const formData = reactive({
aircraft: null,
gpxTrackFile: null,
takeoffAirport: null,
landingAirport: null,
takeoffDatetime: null,
@@ -52,8 +57,14 @@ watch(() => props.flight, (newVal) => {
if (newVal) {
setDefaultData(formData, newVal);
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});
watch(() => formData.takeoffDatetime, () => {
@@ -67,79 +78,75 @@ watch(() => formData.takeoffAirport, () => {
formData.landingAirport = {...formData.takeoffAirport};
}
});
</script>
<template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<div class="row">
<div class="col">
<q-card class="q-my-sm">
<q-card-section>
<div class="text-h6">Vyber letadlo</div>
</q-card-section>
<q-card-section>
<ComboboxInput
:rules="rules.aircraft"
v-bind="commonProps"
:options="aircrafts"
v-model="formData.aircraft"
label="Letadlo"
/>
</q-card-section>
</q-card>
<ComboboxInput
:rules="rules.aircraft"
v-bind="commonProps"
:options="aircrafts"
v-model="formData.aircraft"
label="Letadlo"
/>
</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
:options="airports"
:rules="rules.takeoffAirport"
v-model="formData.takeoffAirport"
label="Letiště vzletu"
v-bind="commonProps"
/>
</q-card-section>
</q-card>
</div>
<div class="row" v-if="!manualInput">
<div class="col">
<div class="text-h6">Mám trasu v GPX, chci importovat data</div>
<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."/>
<div class="col col-12 col-md-6">
<q-card class="q-ml-md-sm q-my-sm">
<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 class="text-center">
<q-btn @click="manualInput = true" rounded flat>Zadat informace ručně</q-btn>
</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>
</template>
@@ -1,10 +1,13 @@
<script setup>
import {reactive, watch} from "vue";
import {reactive, ref, watch} from "vue";
import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling";
import RichTextEditor from "components/inputs/RichTextEditor.vue";
import {commonProps} from "src/composables/inputs";
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({
flight: {
@@ -16,18 +19,24 @@ const props = defineProps({
required: false,
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
type: Boolean,
required: false,
default: false,
}
});
const formData = reactive({
name: "",
event: null,
description: ""
description: "",
debriefingNotes: "",
urlSlug: "",
isPublic: null
})
const loggedUser = storeToRefs(useLoggedUserStore()).user;
watch(() => props.flight, (newVal) => {
if (newVal) {
setDefaultData(formData, newVal);
@@ -38,12 +47,40 @@ watch(() => props.flight, (newVal) => {
}
}, {immediate: true});
const descriptionTab = ref("description");
</script>
<template>
<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"/>
<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>
</template>
@@ -3,6 +3,7 @@ import FormWrapper from "components/FormWrapper.vue";
import {reactive, ref, watch} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
const props = defineProps({
modelValue: {
@@ -24,7 +25,7 @@ const props = defineProps({
const formData = reactive({
track: [],
gpxTrack: null
gpxTrackFile: null
});
const removeTrackItem = (index) => {
formData.track.splice(index, 1);
@@ -51,6 +52,7 @@ watch(poi, (newVal) => {
});
watch(() => props.flight.track, (newVal) => {
formData.track = [];
for (const i in newVal) {
const trackInfo = newVal[i];
const info = {landingDuration: trackInfo.landingDuration};
@@ -81,7 +83,7 @@ watch(() => props.flight.track, (newVal) => {
<q-file
accept=".gpx"
v-model="formData.gpxTrack"
v-model="formData.gpxTrackFile"
v-bind="commonProps"
label="Nahrát trasu v GPX"
hint="Slouží pro vykreslení kompletní a detailní trasy"
@@ -91,6 +93,8 @@ watch(() => props.flight.track, (newVal) => {
</template>
</q-file>
<h5>Body na mapě</h5>
<ComboboxInput
v-bind="commonProps"
v-model="poi"
@@ -103,7 +107,7 @@ watch(() => props.flight.track, (newVal) => {
/>
<div class="row">
<div class="col">
<div class="col-12 col-md-8">
<ComboboxInput
v-bind="commonProps"
v-model="airport"
@@ -111,16 +115,16 @@ watch(() => props.flight.track, (newVal) => {
:options="airports"
clearable
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 class="col" v-if="airport?.name">
<div class="col-md-4 col-12" v-if="airport?.name">
<q-input
type="number"
v-bind="commonProps"
v-model="landingDuration"
label="Délka mezipřistání (minuty)"
class="q-mx-md"
class="q-mx-md-md"
>
<template #after>
<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-item-section>
<q-item-section>
{{ flight.takeoffAirport.name }}
{{ flight.takeoffAirport?.name || '???' }}
</q-item-section>
</q-item>
@@ -153,7 +157,11 @@ watch(() => props.flight.track, (newVal) => {
</q-badge>
</span>
<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>
</q-item-section>
@@ -167,7 +175,7 @@ watch(() => props.flight.track, (newVal) => {
<q-avatar color="primary" text-color="white" icon="flight_land"/>
</q-item-section>
<q-item-section>
{{ flight.landingAirport.name }}
{{ flight.landingAirport?.name || '???' }}
</q-item-section>
</q-item>
</q-list>
@@ -1,10 +1,18 @@
<script setup>
import {randomInt} from "src/composables/random";
const props = defineProps({
items: {
type: Number,
required: false,
default: 10,
}
})
</script>
<template>
<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-avatar size="100px">
<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-skeleton :width="randomInt(30,50)+'%'"/>
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/>
<q-skeleton type="text" :width="randomInt(5,10)+'%'"/>
<q-skeleton type="text" width="8%"/>
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/>
<q-skeleton type="text" :width="randomInt(5,10)+'%'"/>
<q-skeleton type="text" width="8%"/>
</q-item-section>
</q-item>
</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>
+32
View File
@@ -0,0 +1,32 @@
<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
}
})
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="link"
@click="copyLinkToClipboard()"
flat
label="Získat odkaz"
/>
</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>
<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>
+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>
@@ -6,8 +6,8 @@ import {randomInt} from "src/composables/random";
<template>
<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(40, 80)}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(200, 280)}px`" type="rect" /> </q-breadcrumbs-el>
</q-breadcrumbs>
+1 -1
View File
@@ -127,7 +127,7 @@ const showLarge = (displayedPhoto) => {
</script>
<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">
<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">
@@ -1,7 +1,17 @@
<script setup>
const props = defineProps({
photos: {
type: Number,
required: false,
default: 9
}
});
</script>
<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>
+20 -6
View File
@@ -8,6 +8,8 @@ import {errorObject} from "src/composables/dialog";
import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {useRouter} from "vue-router";
import SlugInput from "components/inputs/SlugInput.vue";
const props = defineProps({
poi: {
@@ -23,7 +25,8 @@ const props = defineProps({
})
const formData = reactive({
name: null,
name: "",
urlSlug: "",
description: "",
gpsLatitude: null,
gpsLongitude: null,
@@ -47,7 +50,8 @@ const resetMap = () => {
}
const rules = {
name: [isRequired]
name: [isRequired],
urlSlug: []
}
const zoom = ref(10);
@@ -72,6 +76,18 @@ const handlePositionNotFound = () => {
</template>
</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
v-if="poiNameForMap"
:key="poiNameForMap"
@@ -82,10 +98,8 @@ const 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" />
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
</FormWrapper>
</template>
+59 -21
View File
@@ -1,30 +1,68 @@
<script setup>
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import LoadMoreList from "components/LoadMoreList.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({
items: {
type: Array,
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>
<template>
<q-list bordered separator>
<q-item clickable v-ripple v-for="poi of items" :key="poi" :to="{name: 'poiDetail', params: {id: poi.id}}">
<q-item-section avatar>
<q-avatar v-if="poi.titlePhoto">
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
{{ poi.name }}
<div class="text-secondary" v-if="poi.type">
{{ poi.type.name }}
</div>
</q-item-section>
<q-item-section side top v-if="poi.isPublic">
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
</q-list>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<template #items>
<q-list bordered separator>
<q-item clickable v-ripple v-for="poi of items" :key="poi" :to="getRoute(poi)">
<q-item-section avatar>
<q-avatar v-if="poi.titlePhoto">
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
{{ poi.name }}
<div class="text-secondary" v-if="poi.type">
{{ poi.type.name }}
</div>
</q-item-section>
<q-item-section side top v-if="!props.public && poi.isPublic">
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template>
+16 -18
View File
@@ -1,11 +1,13 @@
<script setup>
import {onMounted, reactive, watch} from "vue";
import {useRoute} from "vue-router";
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
import {commonProps} from "src/composables/inputs";
import RichTextEditor from "components/inputs/RichTextEditor.vue";
import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling";
import {slugify} from "src/composables/slugify";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const props = defineProps({
user: {
@@ -13,12 +15,11 @@ const props = defineProps({
required: false,
default: null
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
@@ -29,22 +30,19 @@ const formData = reactive({
watch(() => props.user, (newVal) => {
setDefaultData(formData, props.user)
}, {immediate: true});
watch(() => formData.publicUsername, (newVal) => {
formData.publicUsername = slugify(newVal);
}, {immediate: true})
</script>
<template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<q-list>
<q-item v-if="user.publicUsername">
<router-link target="_blank" :to="{name: 'publicProfileDetail', params: {username: user.publicUsername}}">
@{{ 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>
<BasicInfoBanner style="padding: 0 !important" v-if="formData.publicUsername === ''">
Nastav si veřejné uživatelské jméno pro zpřístupnění profilu a sdílení záznamů s ostatními
</BasicInfoBanner>
<q-input v-model="formData.name" label="Jméno" v-bind="commonProps"/>
<q-input
+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 trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - ';
return `${flight.takeoffAirport.icaoCode} ${trackStr} ${flight.landingAirport.icaoCode} (${date})`;
return `${flight.takeoffAirport?.icaoCode || '???'} ${trackStr} ${flight.landingAirport?.icaoCode || "???"} (${date})`;
}
}
+5 -7
View File
@@ -13,11 +13,9 @@ export const setDefaultData = (formData, defaultData) => {
}
}
export const toggleVisibility = ({store, $q, object}) => {
console.log(object);
const newVisibility = !object.isPublic;
store.update(object.id, {isPublic: newVisibility})
.then(() => {
$q.notify(successObject(newVisibility ? "Úspěšně publikováno" : "Úspěšně skryto"));
});
export const toggleVisibility = ({store, $q, object, variables, mutation = undefined}) => {
store.update(object.id, variables, mutation)
.then(() => {
$q.notify(successObject(variables.isPublic ? "Úspěšně publikováno" : "Úspěšně skryto"));
});
}
+32 -28
View File
@@ -1,41 +1,45 @@
const stripHtml = (str) => {
if (!str)
return '';
else
str = str.toString();
if (!str)
return '';
else
str = str.toString();
return str.replace(/<[^>]+>/ig, '');
return str.replace(/<[^>]+>/ig, '');
}
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);
const filteredDescription = stripHtml(description);
export const metaData = ({title, description, keywords, image, index = true}) => {
const filteredKeywords = (keywords || []).filter(kw => !!kw);
const filteredDescription = stripHtml(description);
return {
title: title || "",
titleTemplate(title) {
return title !== '' ? `${title} - Polétání.cz` : 'Polétání.cz'
},
meta: {
description: {name: "description", content: filteredDescription || ""},
keywords: {name: "keywords", content: (filteredKeywords || commonKeywords).join(", ")},
ogImage: image ? {property: "og:image", content: image} : undefined,
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,
}
return {
title: title,
titleTemplate(title) {
return title !== '' ? `${title} - Polétání.cz: fotogalerie ze vzduchu` : 'Polétání.cz'
},
meta: {
description: {name: "description", content: filteredDescription || ""},
keywords: {name: "keywords", content: (filteredKeywords || commonKeywords).join(", ")},
ogImage: image ? {property: "og:image", content: image} : undefined,
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"},
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import {ref} from "vue";
export const paginationLimit = ref(10);
export const paginationOffset = ref(0);
export const filters = ref({});
export const loadPage = ({fetchFunc, appendItems}) => {
return fetchFunc({
...filters.value,
limit: paginationLimit.value,
offset: paginationOffset.value,
appendItems
}).then(() => {
paginationOffset.value += paginationLimit.value;
})
}
+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
@@ -6,3 +6,9 @@
margin-left: auto;
margin-right: auto;
}
.q-tab-panel {
@media (max-width: $breakpoint-sm) {
padding: 0;
}
}
-5
View File
@@ -60,13 +60,8 @@ fragment aircraftFullPrivateInfo on Aircraft {
name
}
}
flights {
...flightListInfo
}
}
${FRAGMENT_AIRCRAFT_FULL_INFO}
${FRAGMENT_PHOTO_FULL_INFO}
${FRAGMENT_FLIGHT_LIST_INFO}
`;
+4 -7
View File
@@ -15,6 +15,8 @@ export const FRAGMENT_COPILOT_FULL_INFO = gql`
fragment copilotFullInfo on Copilot {
id
name
isPublic
urlSlug
titlePhoto {
id
@@ -25,13 +27,7 @@ fragment copilotFullInfo on Copilot {
export const FRAGMENT_COPILOT_FULL_PRIVATE_INFO = gql`
fragment copilotFullPrivateInfo on Copilot {
id
name
titlePhoto {
id
url
}
...copilotFullInfo
photos {
...photoFullInfo
@@ -49,4 +45,5 @@ fragment copilotFullPrivateInfo on Copilot {
}
${FRAGMENT_PHOTO_FULL_INFO}
${FRAGMENT_COPILOT_FULL_INFO}
`;
+2
View File
@@ -6,6 +6,7 @@ fragment eventListInfo on Event {
name
description
isPublic
urlSlug
}
`;
@@ -15,6 +16,7 @@ fragment eventFullInfo on Event {
name
description
isPublic
urlSlug
dateFrom
dateTo
}
+16 -11
View File
@@ -21,6 +21,7 @@ export const FRAGMENT_FLIGHT_FULL_INFO = gql`
fragment flightFullInfo on Flight {
id
name
urlSlug
description
takeoffDatetime
landingDatetime
@@ -53,6 +54,8 @@ fragment flightFullInfo on Flight {
copilots {
id
name
isPublic
urlSlug
}
event {
@@ -100,21 +103,23 @@ ${FRAGMENT_WEATHER_FULL_INFO}
export const FRAGMENT_FLIGHT_FULL_PRIVATE_INFO = gql`
fragment flightFullPrivateInfo on Flight {
...flightFullInfo
debriefingNotes
photos {
...photoFullInfo
...photoFullInfo
pointOfInterest {
id
name
}
copilots {
id
name
}
pointOfInterest {
id
name
}
copilots {
id
name
}
}
}
${FRAGMENT_FLIGHT_FULL_INFO}
${FRAGMENT_PHOTO_FULL_INFO}
`;
`;
+2
View File
@@ -6,6 +6,7 @@ export const FRAGMENT_FLIGHT_LIST_INFO = gql`
fragment flightListInfo on Flight {
id
name
urlSlug
takeoffDatetime
landingDatetime
durationMinCalculated
@@ -13,6 +14,7 @@ fragment flightListInfo on Flight {
titlePhoto {
thumbnailUrl
url
}
track {
-1
View File
@@ -12,7 +12,6 @@ export const FRAGMENT_PHOTO_FULL_INFO = gql`
aircraftId
width
height
isFlightCover
gpsLatitude
gpsLongitude
gpsAltitude
+2
View File
@@ -6,6 +6,7 @@ export const FRAGMENT_POI_LIST_INFO = gql`
fragment pointOfInterestListInfo on PointOfInterest {
id
name
urlSlug
isPublic
titlePhoto {
thumbnailUrl
@@ -21,6 +22,7 @@ export const FRAGMENT_POI_FULL_INFO = gql`
fragment pointOfInterestFullInfo on PointOfInterest {
id
name
urlSlug
description
isPublic
gpsLatitude
+8
View File
@@ -20,6 +20,14 @@ mutation editFlight($id: Int!, $input: EditFlightInput!) {
${FRAGMENT_FLIGHT_FULL_PRIVATE_INFO}
`;
export const TOGGLE_FLIGHT_PUBLISH = gql`
mutation toggleFlightPublish($id: Int!, $input: EditFlightInput!) {
editFlight(id: $id, input: $input) {
isPublic
urlSlug
}
}
`;
export const DELETE_FLIGHT = gql`
mutation deleteFlight($id: Int!) {
+9 -6
View File
@@ -2,11 +2,14 @@ import gql from "graphql-tag";
import {FRAGMENT_AIRCRAFT_FULL_INFO, FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO, FRAGMENT_AIRCRAFT_LIST_INFO} from "src/graphql/fragments/aircraft";
export const AIRCRAFTS_LIST = gql`
query AircraftsList {
aircrafts {
...aircraftListInfo
query AircraftsList ($limit: Int!, $offset: Int) {
aircrafts(limit: $limit, offset: $offset) {
totalItemsCount
items {
...aircraftListInfo
}
}
}
}
${FRAGMENT_AIRCRAFT_LIST_INFO}
`;
@@ -22,8 +25,8 @@ ${FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO}
`;
export const AIRCRAFT_PUBLIC_DETAIL = gql`
query AircraftDetail($id: Int!) {
aircraft(id: $id, public: true) {
query AircraftDetail($callSign: String!) {
aircraft(callSign: $callSign, public: true) {
...aircraftFullInfo
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ ${FRAGMENT_FLIGHT_LIST_INFO}
`;
export const COPILOT_PUBLIC_DETAIL = gql`
query copilot($id: Int!, $pilotUsername: String) {
copilot(id: $id, pilotUsername: $pilotUsername) {
query copilot($urlSlug: String!, $pilotUsername: String) {
copilot(urlSlug: $urlSlug, pilotUsername: $pilotUsername, public: true) {
...copilotFullInfo
}
}
+48 -10
View File
@@ -3,13 +3,33 @@ import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list";
import {FRAGMENT_EVENT_FULL_INFO, FRAGMENT_EVENT_LIST_INFO} from "src/graphql/fragments/event";
export const EVENTS_LIST = gql`
query eventsList($username: String) {
events(username: $username) {
...eventListInfo
query eventsList($limit: Int!, $offset: Int) {
events(limit: $limit, offset: $offset) {
items {
...eventListInfo
flights {
titlePhoto {
thumbnailUrl
flights {
titlePhoto {
thumbnailUrl
}
}
}
}
}
${FRAGMENT_EVENT_LIST_INFO}
`;
export const EVENTS_PUBLIC_LIST = gql`
query eventsPublicList($username: String, $limit: Int!, $offset: Int) {
events(username: $username, limit: $limit, offset: $offset, public: true) {
items {
...eventListInfo
flights {
titlePhoto {
thumbnailUrl
}
}
}
}
@@ -19,16 +39,34 @@ ${FRAGMENT_EVENT_LIST_INFO}
`;
export const EVENT_DETAIL = gql`
query event($id: Int!, $username: String) {
event(id: $id, username: $username) {
query event($id: Int!) {
event(id: $id) {
...eventFullInfo
flights {
...flightListInfo
titlePhoto {
thumbnailUrl
}
}
}
}
${FRAGMENT_EVENT_FULL_INFO}
${FRAGMENT_FLIGHT_LIST_INFO}
`;
export const EVENT_PUBLIC_DETAIL = gql`
query eventPublicDetail($urlSlug: String!, $username: String!) {
event(urlSlug: $urlSlug, username: $username, public: true) {
...eventFullInfo
flights {
titlePhoto {
thumbnailUrl
}
}
}
}
${FRAGMENT_EVENT_FULL_INFO}
`;
+33 -12
View File
@@ -4,22 +4,43 @@ import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list";
import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
export const FLIGHTS_LIST = gql`
query FlightsList {
flights {
...flightListInfo
query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $limit: Int!, $offset: Int) {
flights(
eventId: $eventId,
aircraftId: $aircraftId,
copilotId: $copilotId,
pointOfInterestId: $pointOfInterestId,
limit: $limit,
offset: $offset
) {
totalItemsCount
items {
...flightListInfo
}
}
}
${FRAGMENT_FLIGHT_LIST_INFO}
`;
export const FLIGHTS_PUBLIC_LIST = gql`
query FlightsPublicList($username: String, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int) {
flights(username: $username, aircraftId: $aircraftId, copilotId: $copilotId, pointOfInterestId: $pointOfInterestId, public: true) {
...flightListInfo
pilot {
id
publicUsername
query FlightsPublicList($username: String, $eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $limit: Int!, $offset: Int) {
flights(
username: $username,
eventId: $eventId,
aircraftId: $aircraftId,
copilotId: $copilotId,
pointOfInterestId: $pointOfInterestId,
limit: $limit,
offset: $offset,
public: true
) {
totalItemsCount
items {
...flightListInfo
pilot {
id
publicUsername
}
}
}
}
@@ -37,8 +58,8 @@ ${FRAGMENT_FLIGHT_FULL_PRIVATE_INFO}
`;
export const FLIGHT_PUBLIC_DETAIL = gql`
query FlightPublicDetail($id: Int!, $username: String) {
flight(id: $id, username: $username, public: true) {
query FlightPublicDetail($urlSlug: String!, $username: String) {
flight(urlSlug: $urlSlug, username: $username, public: true) {
...flightFullInfo
}
}
+14 -8
View File
@@ -2,9 +2,12 @@ import gql from "graphql-tag";
import {FRAGMENT_POI_FULL_INFO, FRAGMENT_POI_FULL_PRIVATE_INFO, FRAGMENT_POI_LIST_INFO} from "src/graphql/fragments/poi";
export const POI_LIST = gql`
query poiList {
pointsOfInterest {
...pointOfInterestListInfo
query poiList($limit: Int!, $offset: Int, $search: String) {
pointsOfInterest (limit: $limit, offset: $offset, search: $search) {
totalItemsCount
items {
...pointOfInterestListInfo
}
}
}
@@ -12,9 +15,12 @@ ${FRAGMENT_POI_LIST_INFO}
`;
export const POI_PUBLIC_LIST = gql`
query poiList {
pointsOfInterest(public: true) {
...pointOfInterestListInfo
query poiList($limit: Int!, $offset: Int, $search: String) {
pointsOfInterest(limit: $limit, offset: $offset, search: $search, public: true) {
totalItemsCount
items {
...pointOfInterestListInfo
}
}
}
@@ -32,8 +38,8 @@ ${FRAGMENT_POI_FULL_PRIVATE_INFO}
`;
export const POI_PUBLIC_DETAIL = gql`
query publicPOIDetail($id: Int!) {
pointOfInterest(id: $id, public: true) {
query publicPOIDetail($urlSlug: String!) {
pointOfInterest(urlSlug: $urlSlug, public: true) {
...pointOfInterestFullInfo
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import gql from "graphql-tag";
import {FRAGMENT_USER_FULL_INFO} from "src/graphql/fragments/user";
export const USER_DETAIL = gql`
query userDetail($username: String!) {
export const PILOT_DETAIL = gql`
query pilotDetail($username: String!) {
user(username: $username) {
...userFullInfo
}
+61 -21
View File
@@ -3,12 +3,12 @@
<q-header reveal elevated class="bg-primary text-white">
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer"
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer"
/>
<q-toolbar-title>
@@ -21,12 +21,42 @@
</q-header>
<q-drawer
v-model="leftDrawerOpen"
show-if-above
bordered
v-model="leftDrawerOpen"
show-if-above
>
<q-img class="absolute-top" v-if="!isQueryRunning && user" :src="user.titleImageUrl" style="height: 150px"/>
<q-spinner v-else/>
<q-img
fit="cover"
class="absolute-top"
v-if="!isQueryRunning && user"
:src="user.titleImageUrl"
style="height: 150px"
>
<div class="bg-transparent absolute-bottom-right" v-if="user.publicUsername && user.publicUsername !== ''">
<router-link
v-if="user.avatarImageUrl"
target="_blank"
:to="{name: 'publicProfileDetail', params: {username: user.publicUsername}}"
>
<AvatarFromPhoto
class="shadow-9"
:size="40"
:src="user.avatarImageUrl"
/>
</router-link>
<q-btn
v-else
color="blue-1"
text-color="blue-9"
target="_blank"
:to="{name: 'publicProfileDetail', params: {username: user.publicUsername}}"
>
@{{ user.publicUsername }}
</q-btn>
</div>
</q-img>
<div class="absolute-top text-center bg-grey-2" style="padding-top: 40px; height: 150px;" v-else>
<q-spinner size="64px" color="grey-5"/>
</div>
<q-scroll-area style="height: calc(100% - 150px); margin-top: 150px; border-right: 1px solid #ddd">
<SidebarNavigation/>
@@ -35,23 +65,28 @@
<q-page-container>
<BreadcrumbsNavigation :items="breadcrumbs || []"/>
<div class="q-mx-md q-my-md">
<div class="q-mx-md q-my-md" style="padding-bottom: 150px">
<router-view/>
</div>
</q-page-container>
</q-layout>
</template>
<script>
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
export default {
preFetch() {
return useUserStore().fetchLoggedUserDetail({refresh: true});
preFetch({redirect, currentRoute}) {
// nelze udelat refresh tokenu, protoze refreshToken je ulozeny v HTTP-only cookie a neposila se!
return useLoggedUserStore().fetchLoggedUserDetail({refresh: false}).catch(() => {
redirect({name: "profileRefresh", query: {url: currentRoute.href}})
})
}
}
</script>
<script setup>
import {ref} from 'vue'
import {ref, watch} from 'vue'
import {storeToRefs} from "pinia";
import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue";
import {useAppStore} from "stores/App";
@@ -61,24 +96,29 @@ import {successObject} from "src/composables/dialog";
import SidebarNavigation from "components/navigation/SidebarNavigation.vue";
import TopMenuButton from "components/navigation/TopMenuButton.vue";
import {metaData} from "src/composables/meta";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import {useAuthStore} from "stores/Auth";
const $q = useQuasar();
const $router = useRouter();
const {pageTitle, breadcrumbs} = storeToRefs(useAppStore());
useMeta(() => metaData({
title: pageTitle.value
}));
watch(() => pageTitle.value, () => {
useMeta(() => metaData({
title: pageTitle.value
}));
})
const leftDrawerOpen = ref(false)
const toggleLeftDrawer = () => {
leftDrawerOpen.value = !leftDrawerOpen.value
};
const {isQueryRunning, user} = storeToRefs(useUserStore());
const {isQueryRunning, user} = storeToRefs(useLoggedUserStore());
const logout = () => {
useUserStore().logout().then(() => {
useAuthStore().logout().then(() => {
$q.notify(successObject("Odhlášení bylo úspěšné"));
$router.push({name: "homepage"});
})
+7 -5
View File
@@ -1,5 +1,5 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-layout view="lHh Lpr lff">
<q-header>
<q-toolbar>
<q-toolbar-title>
@@ -29,15 +29,16 @@
<router-view class="q-my-lg q-mx-lg"/>
</q-page-container>
<AppFooter />
</q-layout>
</template>
<script>
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
export default {
preFetch() {
return useUserStore().fetchLoggedUserDetail({refresh: true});
return useLoggedUserStore().fetchLoggedUserDetail({refresh: false}).catch(e => {});
}
}
</script>
@@ -47,7 +48,8 @@ import {useAppStore} from "stores/App";
import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import BreadcrumbsSkeleton from "components/navigation/BreadcrumbsSkeleton.vue";
import AppFooter from "components/AppFooter.vue";
const {pageTitle, breadcrumbs} = storeToRefs(useAppStore());
const loggedUser = storeToRefs(useUserStore()).user;
const {pageTitle, breadcrumbs, appStatus} = storeToRefs(useAppStore());
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script>
-30
View File
@@ -1,30 +0,0 @@
<template>
<div class="fullscreen text-center q-pa-md flex flex-center">
<div>
<q-img src="/plane-crash.svg" width="75%" />
<div style="font-size: 20vh">
404
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
:to="{name: 'homepage'}"
label="Zpět na titulní stranu"
no-caps
/>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ErrorNotFound'
})
</script>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="fullscreen text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 10vh">
Tohle jsme tu nenašli...
</div>
<div>
... zkus si srovnat mapu podle kurzu, najít významnou linii bezpečně před sebou a tu vyhlížej...
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
@click="backToHomepage"
label="Zpět na titulní stranu"
no-caps
/>
</div>
</div>
</template>
<script setup>
import {useAppStore} from "stores/App";
import {useRouter} from "vue-router";
import {metaData} from "src/composables/meta";
import {useMeta} from "quasar";
useAppStore().setPageTitle("Nenalezeno");
useMeta(() => metaData({
title: "Nenalezeno",
index: false
}))
const $router = useRouter();
const backToHomepage = () => {
$router.push({name: "homepage"}).then(() => useAppStore().appStatus = 200);
}
</script>
+23
View File
@@ -0,0 +1,23 @@
<template>
<div class="fullscreen text-center q-pa-md flex flex-center">
<div>
<q-img src="/plane-crash.svg" width="70%" />
<div style="font-size: 15vh">
Uups&hellip;
</div>
Něco tu havarovalo...
Pro další letecké nehody zatím koukni <a href="https://uzpln.cz/zpravy-ln" target="_blank">sem</a>. Za chvíli to tu snad bude fungovat.
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ServerError'
})
</script>
+4 -4
View File
@@ -1,5 +1,5 @@
<script setup>
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
import ProfileImagesForm from "components/profile/ProfileForm.vue";
import {useAppStore} from "stores/App";
import {storeToRefs} from "pinia";
@@ -10,14 +10,14 @@ import ChangePasswordForm from "components/profile/ChangePasswordForm.vue";
import PublicProfileInfo from "components/profile/PublicProfileInfo.vue";
useAppStore().setPageTitle("Profil");
const loggedUser = storeToRefs(useUserStore()).user;
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const $q = useQuasar();
const handleSave = async (formData) => {
await useUserStore().update(formData);
await useLoggedUserStore().update(formData);
$q.notify(successObject("Úspěšně uloženo"));
};
const isRequestPending = storeToRefs(useUserStore()).isMutationRunning;
const isRequestPending = storeToRefs(useLoggedUserStore()).isMutationRunning;
const openedTab = ref("profile");
</script>
+51
View File
@@ -0,0 +1,51 @@
<script setup>
import {useRoute, useRouter} from "vue-router";
import {computed, onBeforeMount, onUnmounted, ref} from "vue";
import {useAuthStore} from "stores/Auth";
const $route = useRoute();
const $router = useRouter();
const url = computed(() => $route.query.url || '/moje');
const showLoginInfo = ref(false);
const defaultRedirectTimeout = ref(null);
onBeforeMount(() => {
useAuthStore().refreshAccessToken()
.then(() => $router.push(url.value))
.catch(e => $router.push({name: "login"}));
setTimeout(() => showLoginInfo.value = true, 1000);
defaultRedirectTimeout.value = setTimeout(() => $router.push(url.value), 10000);
});
onUnmounted(() => {
clearTimeout(defaultRedirectTimeout.value);
})
</script>
<template>
<div class="row justify-center">
<div class="col-sm-12 col-4">
<transition
v-if="showLoginInfo"
appear
enter-active-class="animated fadeIn"
leave-active-class="animated fadeOut"
>
<q-card style="max-width: 100%" class="q-my-lg">
<q-card-section class="text-center">
<q-spinner size="80px" color="primary"/>
<br>
<p class="q-my-lg">
Probíhá přihlašování. Za okamžik dojde k přesměrování. Pokud se tak nestane, klikni na tlačítko
</p>
</q-card-section>
<q-card-actions>
<q-btn class="full-width" color="primary" size="lg" :to="url">Návrat do rozhraní</q-btn>
</q-card-actions>
</q-card>
</transition>
</div>
</div>
</template>
@@ -1,30 +1,30 @@
<script setup>
import {useRoute, useRouter} from "vue-router";
import {copyToClipboard, useMeta, useQuasar} from "quasar";
import {useMeta, useQuasar} from "quasar";
import AircraftDetailSkeleton from "components/aircrafts/skeletons/AircraftDetailSkeleton.vue";
import {confirmObject, successObject} from "src/composables/dialog";
import {useAppStore} from "stores/App";
import FlightsList from "components/flights/FlightsList.vue";
import AircraftDetailCard from "components/aircrafts/AircraftInfo.vue";
import {storeToRefs} from "pinia";
import {useAircraftsStore} from "stores/Aircrafts";
import {computed, watch} from "vue";
import {watch} from "vue";
import {metaData} from "src/composables/meta";
import PhotoGallery from "components/photos/PhotoGallery.vue";
import TitlePhotoWithGallery from "components/photos/TitlePhotoWithGallery.vue";
import ContentCard from "components/ContentCard.vue";
import AircraftInfo from "components/aircrafts/AircraftInfo.vue";
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
import {useFlightsStore} from "stores/Flights";
import {toggleVisibility} from "src/composables/form/formHandling";
import ContentCardEditButton from "components/ContentCardEditButton.vue";
const $q = useQuasar();
const $router = useRouter();
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import PublishButton from "components/inputs/PublishButton.vue";
const aircraftId = parseInt(useRoute().params.id);
useAircraftsStore().fetchDetail(aircraftId);
const loggedUser = storeToRefs(useUserStore()).user;
const {aircraft, isQueryRunning} = storeToRefs(useAircraftsStore());
watch(() => aircraft.value, (newVal) => {
if (!newVal) return;
@@ -37,31 +37,31 @@ watch(() => aircraft.value, (newVal) => {
])
});
const $q = useQuasar();
const $router = useRouter();
const deleteAircraft = (id) => {
$q.dialog(confirmObject('Opravdu chceš smazat toto letadlo?'))
.onOk(() => {
useAircraftsStore().destroy(id).then(() => {
$router.push({name: "aircrafts"});
$q.notify(successObject("Letadlo úspěšně odstraněno"));
});
.onOk(() => {
useAircraftsStore().destroy(id).then(() => {
$router.push({name: "aircrafts"});
$q.notify(successObject("Letadlo úspěšně odstraněno"));
});
});
};
const togglePublicVisibility = () => toggleVisibility({
$q,
store: useAircraftsStore(),
object: aircraft.value
const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useAircraftsStore(), object: aircraft.value, variables
});
const copyLinkToClipboard = () => {
const url = $router.resolve({
name: "publicAircraftDetail",
params: {id: aircraftId}
});
copyToClipboard(window.location.origin + url.href).then(() => {
$q.notify(successObject("Odkaz zkopírován"))
});
};
filters.value = {aircraftId: aircraftId};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch,
appendItems
});
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
loadNextFlightsPage(false);
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script>
<template>
@@ -71,18 +71,15 @@ const copyLinkToClipboard = () => {
<ContentCard :title-photo="aircraft.titlePhoto" :title="aircraft.callSign">
<template #actions v-if="aircraft.createdById === loggedUser?.id">
<div class="col-grow">
<q-btn
@click="togglePublicVisibility"
:icon="aircraft.isPublic ? 'visibility_off' : 'visibility'"
flat
:label="aircraft.isPublic ? 'Skrýt' : 'Zveřejnit'"
<PublishButton
url-prefix="/letadla"
:is-public="aircraft.isPublic"
:require-url-slug="false"
@change-visibility="togglePublicVisibility"
/>
<q-btn
icon="link"
@click="copyLinkToClipboard()"
flat
v-if="aircraft.isPublic"
label="Získat odkaz"
<CopyLinkButton
v-if="aircraft.isPublic"
:route="{name: 'publicAircraftDetail', params: {callsign: aircraft.callSign}}"
/>
</div>
<q-btn @click="deleteAircraft(aircraft.id)" icon="delete" flat text-color="red" label="Smazat"/>
@@ -91,21 +88,33 @@ const copyLinkToClipboard = () => {
<q-card-section>
<ContentCardEditButton
:to='{name: "aircraftEdit", params: {id: aircraft.id, step: "info"}}'
label="Upravit informace"
v-if="aircraft.createdById === loggedUser?.id"
:to='{name: "aircraftEdit", params: {id: aircraft.id, step: "info"}}'
label="Upravit informace"
/>
<AircraftInfo :aircraft="aircraft"/>
<ContentCardEditButton
:to='{name: "aircraftEdit", params: {id: aircraft.id, step: "photo"}}'
label="Titulní fotografie"
v-if="aircraft.createdById === loggedUser?.id"
:to='{name: "aircraftEdit", params: {id: aircraft.id, step: "photo"}}'
label="Titulní fotografie"
/>
<PhotoGallery :photos="aircraft.photos"/>
<h3>Lety</h3>
<q-banner v-if="aircraft.flights.length === 0" class="bg-light-blue text-white">
<FlightsListSkeleton v-if="areFlightsLoading && flights.length === 0"/>
<BasicInfoBanner v-if="flights.length === 0">
Zatím žádné lety.
</q-banner>
<FlightsList :items="aircraft.flights" :public="false" v-else/>
</BasicInfoBanner>
<FlightsList
v-else-if="flights.length > 0"
:public="false"
:items="flights"
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
@load-more="loadNextFlightsPage(true)"
/>
</q-card-section>
</template>
</ContentCard>
+18 -4
View File
@@ -6,18 +6,26 @@ import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useAircraftsStore} from "stores/Aircrafts";
import {storeToRefs} from "pinia";
import {useAppStore} from "stores/App";
import FlightsList from "components/flights/FlightsList.vue";
import {ref} from "vue";
import {useFlightsStore} from "stores/Flights";
import {loadPage, paginationLimit} from "src/composables/pagination";
useAircraftsStore().fetch();
const {aircrafts, isQueryRunning} = storeToRefs(useAircraftsStore());
useAppStore().setPageTitle("Letadla");
const loadNextAircraftsPage = (appendItems) => loadPage({
fetchFunc: useAircraftsStore().fetch, appendItems
});
loadNextAircraftsPage(false);
const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsStore());
</script>
<template>
<BasicListSkeleton v-if="isQueryRunning || !aircrafts.length"/>
<BasicListSkeleton :items="paginationLimit" v-if="isQueryRunning && !totalItemsCount"/>
<BasicInfoBanner
v-if="!isQueryRunning && aircrafts.length === 0"
v-if="!isQueryRunning && totalItemsCount === 0"
:route-add="{name: 'addAircraft'}"
label-add="Vytvořit letadlo"
>
@@ -29,6 +37,12 @@ useAppStore().setPageTitle("Letadla");
<AircraftsList
v-else
:items="aircrafts"
:total-items-count="totalItemsCount"
:is-query-running="isQueryRunning"
:items-per-page="paginationLimit"
:public="true"
class="q-mb-xl"
@load-more="loadNextAircraftsPage(true)"
/>
<FAB mode="add" :router-link="{name: 'addAircraft'}"/>
@@ -9,15 +9,16 @@ import {successObject} from "src/composables/dialog";
import {useAppStore} from "stores/App";
import AccessDeniedBanner from "components/banners/AccessDeniedBanner.vue";
import {storeToRefs} from "pinia";
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const $router = useRouter();
const $q = useQuasar();
const aircraftId = parseInt(useRoute().params.id);
const loggedUser = storeToRefs(useUserStore()).user;
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {aircraft, isMutationRunning, isQueryRunning} = storeToRefs(useAircraftsStore());
@@ -56,9 +57,10 @@ const formStep = computed(() => useRoute().params.step || "info");
<template v-else>
<CommonEditSwitcher :route="{name: 'aircraftEdit', params: {id: aircraft?.id}}"/>
<template v-if="formStep === 'basic'">
<template v-if="formStep === 'info'">
<AircraftEditSkeleton v-if="isQueryRunning || !aircraft"/>
<AircraftForm
v-else
@save="handleSave"
:default-values="aircraft"
:organizations="loggedUser.organizations"
@@ -68,6 +70,9 @@ const formStep = computed(() => useRoute().params.step || "info");
<template v-else-if="formStep === 'photo'">
<PhotoGallerySkeleton v-if="isQueryRunning || !aircraft"/>
<BasicInfoBanner v-else-if="aircraft && aircraft.photos.length === 0">
U tohoto letadla nemáš žádnou fotku. Můžeš ji přidat v editaci fotky.
</BasicInfoBanner>
<PhotosEditorList
v-else
:photos="aircraft.photos"
+78 -40
View File
@@ -8,57 +8,95 @@ import {useCopilotsStore} from "stores/Copilots";
import {storeToRefs} from "pinia";
import PhotoGallery from "components/photos/PhotoGallery.vue";
import ContentCard from "components/ContentCard.vue";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {useFlightsStore} from "stores/Flights";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {useMeta, useQuasar} from "quasar";
import {useLoggedUserStore} from "stores/LoggedUser";
import PublishButton from "components/inputs/PublishButton.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import {toggleVisibility} from "src/composables/form/formHandling";
import {metaData} from "src/composables/meta";
const copilotId = parseInt(useRoute().params.id);
onMounted(() => {
useCopilotsStore().fetchDetail(copilotId).then(() => {
useAppStore().setPageTitle(copilot.value.name);
useAppStore().setBreadcrumbs([
{to: {name: "copilots"}, label: "Kopiloti"},
{label: copilot.value.name}
])
});
useCopilotsStore().fetchDetail(copilotId).then(() => {
useAppStore().setPageTitle(copilot.value.name);
useAppStore().setBreadcrumbs([
{to: {name: "copilots"}, label: "Kopiloti"},
{label: copilot.value.name}
])
});
const $q = useQuasar();
const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useCopilotsStore(), object: copilot.value, variables
});
filters.value = {copilotId: copilotId};
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch, appendItems
});
loadNextFlightsPage(false);
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
const {isQueryRunning, copilot} = storeToRefs(useCopilotsStore());
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script>
<template>
<CopilotDetailSkeleton v-if="isQueryRunning || !copilot"/>
<template v-else>
<ContentCard :title-photo="copilot.titlePhoto" :title="copilot.name" v-else>
<template #actions>
<div class="col-grow">
<q-btn
flat
:to="{name: 'copilotEdit', params: {id: copilotId}}"
icon="edit"
text-color="primary"
label="Upravit"
/>
<ContentCard :title-photo="copilot.titlePhoto" :title="copilot.name">
<template #actions>
<div class="col-grow">
<q-btn
flat
:to="{name: 'copilotEdit', params: {id: copilotId}}"
icon="edit"
text-color="primary"
label="Upravit"
/>
</div>
</template>
<template #content>
<q-card-section>
<PhotoGallery :photos="copilot.photos" v-if="copilot.photos.length > 0"/>
<q-banner v-else class="bg-light-blue text-white">
Zatím tu nemáš žádné společné fotky. Po nahrání fotky můžeš označit tuto osobu a takto označené
fotografie se zobrazí v galerii.
</q-banner>
<PublishButton
:url-prefix="`/${loggedUser?.publicUsername}/a/`"
:is-public="copilot.isPublic"
:require-url-slug="copilot.urlSlug === ''"
@change-visibility="togglePublicVisibility"
/>
<CopyLinkButton
v-if="copilot.isPublic"
:route="{
name: 'publicCopilotDetail', params: {
username: loggedUser.publicUsername,
copilotSlug: copilot.urlSlug
}}"
/>
</div>
</template>
<template #content>
<q-card-section>
<PhotoGallery :photos="copilot.photos" v-if="copilot.photos.length > 0"/>
<q-banner v-else class="bg-light-blue text-white">
Zatím tu nemáš žádné společné fotky. Po nahrání fotky můžeš označit tuto osobu a takto označené
fotografie se zobrazí v galerii.
</q-banner>
<h3>Společné lety</h3>
<h3>Společné lety</h3>
<q-banner v-if="copilot.flights.length === 0" class="bg-light-blue text-white">
Zatím tu nemáš uložené žádné společné lety.
</q-banner>
<FlightsList :items="copilot.flights" :public="false" v-else/>
</q-card-section>
</template>
</ContentCard>
</template>
<FlightsListSkeleton v-if="areFlightsLoading && !flights.length"/>
<FlightsList
v-else-if="flights.length > 0"
:public="false"
:items="flights"
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
@load-more="loadNextFlightsPage(true)"
/>
<q-banner v-if="flights.length === 0" class="bg-light-blue text-white">
Zatím tu nemáš uložené žádné společné lety.
</q-banner>
</q-card-section>
</template>
</ContentCard>
</template>
@@ -11,6 +11,7 @@ import {storeToRefs} from "pinia";
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const $q = useQuasar();
const $router = useRouter();
@@ -61,6 +62,9 @@ const setTitlePhoto = (titlePhoto) => {
<template v-else-if="formStep === 'photo'">
<PhotoGallerySkeleton v-if="isQueryRunning || !copilot" />
<BasicInfoBanner v-else-if="!isQueryRunning && copilot.photos.length === 0">
U tohoto kopilota nemáš označenou žádnou fotku. Bez toho není možné zvolit úvodní fotografii.
</BasicInfoBanner>
<PhotosEditorList
v-else
:photos="copilot.photos"
+43 -2
View File
@@ -8,6 +8,14 @@ import FlightsList from "components/flights/FlightsList.vue";
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
import {useEventsStore} from "stores/Events";
import {storeToRefs} from "pinia";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {useFlightsStore} from "stores/Flights";
import PublishButton from "components/inputs/PublishButton.vue";
import {useLoggedUserStore} from "stores/LoggedUser";
import {toggleVisibility} from "src/composables/form/formHandling";
import {TOGGLE_FLIGHT_PUBLISH} from "src/graphql/mutations/flight";
import {useQuasar} from "quasar";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
const eventId = parseInt(useRoute().params.id);
onMounted(() => {
@@ -18,9 +26,25 @@ onMounted(() => {
{label: event.value.name}
])
});
loadNextFlightsPage(false);
})
const $q = useQuasar();
const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useEventsStore(), object: event.value, variables
});
const {isQueryRunning, event} = storeToRefs(useEventsStore());
filters.value = {eventId: eventId}
const loadNextFlightsPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch, appendItems
});
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script>
<template>
@@ -41,6 +65,16 @@ const {isQueryRunning, event} = storeToRefs(useEventsStore());
</q-item>
</q-list>
<PublishButton
:url-prefix="`${loggedUser.publicUsername}/udalosti/`"
:is-public="event.isPublic"
:require-url-slug="event.urlSlug === ''"
@change-visibility="togglePublicVisibility"
/>
<CopyLinkButton
:route="{name: 'publicEventDetail', params: {username: loggedUser.publicUsername, eventSlug: event.urlSlug}}"
/>
<article v-html="event.description"/>
<h3>Lety</h3>
@@ -49,8 +83,15 @@ const {isQueryRunning, event} = storeToRefs(useEventsStore());
Zatím tu nemáš uložené žádné lety.
</q-banner>
<FlightsList :items="event.flights" :public="false" v-else/>
<FlightsList
v-else-if="flights.length > 0"
:public="false"
:items="flights"
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
@load-more="loadNextFlightsPage(true)"
/>
<FAB mode="edit" :router-link="{name: 'eventEdit', params: {id: eventId}}"/>
</template>
</template>
+6 -1
View File
@@ -6,10 +6,14 @@ import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import EventsList from "components/event/EventsList.vue";
import {storeToRefs} from "pinia";
import {useEventsStore} from "stores/Events";
import {onBeforeMount} from "vue";
useEventsStore().fetch();
useAppStore().setPageTitle("Letecké události");
onBeforeMount(() => {
useEventsStore().fetch({});
});
const {isQueryRunning, events} = storeToRefs(useEventsStore());
</script>
@@ -31,6 +35,7 @@ const {isQueryRunning, events} = storeToRefs(useEventsStore());
<EventsList
v-else
:items="events"
:route-detail="{name: 'eventDetail', params: {}}"
/>
<FAB :router-link="{name: 'addEvent'}" mode="add"/>
+34 -26
View File
@@ -42,11 +42,11 @@ watch(() => $route.params.step, (val) => formStep.value = val);
onMounted(() => {
formStep.value = $route.params.step || "basic";
useAircraftsStore().fetch();
usePointsOfInterestStore().fetch();
useAircraftsStore().fetch({});
usePointsOfInterestStore().fetch({});
useAirportsStore().fetch();
useCopilotsStore().fetch();
useEventsStore().fetch();
useEventsStore().fetch({});
useFlightsStore().fetchDetail(flightId).then((f) => {
const flightNameFormatted = flightName(f);
@@ -133,7 +133,7 @@ const updateSinglePhoto = (id, formData) => {
return usePhotosStore().update(id, formData).then((data) => {
const idx = _findPhotoIndex(data.id);
flight.value.photos[idx] = data;
usePointsOfInterestStore().fetch();
usePointsOfInterestStore().fetch({});
});
}
@@ -208,38 +208,44 @@ const handleBatchPhotoEdit = async (flightId, data) => {
<q-tab-panel name="basic">
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
<FlightFormBasic
@save="handleFlightEdit"
:flight="flight"
:locations="pointsOfInterest"
:airports="airports"
:aircrafts="aircrafts"
v-else
@save="handleFlightEdit"
:flight="flight"
:locations="pointsOfInterest"
:airports="airports"
:aircrafts="aircrafts"
/>
</q-tab-panel>
<q-tab-panel name="copilot">
<FlightEditCopilotSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
<FlightFormCopilot @save="handleFlightEdit" :flight="flight" :copilots="copilots"/>
<FlightEditCopilotSkeleton v-if="isQueryRunning || !flight"/>
<FlightFormCopilot
v-else
@save="handleFlightEdit"
:flight="flight"
:copilots="copilots"/>
</q-tab-panel>
<q-tab-panel name="track">
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
<FlightFormTrack
v-if="pointsOfInterest"
:flight="flight"
:locations="pointsOfInterest"
:airports="airports"
@save="handleFlightEdit"
v-if="flight && pointsOfInterest"
:flight="flight"
:locations="pointsOfInterest"
:airports="airports"
@save="handleFlightEdit"
/>
</q-tab-panel>
<q-tab-panel name="photos">
<FlightFormPhotos
:flight="flight"
:is-request-pending="isRequestPending || uploadInProgress"
@save="handlePhotosUpload"
:flight="flight"
:is-request-pending="isRequestPending || uploadInProgress"
@save="handlePhotosUpload"
/>
<PhotosEditorList
<template v-if="flight">
<PhotosEditorList
:title-photo="flight.titlePhoto"
:photos="flight.photos"
:show-actions="true"
@@ -248,19 +254,19 @@ const handleBatchPhotoEdit = async (flightId, data) => {
@batch-rotate="rotatePhotos"
@delete="deletePhoto"
@set-title-photo="setTitlePhoto"
/>
/>
<!-- Dialogs for photo edit -->
<PhotoFormDialog
<!-- Dialogs for photo edit -->
<PhotoFormDialog
v-model="openEdit"
:copilots="flight.copilots"
:aircrafts="aircrafts"
:locations="pointsOfInterest"
:photo="editPhoto"
@save="handlePhotoUpdate(flightId, $event)"
/>
/>
<BatchPhotoFormDialog
<BatchPhotoFormDialog
v-model="openBatchEdit"
:locations="pointsOfInterest"
:photos="editBatchPhotos"
@@ -268,7 +274,9 @@ const handleBatchPhotoEdit = async (flightId, data) => {
:aircrafts="aircrafts"
@save="handleBatchPhotoEdit(flightId, $event)"
@delete="deletePhoto"
/>
/>
</template>
</q-tab-panel>
<q-tab-panel name="notes">
+69 -67
View File
@@ -1,29 +1,37 @@
<script>
import {useFlightsStore} from "stores/Flights";
export default {
preFetch({currentRoute}) {
useFlightsStore().fetchDetail(currentRoute.params.id);
}
}
</script>
<script setup>
import {useRoute, useRouter} from "vue-router";
import {computed, onMounted, ref, watch} from "vue";
import {watch} from "vue";
import FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue";
import {flightName} from "src/composables/flight";
import {useAppStore} from "stores/App";
import FlightBasicInfo from "components/flights/FlightBasicInfo.vue";
import {copyToClipboard, useQuasar} from "quasar";
import {useQuasar} from "quasar";
import {confirmObject, successObject} from "src/composables/dialog";
import {useFlightsStore} from "stores/Flights";
import FlightTrackMap from "components/flights/FlightTrackMap.vue";
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
import {storeToRefs} from "pinia";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import PhotoGallery from "components/photos/PhotoGallery.vue";
import TitlePhotoWithGallery from "components/photos/TitlePhotoWithGallery.vue";
import ContentCard from "components/ContentCard.vue";
import ContentCardEditButton from "components/ContentCardEditButton.vue";
import {toggleVisibility} from "src/composables/form/formHandling";
import PublishButton from "components/inputs/PublishButton.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import {TOGGLE_FLIGHT_PUBLISH} from "src/graphql/mutations/flight";
const $q = useQuasar();
const $router = useRouter();
const flightId = parseInt(useRoute().params.id);
const loggedUser = storeToRefs(useUserStore()).user;
const loggedUser = storeToRefs(useLoggedUserStore()).user;
useFlightsStore().fetchDetail(flightId);
const {isQueryRunning, flight} = storeToRefs(useFlightsStore());
watch(() => flight.value, (f) => {
if (!f) return;
@@ -35,113 +43,107 @@ watch(() => flight.value, (f) => {
]);
});
const $q = useQuasar();
const $router = useRouter();
const handleDeleteAndRedirect = () => {
$q.dialog(confirmObject("Opravdu chceš smazat tento let?"))
.onOk(() => {
useFlightsStore().destroy(flightId).then(() => {
$router.push({name: "myFlights"});
$q.notify(successObject(`Úspěšně smazáno`));
});
.onOk(() => {
useFlightsStore().destroy(flightId).then(() => {
$router.push({name: "myFlights"});
$q.notify(successObject(`Úspěšně smazáno`));
});
});
}
const togglePublicVisibility = () => toggleVisibility({
$q,
store: useFlightsStore(),
object: flight.value
const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useFlightsStore(), object: flight.value, mutation: TOGGLE_FLIGHT_PUBLISH, variables
});
const copyLinkToClipboard = () => {
const loggedUser = useUserStore().user;
const url = $router.resolve({
name: "publicFlightDetail",
params: {id: flightId, username: loggedUser.publicUsername}
});
copyToClipboard(window.location.origin + url.href).then(() => {
$q.notify(successObject("Odkaz zkopírován"))
});
};
</script>
<template>
<FlightDetailSkeleton v-if="isQueryRunning || !flight"/>
<ContentCard :title-photo="flight.titlePhoto" :title="flightName(flight)" v-else>
<template #actions>
<div class="col-grow">
<q-btn
@click="togglePublicVisibility"
:icon="flight.isPublic ? 'visibility_off' : 'visibility'"
:disable="!loggedUser?.publicUsername"
flat
:label="flight.isPublic ? 'Skrýt' : 'Zveřejnit'"
/>
<q-btn
icon="link"
@click="copyLinkToClipboard()"
flat
<template v-if="loggedUser.publicUsername">
<div class="col-grow">
<PublishButton
:url-prefix="`/${loggedUser.publicUsername}/`"
:is-public="flight.isPublic"
:require-url-slug="!flight.urlSlug"
@change-visibility="togglePublicVisibility"
/>
<CopyLinkButton
:route='{
name: "publicFlightDetail",
params: {flightSlug: flight.urlSlug, username: loggedUser.publicUsername}
}'
v-if="flight.isPublic"
label="Získat odkaz"
/>
</div>
/>
</div>
</template>
<q-btn @click="handleDeleteAndRedirect" icon="delete" flat text-color="red" label="Smazat"/>
</template>
<template #content>
<q-card-section>
<ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'basic'}}"
label="Upravit informace"
:to="{name: 'flightEdit', params: {id: flightId, step: 'basic'}}"
label="Upravit informace"
/>
<FlightBasicInfo
:flight="flight"
:poi-detail-route="{name: 'poiDetail'}"
:aircraft-detail-route="{name: 'aircraftDetail'}"
:copilot-detail-route="{name: 'copilotDetail'}"
:flight="flight"
:poi-detail-route="{name: 'poiDetail'}"
:aircraft-detail-route="{name: 'aircraftDetail'}"
:copilot-detail-route="{name: 'copilotDetail'}"
/>
<ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'notes'}}"
label="Upravit text"
:to="{name: 'flightEdit', params: {id: flightId, step: 'notes'}}"
label="Upravit text"
/>
<article class="q-my-lg" v-html="flight.description"/>
<BasicInfoBanner v-if="flight.description === ''">Popiš, jaký byl let.</BasicInfoBanner>
<q-separator/>
<article class="q-my-lg" v-html="flight.debriefingNotes"/>
<BasicInfoBanner v-if="flight.debriegingNotes === ''">
Nezapomeň si poznamenat důležité informace pro debriefing. Tyto poznámky slouží jen pro tebe.
</BasicInfoBanner>
<q-separator/>
<ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
label="Upravit fotografie"
:to="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
label="Upravit fotografie"
/>
<BasicInfoBanner
v-if="flight.photos.length === 0"
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
v-if="flight.photos.length === 0"
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
>
U tohoto letu nemáš nahrané žádné fotografie.
</BasicInfoBanner>
<PhotoGallery
v-else
:photos="flight.photos"
v-else
:photos="flight.photos"
/>
<q-separator/>
<ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
label="Upravit trasu"
:to="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
label="Upravit trasu"
/>
<BasicInfoBanner
v-if="flight.track.length === 0 && !flight.gpxTrack"
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
v-if="flight.track.length === 0 && !flight.gpxTrack"
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
>
Nemáš nastavenou žádnou trasu.
</BasicInfoBanner>
<FlightTrackMap
v-else
:flight="flight"
:photos="flight.photos"
v-else
:flight="flight"
:photos="flight.photos"
/>
</q-card-section>
</template>
+19 -5
View File
@@ -6,24 +6,38 @@ import {useAppStore} from "stores/App";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useFlightsStore} from "stores/Flights";
import {storeToRefs} from "pinia";
import {loadPage, paginationLimit} from "src/composables/pagination";
useAppStore().setPageTitle("Moje lety");
useFlightsStore().fetch({});
const {flights, isQueryRunning} = storeToRefs(useFlightsStore());
const loadNextPage = (appendItems) => loadPage({
fetchFunc: useFlightsStore().fetch, appendItems
});
loadNextPage(false);
const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore());
</script>
<template>
<FlightsListSkeleton v-if="isQueryRunning"/>
<FlightsListSkeleton v-if="isQueryRunning && flights.length === 0"/>
<BasicInfoBanner
v-else-if="!isQueryRunning && flights.length === 0"
v-else-if="!isQueryRunning && totalItemsCount === 0"
:route-add="{name: 'addFlight'}"
label-add="Přidat let"
>
Nemáš zaznamenaný žádný let.
</BasicInfoBanner>
<FlightsList v-else :items="flights" :public="false" />
<FlightsList
v-else
:items="flights"
:public="false"
:is-query-running="isQueryRunning"
:items-per-page="paginationLimit"
:total-items-count="totalItemsCount"
@load-more="loadNextPage(true)"
/>
<FAB mode="add" :router-link="{name: 'addFlight'}"/>
</template>
+3 -4
View File
@@ -1,5 +1,5 @@
<script setup>
import {computed, onMounted, ref} from "vue";
import {computed, onMounted} from "vue";
import {useAircraftsStore} from "stores/Aircrafts";
import {useFlightsStore} from "stores/Flights";
import {useRouter} from "vue-router";
@@ -12,9 +12,8 @@ import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
onMounted(() => {
useAircraftsStore().fetch()
usePointsOfInterestStore().fetch()
useFlightsStore().fetch()
useAircraftsStore().fetch({})
usePointsOfInterestStore().fetch({})
useAirportsStore().fetch()
useAppStore().setPageTitle("Nový let");
@@ -4,7 +4,7 @@ import {computed, onMounted} from "vue";
import {useAppStore} from "stores/App";
import FAB from "components/FAB.vue";
import {storeToRefs} from "pinia";
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import AircraftsList from "components/aircrafts/AircraftsList.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
@@ -13,7 +13,7 @@ import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import {useOrganizationsStore} from "stores/Organizations";
const organizationId = parseInt(useRoute().params.id);
const loggedUser = storeToRefs(useUserStore()).user;
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const $q = useQuasar();
onMounted(() => {
@@ -67,7 +67,11 @@ const {isQueryRunning, organization} = storeToRefs(useOrganizationsStore());
<BasicListSkeleton :items-count-min="1" :items-count-max="5" v-if="isQueryRunning || !organization"/>
<template v-else>
<AircraftsList :items="organization.aircrafts" v-if="organization.aircrafts.length > 0"/>
<AircraftsList
v-if="organization.aircrafts.length > 0"
:items="organization.aircrafts"
:total-items-count="organization.aircrafts.length"
/>
<BasicInfoBanner v-else>
Žádné letadlo
</BasicInfoBanner>
@@ -7,12 +7,12 @@ import OrganizationsList from "components/organizations/OrganizationsList.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useOrganizationsStore} from "stores/Organizations";
import {storeToRefs} from "pinia";
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
useOrganizationsStore().fetch();
useAppStore().setPageTitle("Organizace");
const loggedUser = storeToRefs(useUserStore()).user;
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const myOrganizationIds = computed(() => (loggedUser.value && loggedUser.value.organizations?.map(o => o.id)) || []);
const {isQueryRunning, organizations} = storeToRefs(useOrganizationsStore());
@@ -9,12 +9,13 @@ import {successObject} from "src/composables/dialog";
import {usePointsOfInterestTypeStore} from "stores/PointOfInterestType";
import {storeToRefs} from "pinia";
import POIForm from "components/poi/POIForm.vue";
import {useUserStore} from "stores/User";
import {useLoggedUserStore} from "stores/LoggedUser";
import AccessDeniedBanner from "components/banners/AccessDeniedBanner.vue";
import PhotoEditorSkeleton from "components/photos/skeletons/PhotoEditorSkeleton.vue";
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const poiId = parseInt(useRoute().params.id);
@@ -32,7 +33,7 @@ usePointsOfInterestStore().fetchDetail(poiId).then((p) => {
usePointsOfInterestTypeStore().fetch();
const poiTypes = storeToRefs(usePointsOfInterestTypeStore()).types;
const loggedUser = storeToRefs(useUserStore()).user;
const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {isQueryRunning, pointOfInterest, isMutationRunning} = storeToRefs(usePointsOfInterestStore());
const $q = useQuasar();
@@ -69,6 +70,9 @@ const formStep = computed(() => useRoute().params.step || "info");
<template v-else-if="formStep === 'photo'">
<PhotoGallerySkeleton v-if="isQueryRunning || !pointOfInterest"/>
<BasicInfoBanner v-else-if="!isQueryRunning && pointOfInterest.photos.length === 0">
Toto místo není označené na žádné fotografii.
</BasicInfoBanner>
<PhotosEditorList
v-else
:photos="pointOfInterest.photos"

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