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; #tcp_nopush on;
keepalive_timeout 65; keepalive_timeout 65;
gzip on;
#gzip on;
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
map $sent_http_content_type $expires { map $sent_http_content_type $expires {
@@ -34,6 +32,10 @@ http {
text/html 0; text/html 0;
} }
map $host $api_url {
default "$API_URL";
}
server { server {
listen 80 default; listen 80 default;
server_name poletani.cz; server_name poletani.cz;
@@ -46,5 +48,9 @@ http {
expires $expires; expires $expires;
try_files $uri /index.html; try_files $uri /index.html;
} }
location /sitemap.xml {
try_files $uri $api_url;
}
} }
} }
+2
View File
@@ -10,3 +10,5 @@ services:
ports: ports:
# - 9000:80 # - 9000:80
- 9000:3000 - 9000:3000
environment:
API_URL: "https://api.poletani.cz"
+4 -3
View File
@@ -33,11 +33,12 @@ services:
- "9001:9100" - "9001:9100"
volumes: volumes:
- ./src:/app/src - ./src:/app/src
- ./src-ssr:/app/src-ssr
- ./public:/app/public - ./public:/app/public
- ./node_modules:/app/node_modules:delegated # - ./node_modules:/app/node_modules:delegated
command: quasar dev -m ssr command: quasar dev # -m ssr
environment: environment:
API_URL: "http://localhost:8000" API_URL: "http://127.0.0.1:8000"
# nginx: # nginx:
# image: # image:
+9 -9
View File
@@ -15,28 +15,28 @@
"@vue/apollo-composable": "4.0.0-beta.2", "@vue/apollo-composable": "4.0.0-beta.2",
"@vue/apollo-option": "^4.0.0-beta.5", "@vue/apollo-option": "^4.0.0-beta.5",
"apollo-upload-client": "^17.0.0", "apollo-upload-client": "^17.0.0",
"axios": "^1.4.0", "axios": "^1.6.2",
"chart.js": "^4.4.0", "chart.js": "^4.4.1",
"graphql": "16.6.0", "graphql": "16.6.0",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"photoswipe": "^5.4.2", "photoswipe": "^5.4.2",
"photoswipe-dynamic-caption-plugin": "^1.2.7", "photoswipe-dynamic-caption-plugin": "^1.2.7",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"quasar": "^2.14.1", "quasar": "^2.14.1",
"vue": "^3.2.47", "vue": "^3.3.11",
"vue-chartjs": "^5.2.0", "vue-chartjs": "^5.2.0",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",
"vue-router": "^4.1.6", "vue-router": "^4.2.5",
"vue3-google-map": "^0.15.0" "vue3-google-map": "^0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@quasar/app-vite": "^1.7.1", "@quasar/app-vite": "^1.7.1",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.16",
"eslint": "^8.10.0", "eslint": "^8.55.0",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.10.0",
"eslint-plugin-vue": "^9.0.0", "eslint-plugin-vue": "^9.19.2",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"prettier": "^2.5.1" "prettier": "^2.8.8"
}, },
"engines": { "engines": {
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

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

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+15 -10
View File
@@ -1,17 +1,18 @@
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core' import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core'
import {onError} from "@apollo/client/link/error"; import {onError} from "@apollo/client/link/error";
import {useUserStore} from "stores/User";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import {Notify} from "quasar"; import {Notify} from "quasar";
import {errorObject, warningObject} from "src/composables/dialog"; import {errorObject, warningObject} from "src/composables/dialog";
import {createUploadLink} from "apollo-upload-client"; import {createUploadLink} from "apollo-upload-client";
import {useAuthStore} from "stores/Auth";
import {useRouter} from "vue-router";
// import createUploadLink from "apollo-upload-client/createUploadLink.mjs"; // import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
const handleRefreshToken = (store, router, forward, operation) => { const handleRefreshToken = (store, router, forward, operation) => {
if (!operation.query?._refreshUnauthorizedUser) return; if (!operation.query?._refreshUnauthorizedUser) return;
console.log("refreshing token, ", operation.query); console.log("refreshing token, ", operation.query);
return fromPromise(useUserStore(store).refreshAccessToken() return fromPromise(useAuthStore(store).refreshAccessToken()
.catch((error) => { .catch((error) => {
Notify.create(warningObject("Tvoje přihlášení vypřešlo. Přihlaš se prosím znovu.")); Notify.create(warningObject("Tvoje přihlášení vypřešlo. Přihlaš se prosím znovu."));
router.push({name: "login"}) router.push({name: "login"})
@@ -39,8 +40,7 @@ export default (({store, router}) => {
}) })
const authLink = new ApolloLink((operation, forward) => { const authLink = new ApolloLink((operation, forward) => {
const accessToken = useUserStore(store).getAccessToken(); const accessToken = useAuthStore(store).getAccessToken();
if (accessToken) { if (accessToken) {
operation.setContext({ operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`}, headers: {"Authorization": `Bearer ${accessToken}`},
@@ -50,17 +50,16 @@ export default (({store, router}) => {
}); });
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => { const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
console.log(networkError, graphQLErrors);
if (networkError?.statusCode === 401) { if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation); return handleRefreshToken(store, router, forward, operation);
} }
if (graphQLErrors) { if (graphQLErrors) {
for (const error of graphQLErrors) { for (const error of graphQLErrors) {
if (error.message === "Not found") { if (error.message.includes("401") || error.message.includes("Not authorized")) {
useAppStore(store).appStatus = 404;
} else if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation); return handleRefreshToken(store, router, forward, operation);
} else if (error.message.includes("404") || error.message.includes("Not found")) {
useAppStore(store).appStatus = 404;
} else { } else {
Notify.create(errorObject("API error: " + error.message)); Notify.create(errorObject("API error: " + error.message));
} }
@@ -80,9 +79,15 @@ export default (({store, router}) => {
}) })
.then((response) => response) .then((response) => response)
.catch(err => { .catch(err => {
if (!err.toString().includes("401") && !err.toString().includes("Not authorized")) const errStr = err.toString();
if (errStr.includes('Not found') || errStr.includes("No row was found")) {
useAppStore(store).appStatus = 404;
return Promise.resolve();
}
else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err) console.error("Error during query: ", err)
return err; }
return Promise.reject(err);
}) })
}; };
apolloWrapper.mutate = (mutation, variables) => { apolloWrapper.mutate = (mutation, variables) => {
+8 -9
View File
@@ -1,6 +1,6 @@
import {boot} from 'quasar/wrappers' import {boot} from 'quasar/wrappers'
import axios from 'axios' import axios from 'axios'
import {useUserStore} from "stores/User"; import {useAuthStore} from "stores/Auth";
const api = axios.create({ const api = axios.create({
@@ -15,17 +15,17 @@ const api = axios.create({
export default boot(({store}) => { export default boot(({store}) => {
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = useUserStore(store).getAccessToken(); const token = useAuthStore(store).getAccessToken();
if (token) { if (token) {
config.headers['Authorization'] = `Bearer ${token}` config.headers['Authorization'] = `Bearer ${token}`
} }
if (['/refresh', '/login'].includes(config.url)) { if (['/refresh', '/login'].includes(config.url)) {
delete config.headers['Authorization']; delete config.headers['Authorization'];
} }
return config; return config;
}) })
// Response interceptor for API calls // Response interceptor for API calls
api.interceptors.response.use( api.interceptors.response.use(
@@ -33,8 +33,7 @@ export default boot(({store}) => {
async function (error) { async function (error) {
const originalRequest = error.config; const originalRequest = error.config;
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) { if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
const newToken = await useUserStore(store).refreshAccessToken().catch(err => { const newToken = await useAuthStore(store).refreshAccessToken().catch(err => {
console.error(err);
return Promise.reject("Logged out"); return Promise.reject("Logged out");
}) })
api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken; api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
+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> <script setup>
import LoadMoreList from "components/LoadMoreList.vue";
import {useAppStore} from "stores/App"; import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: true required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
} }
}); });
useAppStore().setPageTitle("Letadla");
</script> </script>
<template> <template>
<q-list bordered separator> <LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<q-item <template #items>
clickable <q-list bordered separator>
v-ripple <q-item
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}" clickable
v-for="aircraft of props.items" :key="aircraft" v-ripple
> :to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
<q-item-section avatar> v-for="aircraft of props.items" :key="aircraft"
<q-avatar v-if="aircraft.titlePhoto"> >
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/> <q-item-section avatar>
</q-avatar> <q-avatar v-if="aircraft.titlePhoto">
<q-avatar color="teal" text-color="white" icon="flight" v-else/> <img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
</q-item-section> </q-avatar>
<q-item-section> <q-avatar color="teal" text-color="white" icon="flight" v-else/>
<q-item-label label> </q-item-section>
{{ aircraft.callSign }} <q-item-section>
</q-item-label> <q-item-label label>
<q-item-label caption> {{ aircraft.callSign }}
{{ aircraft.model }} </q-item-label>
<template v-if="aircraft.manufacturer"> <q-item-label caption>
({{ aircraft.manufacturer }}) {{ aircraft.model }}
</template> <template v-if="aircraft.manufacturer">
</q-item-label> ({{ aircraft.manufacturer }})
<q-item-label caption v-if="aircraft.organization"> </template>
Provozovatel: {{ aircraft.organization.name }} </q-item-label>
</q-item-label> <q-item-label caption v-if="aircraft.organization">
</q-item-section> Provozovatel: {{ aircraft.organization.name }}
</q-item-label>
</q-item> </q-item-section>
</q-list> </q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
+27 -5
View File
@@ -5,26 +5,34 @@ import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules"; import {isRequired} from "src/composables/form/rules";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
copilot: { copilot: {
type: Object type: Object
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}) })
const formData = reactive({ const formData = reactive({
name: null, name: null,
urlSlug: "",
isPublic: false,
}); });
const rules = { const rules = {
name: [isRequired] name: [isRequired],
urlSlug: [],
} }
const loggedUser = storeToRefs(useLoggedUserStore()).user;
onMounted(() => { onMounted(() => {
if (props.copilot) { if (props.copilot) {
setDefaultData(formData, props.copilot); setDefaultData(formData, props.copilot);
@@ -41,5 +49,19 @@ onMounted(() => {
:rules="rules.name" :rules="rules.name"
v-model="formData.name" v-model="formData.name"
/> />
<template v-if="loggedUser?.publicUsername">
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser.publicUsername}/a/`"
:slug-source="formData.name"
:rules="rules.urlSlug"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
</FormWrapper> </FormWrapper>
</template> </template>
+20 -7
View File
@@ -8,20 +8,24 @@ import {isRequired} from "src/composables/form/rules";
import RichTextEditor from "components/inputs/RichTextEditor.vue"; import RichTextEditor from "components/inputs/RichTextEditor.vue";
import DatetimePicker from "components/inputs/DatetimePicker.vue"; import DatetimePicker from "components/inputs/DatetimePicker.vue";
import {FORMAT_DATE, FORMAT_DATETIME_LONG} from "src/composables/datetimeUtils"; import {FORMAT_DATE, FORMAT_DATETIME_LONG} from "src/composables/datetimeUtils";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
event: { event: {
type: Object type: Object
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}) })
const formData = reactive({ const formData = reactive({
name: null, name: "",
urlSlug: "",
isPublic: false, isPublic: false,
description: "", description: "",
dateFrom: null, dateFrom: null,
@@ -38,6 +42,7 @@ onMounted(() => {
} }
}) })
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script> </script>
<template> <template>
@@ -48,7 +53,15 @@ onMounted(() => {
:rules="rules.name" :rules="rules.name"
v-model="formData.name" v-model="formData.name"
/> />
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser?.publicUsername}/udalosti/`"
:slug-source="formData.name"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<div class="row"> <div class="row">
<div class="col q-pr-sm"> <div class="col q-pr-sm">
@@ -61,7 +74,7 @@ onMounted(() => {
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/> <RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</FormWrapper> </FormWrapper>
</template> </template>
+7 -8
View File
@@ -8,8 +8,7 @@ const props = defineProps({
}, },
routeDetail: { routeDetail: {
type: Object, type: Object,
required: false, required: true,
default: () => ({name: 'eventDetail', params: {}})
}, },
}); });
@@ -19,20 +18,20 @@ const getCoverPhotos = (item) => item.flights.filter(f => !!f.titlePhoto).map(f
<template> <template>
<q-list bordered separator> <q-list bordered separator>
<q-item <q-item
v-for="event of items" :key="event" v-for="eventDetail of items" :key="eventDetail"
clickable clickable
:to="{name: routeDetail.name, params: {...routeDetail.params, id: event.id}}" :to="{name: props.routeDetail.name, params: {...props.routeDetail.params, eventSlug: eventDetail.urlSlug, id: eventDetail.id}}"
v-ripple v-ripple
> >
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
<q-avatar icon="event" color="primary" text-color="white" v-if="!event.flights || event.flights.length === 0"/> <q-avatar icon="event" color="primary" text-color="white" v-if="!eventDetail.flights || eventDetail.flights.length === 0"/>
<AvatarsGroup :avatars="getCoverPhotos(event)" v-else/> <AvatarsGroup :avatars="getCoverPhotos(eventDetail)" v-else/>
{{ event.name }} {{ eventDetail.name }}
</q-item-label> </q-item-label>
<q-item-label lines="4" caption> <q-item-label lines="4" caption>
<article v-html="event.description"/> <article v-html="eventDetail.description"/>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
+17 -10
View File
@@ -22,7 +22,6 @@ const props = defineProps({
required: false, required: false,
default: null default: null
} }
}) })
</script> </script>
@@ -34,8 +33,8 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<router-link <router-link
:to="{...aircraftDetailRoute, params: {...aircraftDetailRoute.params, id: flight.aircraft.id}}" :to="{...aircraftDetailRoute, params: {id: flight.aircraft.id, callSign: flight.aircraft.callSign}}"
v-if="aircraftDetailRoute" v-if="flight.aircraft.isPublic"
> >
{{ flight.aircraft.callSign }} {{ flight.aircraft.callSign }}
</router-link> </router-link>
@@ -62,7 +61,9 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<div> <div>
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}), <template v-if="flight.takeoffAirport">
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}),
</template>
čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }} čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/> <WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/>
@@ -78,8 +79,8 @@ const props = defineProps({
<q-item-section> <q-item-section>
<template v-if="poi.pointOfInterest"> <template v-if="poi.pointOfInterest">
<router-link <router-link
:to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, id: poi.pointOfInterest.id}}" :to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, slug: poi.pointOfInterest.urlSlug, id: poi.pointOfInterest.id}}"
v-if="poi.pointOfInterest.isPublic" v-if="poi.pointOfInterest.isPublic"
> >
{{ poi.pointOfInterest.name }} {{ poi.pointOfInterest.name }}
</router-link> </router-link>
@@ -87,7 +88,11 @@ const props = defineProps({
</template> </template>
<span v-if="poi.airport"> <span v-if="poi.airport">
<strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}), <strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}),
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }} <template v-if="poi.landingDuration > 0">
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }}
</template>
<template v-else>touch and go</template>
</span> </span>
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -98,7 +103,9 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<div> <div>
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}), <template v-if="flight.landingAirport">
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}),
</template>
čas přistání: {{ formatDatetime(flight.landingDatetime, FORMAT_TIME) }} čas přistání: {{ formatDatetime(flight.landingDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/> <WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/>
</div> </div>
@@ -123,8 +130,8 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<router-link <router-link
v-if="copilotDetailRoute" v-if="copilot.isPublic"
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, id: copilot.id}}" :to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
> >
{{ copilot.name }} {{ copilot.name }}
</router-link> </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 mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]);
const pointsOfInterests = computed(() => mapMarkers const pointsOfInterests = computed(() => mapMarkers
.filter(marker => marker.gpsLatitude && marker.gpsLongitude) .filter(marker => marker && marker.gpsLatitude && marker.gpsLongitude)
.map( .map(
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}}) marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
)); ));
@@ -101,16 +101,17 @@ const chartOptions = {
</script> </script>
<template> <template>
<MapWithMarkers <q-no-ssr>
v-if="displayMap" <MapWithMarkers
style="width: 100%; height: 500px;" v-if="displayMap"
:gpx-track="flight.gpxTrack?.coordinates || []" style="width: 100%; height: 500px;"
:markers="pointsOfInterests" :gpx-track="flight.gpxTrack?.coordinates || []"
:highlight-position="currentPositionMarker" :markers="pointsOfInterests"
:photos="mapPhotos" :highlight-position="currentPositionMarker"
/> :photos="mapPhotos"
/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
</q-no-ssr>
</template> </template>
+88 -41
View File
@@ -2,6 +2,9 @@
import {minutesToHumanReadable} from "src/composables/datetimeUtils"; import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {flightName} from "src/composables/flight"; import {flightName} from "src/composables/flight";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue"; import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {computed} from "vue";
import LoadMoreList from "components/LoadMoreList.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -12,60 +15,104 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true required: true
}, },
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
}); });
const routeDetail = (flight) => { const routeDetail = (flight) => {
if (props.public) if (props.public)
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, id: flight.id}} return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
else else
return {name: "flightDetail", params: {id: flight.id}} return {name: "flightDetail", params: {id: flight.id}}
}; };
const 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> </script>
<template> <template>
<q-list bordered separator> <LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<q-item <template #items>
v-for="flight of items" :key="flight" <q-list bordered separator>
ripple <q-item
clickable v-for="flight of items" :key="flight"
:to="routeDetail(flight)" ripple
> clickable
<q-item-section avatar> :to="routeDetail(flight)"
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/> >
</q-item-section> <q-item-section avatar>
<q-item-section> <AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div> <q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
</q-item-section>
<q-item-section>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<div class="text-secondary" v-if="props.public"> <div class="text-secondary" v-if="props.public">
<q-icon name="support_agent"/> <q-icon name="support_agent"/>
@{{flight.pilot.publicUsername}} @{{ flight.pilot.publicUsername }}
</div> </div>
<div class="text-secondary" v-if="flight.copilot"> <div class="text-secondary" v-if="flight.copilots">
<q-icon name="person"/> <q-icon name="person"/>
{{ flight.pilot.name }} {{ flight.pilot.name }}
{{ flight.copilot.name }} {{ flight.copilots }}
</div> </div>
<div class="text-secondary"> <div class="text-secondary">
<q-icon name="schedule"/> <q-icon name="schedule"/>
{{ minutesToHumanReadable(flight.durationMinCalculated) }} {{ minutesToHumanReadable(flight.durationMinCalculated) }}
</div> </div>
<div class="text-secondary"> <div class="text-secondary">
<q-icon name="flight"/> <q-icon name="flight"/>
{{ flight.aircraft.callSign }} {{ flight.aircraft.callSign }}
</div> </div>
<div class="text-secondary" v-if="flight.event"> <div class="text-secondary" v-if="flight.event">
<q-icon name="event"/> <q-icon name="event"/>
{{ flight.event.name }} {{ flight.event.name }}
</div> </div>
</q-item-section> </q-item-section>
<q-item-section side top v-if="!props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility" v-if="flight.isPublic"/>
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
<q-tooltip>
Let je sice publikovaný, nemá ale nastavenou URL adresu!
</q-tooltip>
</q-icon>
</q-item-section>
</q-item>
</q-list>
</template>
<template #skeleton>
<FlightsListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Načíst {{ pluralize(loadMoreCount, 'jeden let', `další ${loadMoreCount} lety`, `dalších ${loadMoreCount} letů`) }}
</template>
</LoadMoreList>
<q-item-section side top v-if="flight.isPublic && !props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
</q-list>
</template> </template>
+75 -68
View File
@@ -21,10 +21,10 @@ const props = defineProps({
required: false required: false
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}); });
const airportName = (airport) => `${airport.icaoCode} (${airport.name})`; const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
@@ -32,8 +32,13 @@ const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
const airports = computed(() => props.airports.map(a => ({id: a.id, name: airportName(a)}))); const airports = computed(() => props.airports.map(a => ({id: a.id, name: airportName(a)})));
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign}))); const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
const manualInput = ref(false);
const allowGPXUpload = ref(true);
const formData = reactive({ const formData = reactive({
aircraft: null, aircraft: null,
gpxTrackFile: null,
takeoffAirport: null, takeoffAirport: null,
landingAirport: null, landingAirport: null,
takeoffDatetime: null, takeoffDatetime: null,
@@ -52,8 +57,14 @@ watch(() => props.flight, (newVal) => {
if (newVal) { if (newVal) {
setDefaultData(formData, newVal); setDefaultData(formData, newVal);
formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign}; formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign};
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)} if (newVal.takeoffAirport)
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
if (newVal.landingAirport)
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)}
allowGPXUpload.value = false;
manualInput.value = true;
} }
}, {immediate: true}); }, {immediate: true});
watch(() => formData.takeoffDatetime, () => { watch(() => formData.takeoffDatetime, () => {
@@ -67,79 +78,75 @@ watch(() => formData.takeoffAirport, () => {
formData.landingAirport = {...formData.takeoffAirport}; formData.landingAirport = {...formData.takeoffAirport};
} }
}); });
</script> </script>
<template> <template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending"> <FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<q-card class="q-my-sm"> <ComboboxInput
<q-card-section> :rules="rules.aircraft"
<div class="text-h6">Vyber letadlo</div> v-bind="commonProps"
</q-card-section> :options="aircrafts"
v-model="formData.aircraft"
<q-card-section> label="Letadlo"
<ComboboxInput />
:rules="rules.aircraft"
v-bind="commonProps"
:options="aircrafts"
v-model="formData.aircraft"
label="Letadlo"
/>
</q-card-section>
</q-card>
</div> </div>
</div> </div>
<div class="row">
<div class="col col-12 col-md-6">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<div class="text-h6">Vzlet</div>
</q-card-section>
<q-card-section>
<DatetimePicker
v-model="formData.takeoffDatetime"
:rules="rules.takeoffDatetime"
label="Datum a čas vzletu: "
v-bind="commonProps"
/>
<ComboboxInput <div class="row" v-if="!manualInput">
:options="airports" <div class="col">
:rules="rules.takeoffAirport" <div class="text-h6">Mám trasu v GPX, chci importovat data</div>
v-model="formData.takeoffAirport" <q-file v-bind="commonProps" v-model="formData.gpxTrackFile" label="Trasa v GPX" hint="Z trasy se načtou letiště a časy vzletu a přistání. Tyto hodnoty je možné později upravit."/>
label="Letiště vzletu"
v-bind="commonProps"
/>
</q-card-section>
</q-card>
</div>
<div class="col col-12 col-md-6"> <div class="text-center">
<q-btn @click="manualInput = true" rounded flat>Zadat informace ručně</q-btn>
<q-card class="q-ml-md-sm q-my-sm"> </div>
<q-card-section>
<div class="text-h6">Přistání</div>
</q-card-section>
<q-card-section>
<DatetimePicker
v-model="formData.landingDatetime"
:min-date="formData.takeoffDatetime"
:rules="rules.landingDatetime"
label="Datum a čas přistání"
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
v-model="formData.landingAirport"
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
/>
</q-card-section>
</q-card>
</div> </div>
</div> </div>
<div class="row" v-if="manualInput">
<div class="col col-12 col-md-6 q-pr-md-sm">
<div class="text-h6">Vzlet</div>
<DatetimePicker
v-model="formData.takeoffDatetime"
:rules="rules.takeoffDatetime"
label="Datum a čas vzletu: "
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
:rules="rules.takeoffAirport"
v-model="formData.takeoffAirport"
label="Letiště vzletu"
v-bind="commonProps"
/>
</div>
<div class="col col-12 col-md-6 q-pl-md-sm">
<div class="text-h6">Přistání</div>
<DatetimePicker
v-model="formData.landingDatetime"
:min-date="formData.takeoffDatetime"
:rules="rules.landingDatetime"
label="Datum a čas přistání"
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
v-model="formData.landingAirport"
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
/>
</div>
<div class="col-12 text-center" v-if="allowGPXUpload">
<q-btn @click="manualInput = false" rounded flat>Mám GPX, načíst informace z trasy</q-btn>
</div>
</div>
</FormWrapper> </FormWrapper>
</template> </template>
@@ -1,10 +1,13 @@
<script setup> <script setup>
import {reactive, watch} from "vue"; import {reactive, ref, watch} from "vue";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling"; import {setDefaultData} from "src/composables/form/formHandling";
import RichTextEditor from "components/inputs/RichTextEditor.vue"; import RichTextEditor from "components/inputs/RichTextEditor.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -16,18 +19,24 @@ const props = defineProps({
required: false, required: false,
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}); });
const formData = reactive({ const formData = reactive({
name: "", name: "",
event: null, event: null,
description: "" description: "",
debriefingNotes: "",
urlSlug: "",
isPublic: null
}) })
const loggedUser = storeToRefs(useLoggedUserStore()).user;
watch(() => props.flight, (newVal) => { watch(() => props.flight, (newVal) => {
if (newVal) { if (newVal) {
setDefaultData(formData, newVal); setDefaultData(formData, newVal);
@@ -38,12 +47,40 @@ watch(() => props.flight, (newVal) => {
} }
}, {immediate: true}); }, {immediate: true});
const descriptionTab = ref("description");
</script> </script>
<template> <template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending"> <FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/> <ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/>
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/> <q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/> <template v-if="loggedUser?.publicUsername">
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser.publicUsername}/`"
:slug-source="formData.name"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
<q-tabs v-model="descriptionTab" active-color="primary">
<q-tab name="description">Popis</q-tab>
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
</q-tabs>
<q-tab-panels v-model="descriptionTab">
<q-tab-panel name="description">
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
</q-tab-panel>
<q-tab-panel name="debriefing">
<RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/>
</q-tab-panel>
</q-tab-panels>
</FormWrapper> </FormWrapper>
</template> </template>
@@ -3,6 +3,7 @@ import FormWrapper from "components/FormWrapper.vue";
import {reactive, ref, watch} from "vue"; import {reactive, ref, watch} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -24,7 +25,7 @@ const props = defineProps({
const formData = reactive({ const formData = reactive({
track: [], track: [],
gpxTrack: null gpxTrackFile: null
}); });
const removeTrackItem = (index) => { const removeTrackItem = (index) => {
formData.track.splice(index, 1); formData.track.splice(index, 1);
@@ -51,6 +52,7 @@ watch(poi, (newVal) => {
}); });
watch(() => props.flight.track, (newVal) => { watch(() => props.flight.track, (newVal) => {
formData.track = [];
for (const i in newVal) { for (const i in newVal) {
const trackInfo = newVal[i]; const trackInfo = newVal[i];
const info = {landingDuration: trackInfo.landingDuration}; const info = {landingDuration: trackInfo.landingDuration};
@@ -81,7 +83,7 @@ watch(() => props.flight.track, (newVal) => {
<q-file <q-file
accept=".gpx" accept=".gpx"
v-model="formData.gpxTrack" v-model="formData.gpxTrackFile"
v-bind="commonProps" v-bind="commonProps"
label="Nahrát trasu v GPX" label="Nahrát trasu v GPX"
hint="Slouží pro vykreslení kompletní a detailní trasy" hint="Slouží pro vykreslení kompletní a detailní trasy"
@@ -91,6 +93,8 @@ watch(() => props.flight.track, (newVal) => {
</template> </template>
</q-file> </q-file>
<h5>Body na mapě</h5>
<ComboboxInput <ComboboxInput
v-bind="commonProps" v-bind="commonProps"
v-model="poi" v-model="poi"
@@ -103,7 +107,7 @@ watch(() => props.flight.track, (newVal) => {
/> />
<div class="row"> <div class="row">
<div class="col"> <div class="col-12 col-md-8">
<ComboboxInput <ComboboxInput
v-bind="commonProps" v-bind="commonProps"
v-model="airport" v-model="airport"
@@ -111,16 +115,16 @@ watch(() => props.flight.track, (newVal) => {
:options="airports" :options="airports"
clearable clearable
label="Letiště pro mezipřistání" label="Letiště pro mezipřistání"
hint="Nastav letiště a dobu mezispřistání" hint="Nastav letiště a dobu mezipřistání"
/> />
</div> </div>
<div class="col" v-if="airport?.name"> <div class="col-md-4 col-12" v-if="airport?.name">
<q-input <q-input
type="number" type="number"
v-bind="commonProps" v-bind="commonProps"
v-model="landingDuration" v-model="landingDuration"
label="Délka mezipřistání (minuty)" label="Délka mezipřistání (minuty)"
class="q-mx-md" class="q-mx-md-md"
> >
<template #after> <template #after>
<q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn> <q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn>
@@ -136,7 +140,7 @@ watch(() => props.flight.track, (newVal) => {
<q-avatar color="primary" text-color="white" icon="flight_takeoff"/> <q-avatar color="primary" text-color="white" icon="flight_takeoff"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ flight.takeoffAirport.name }} {{ flight.takeoffAirport?.name || '???' }}
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -153,7 +157,11 @@ watch(() => props.flight.track, (newVal) => {
</q-badge> </q-badge>
</span> </span>
<span v-if="item.airport"> <span v-if="item.airport">
{{ item.airport.name }}, mezipřistání: {{ item.landingDuration }} minut {{ item.airport.name }},
<template v-if="item.landingDuration > 0">
mezipřistání {{ minutesToHumanReadable(item.landingDuration) }}
</template>
<template v-else>touch and go</template>
</span> </span>
</q-item-section> </q-item-section>
@@ -167,7 +175,7 @@ watch(() => props.flight.track, (newVal) => {
<q-avatar color="primary" text-color="white" icon="flight_land"/> <q-avatar color="primary" text-color="white" icon="flight_land"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ flight.landingAirport.name }} {{ flight.landingAirport?.name || '???' }}
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -1,10 +1,18 @@
<script setup> <script setup>
import {randomInt} from "src/composables/random"; import {randomInt} from "src/composables/random";
const props = defineProps({
items: {
type: Number,
required: false,
default: 10,
}
})
</script> </script>
<template> <template>
<q-list bordered separator> <q-list bordered separator>
<q-item v-for="i in 10" :key="i" ripple> <q-item v-for="i in items" :key="i" ripple>
<q-item-section avatar> <q-item-section avatar>
<q-avatar size="100px"> <q-avatar size="100px">
<q-skeleton type="QAvatar" size="100px"></q-skeleton> <q-skeleton type="QAvatar" size="100px"></q-skeleton>
@@ -12,9 +20,9 @@ import {randomInt} from "src/composables/random";
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-skeleton :width="randomInt(30,50)+'%'"/> <q-skeleton :width="randomInt(30,50)+'%'"/>
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/> <q-skeleton type="text" :width="randomInt(10,15)+'%'"/>
<q-skeleton type="text" :width="randomInt(5,10)+'%'"/> <q-skeleton type="text" :width="randomInt(5,10)+'%'"/>
<q-skeleton type="text" width="8%"/> <q-skeleton type="text" width="8%"/>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -0,0 +1,33 @@
<script setup>
import {flightName} from "src/composables/flight";
const props = defineProps({
flight: {
type: Object,
required: true
},
textColor: {
type: String,
required: false,
default: "white"
}
})
</script>
<template>
<div class="text-h4">
<router-link
:class="`text-${props.textColor}`"
:to="{name: 'publicFlightDetail', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
>
{{ flightName(flight) }}
</router-link>
</div>
<router-link
:class="`text-${props.textColor}`"
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
>
@{{ flight.pilot.publicUsername }}
</router-link>
</template>
@@ -0,0 +1,48 @@
<script setup>
import {ref} from "vue";
import {flightName} from "src/composables/flight";
import LatestFlightSliderInfo from "components/flights/slider/LatestFlightSliderInfo.vue";
const props = defineProps({
items: {
type: Array,
required: true
}
});
const slide = ref(0);
</script>
<template>
<div class="bg-blue-1 q-py-lg">
<h3 class="text-center">Poslední lety</h3>
<q-carousel
v-model="slide"
swipeable
animated
control-color="primary"
navigation
padding
arrows
height="500px"
class="bg-blue-1 text-white"
>
<q-carousel-slide :name="idx" v-for="(flight, idx) in items" :key="idx" class="column no-wrap flex-center text-center">
<q-img
:src="flight.titlePhoto.url"
fit="cover"
ratio="16/9"
style="max-width: 1000px;"
height="400px"
v-if="flight.titlePhoto"
>
<div class="absolute-bottom">
<LatestFlightSliderInfo :flight="flight"/>
</div>
</q-img>
<LatestFlightSliderInfo v-else :flight="flight" text-color="primary"/>
</q-carousel-slide>
</q-carousel>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup>
</script>
<template>
<div class="row q-mx-md">
<div class="col-12 col-md-6 col-lg-6 self-center info-box">
<div class="text-h3 text-center q-my-lg">Fotogalerie ze vzduchu</div>
<p>Polétání.cz je aplikace pro piloty, kteří si chtějí spravovat fotografie ze svých výletů, zobrazovat je na mapě a ukládat si trasy svých výletů.</p>
<div class="text-center q-my-lg">
<q-btn size="xl" :to="{name: 'registration'}" rounded color="secondary">Založit účet</q-btn>
</div>
</div>
<div class="col-12 col-md-6 col-lg-6 text-center">
<div>
<q-img src="hp/mobile-preview-200.webp" height="401px" width="200px" class="q-ma-lg" fit="contain" alt="Nahled aplikace"/>
<q-img src="hp/desktop-preview-350.webp" height="310px" width="350px" fit="contain" class="xs-hide sm-hide" alt="Nahled aplikace"/>
</div>
</div>
</div>
</template>
<style>
.info-box {
font-size: 150%;
}
</style>
@@ -0,0 +1,37 @@
<script setup>
</script>
<template>
<q-timeline color="primary" class="q-my-lg">
<q-timeline-entry title="Zaznamenej si let">
<div>
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
</div>
</q-timeline-entry>
<q-timeline-entry title="Ulož trasu">
<div>
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
</div>
</q-timeline-entry>
<q-timeline-entry title="Vyber a nahraj fotky">
<div>A pokud mají v sobě uloženou informaci o poloze, p
Máš nějaké fotky z letu? Nahraj je ke svému letu! Polétání.cz ti zobrazí i místo kde fotka vznikla.
</div>
</q-timeline-entry>
<q-timeline-entry title="Popiš zážitek">
<div>
Určitě se po ceste stalo něco vzrušujícího. Proč o tom nenapsat pár vět?
</div>
</q-timeline-entry>
<q-timeline-entry title="Sdílej">
<div>
Létat v sóle je super, ale ještě lepší je se o zážitek podělit. Jedním kliknutím můžeš svůj let zveřejnit a
sdílet odkaz kde chceš. Třeba s tvým kopilotem.
</div>
</q-timeline-entry>
</q-timeline>
</template>
+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> </script>
<template> <template>
<q-editor v-model="content" :definitions="definitions" :toolbar="toolbar" :height="height" :label="label" :rules="rules"/> <q-editor
v-model="content"
:definitions="definitions"
:toolbar="toolbar"
:height="height"
v-bind="$attrs"
:rules="rules"
/>
</template> </template>
+62
View File
@@ -0,0 +1,62 @@
<script setup>
import {ref, watch} from "vue";
import {slugify} from "src/composables/slugify";
const props = defineProps({
modelValue: {
type: String
},
slugSource: {
type: String,
required: false,
},
url : {
type: String,
required: false,
default: null
},
label: {
type: String,
required: false,
default: "URL"
},
rules: {
type: Array,
required: false,
default: () => ([])
}
});
const emit = defineEmits(['update:modelValue']);
const model = ref("");
const emitValue = (val) => {
emit("update:modelValue", val);
}
watch(() => model.value, (newVal) => {
model.value = slugify(newVal);
emitValue(newVal);
})
watch(() => props.slugSource, (newVal, oldVal) => {
if (model.value === "" || slugify(oldVal) === model.value) {
// pokud puvodni vychozi hodnota je stejna jako aktualne nastaveny slug, zmenime model
model.value = slugify(newVal);
}
}, {immediate: true});
</script>
<template>
<q-input
v-model="model"
:label="label"
:rules="rules"
hint="Veřejná URL adresa"
>
<template #prepend>
{{ url }}
</template>
</q-input>
</template>
@@ -6,8 +6,8 @@ import {randomInt} from "src/composables/random";
<template> <template>
<q-breadcrumbs class="q-mx-md q-my-md"> <q-breadcrumbs class="q-mx-md q-my-md">
<q-breadcrumbs-el> <q-skeleton :width="`${randomInt(30, 60)}px`" type="rect" /> </q-breadcrumbs-el> <q-breadcrumbs-el> <q-skeleton :width="`${randomInt(100, 190)}px`" type="rect" /> </q-breadcrumbs-el>
<q-breadcrumbs-el> <q-skeleton :width="`${randomInt(40, 80)}px`" type="rect" /> </q-breadcrumbs-el> <q-breadcrumbs-el> <q-skeleton :width="`${randomInt(200, 280)}px`" type="rect" /> </q-breadcrumbs-el>
</q-breadcrumbs> </q-breadcrumbs>
+1 -1
View File
@@ -127,7 +127,7 @@ const showLarge = (displayedPhoto) => {
</script> </script>
<template> <template>
<div class="row" style="margin-bottom: 150px"> <div class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id"> <div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id">
<q-card :class='{"q-ma-sm": true, "shadow-1": !batchEdit[photo.id], "shadow-10": batchEdit[photo.id]}'> <q-card :class='{"q-ma-sm": true, "shadow-1": !batchEdit[photo.id], "shadow-10": batchEdit[photo.id]}'>
<q-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)" style="cursor: pointer"> <q-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)" style="cursor: pointer">
@@ -1,7 +1,17 @@
<script setup> <script setup>
const props = defineProps({
photos: {
type: Number,
required: false,
default: 9
}
});
</script> </script>
<template> <template>
gallery skeleton <div class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" v-for="i in props.photos" :key="i">
<q-skeleton type="rect" width="100%" height="200px" />
</div>
</div>
</template> </template>
+20 -6
View File
@@ -8,6 +8,8 @@ import {errorObject} from "src/composables/dialog";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules"; import {isRequired} from "src/composables/form/rules";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {useRouter} from "vue-router";
import SlugInput from "components/inputs/SlugInput.vue";
const props = defineProps({ const props = defineProps({
poi: { poi: {
@@ -23,7 +25,8 @@ const props = defineProps({
}) })
const formData = reactive({ const formData = reactive({
name: null, name: "",
urlSlug: "",
description: "", description: "",
gpsLatitude: null, gpsLatitude: null,
gpsLongitude: null, gpsLongitude: null,
@@ -47,7 +50,8 @@ const resetMap = () => {
} }
const rules = { const rules = {
name: [isRequired] name: [isRequired],
urlSlug: []
} }
const zoom = ref(10); const zoom = ref(10);
@@ -72,6 +76,18 @@ const handlePositionNotFound = () => {
</template> </template>
</q-input> </q-input>
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
url="/mista/"
:slug-source="formData.name"
:rules="rules.urlSlug"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<MapGPSInput <MapGPSInput
v-if="poiNameForMap" v-if="poiNameForMap"
:key="poiNameForMap" :key="poiNameForMap"
@@ -82,10 +98,8 @@ const handlePositionNotFound = () => {
@not-found="handlePositionNotFound" @not-found="handlePositionNotFound"
/> />
<q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis" /> <q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis"/>
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps" /> <ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</FormWrapper> </FormWrapper>
</template> </template>
+59 -21
View File
@@ -1,30 +1,68 @@
<script setup> <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({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: false required: false
},
public: {
type: Boolean,
required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
} }
}) });
const getRoute = (item) => {
if (props.public)
return {name: "publicPOIDetail", params: {slug: item.urlSlug}}
else
return {name: "poiDetail", params: {id: item.id}}
};
</script> </script>
<template> <template>
<q-list bordered separator> <LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<q-item clickable v-ripple v-for="poi of items" :key="poi" :to="{name: 'poiDetail', params: {id: poi.id}}"> <template #items>
<q-item-section avatar> <q-list bordered separator>
<q-avatar v-if="poi.titlePhoto"> <q-item clickable v-ripple v-for="poi of items" :key="poi" :to="getRoute(poi)">
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/> <q-item-section avatar>
</q-avatar> <q-avatar v-if="poi.titlePhoto">
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/> <img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
</q-item-section> </q-avatar>
<q-item-section> <q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
{{ poi.name }} </q-item-section>
<div class="text-secondary" v-if="poi.type"> <q-item-section>
{{ poi.type.name }} {{ poi.name }}
<div class="text-secondary" v-if="poi.type">
</div> {{ poi.type.name }}
</q-item-section> </div>
<q-item-section side top v-if="poi.isPublic"> </q-item-section>
<q-icon name="visibility"></q-icon> <q-item-section side top v-if="!props.public && poi.isPublic">
</q-item-section> <q-icon name="visibility"></q-icon>
</q-item> </q-item-section>
</q-list> </q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
+16 -18
View File
@@ -1,11 +1,13 @@
<script setup> <script setup>
import {onMounted, reactive, watch} from "vue"; import {onMounted, reactive, watch} from "vue";
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import RichTextEditor from "components/inputs/RichTextEditor.vue"; import RichTextEditor from "components/inputs/RichTextEditor.vue";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling"; import {setDefaultData} from "src/composables/form/formHandling";
import {slugify} from "src/composables/slugify";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const props = defineProps({ const props = defineProps({
user: { user: {
@@ -13,12 +15,11 @@ const props = defineProps({
required: false, required: false,
default: null default: null
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
} }
}) })
const formData = reactive({ const formData = reactive({
@@ -29,22 +30,19 @@ const formData = reactive({
watch(() => props.user, (newVal) => { watch(() => props.user, (newVal) => {
setDefaultData(formData, props.user) setDefaultData(formData, props.user)
}, {immediate: true}); }, {immediate: true});
watch(() => formData.publicUsername, (newVal) => {
formData.publicUsername = slugify(newVal);
}, {immediate: true})
</script> </script>
<template> <template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending"> <FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<q-list> <BasicInfoBanner style="padding: 0 !important" v-if="formData.publicUsername === ''">
<q-item v-if="user.publicUsername"> Nastav si veřejné uživatelské jméno pro zpřístupnění profilu a sdílení záznamů s ostatními
<router-link target="_blank" :to="{name: 'publicProfileDetail', params: {username: user.publicUsername}}"> </BasicInfoBanner>
@{{ user.publicUsername }}
</router-link>
</q-item>
<q-item v-else>
<q-icon name="" color="red"/>
Nastav si veřejné uživatelské jméno pro zpřístupnění profilu a sdílení záznamů s ostatními
</q-item>
</q-list>
<q-input v-model="formData.name" label="Jméno" v-bind="commonProps"/> <q-input v-model="formData.name" label="Jméno" v-bind="commonProps"/>
<q-input <q-input
+1 -1
View File
@@ -8,6 +8,6 @@ export const flightName = (flight) => {
const track = flight.track ? flight.track.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : []; const track = flight.track ? flight.track.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : [];
const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - '; const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - ';
return `${flight.takeoffAirport.icaoCode} ${trackStr} ${flight.landingAirport.icaoCode} (${date})`; return `${flight.takeoffAirport?.icaoCode || '???'} ${trackStr} ${flight.landingAirport?.icaoCode || "???"} (${date})`;
} }
} }
+5 -7
View File
@@ -13,11 +13,9 @@ export const setDefaultData = (formData, defaultData) => {
} }
} }
export const toggleVisibility = ({store, $q, object}) => { export const toggleVisibility = ({store, $q, object, variables, mutation = undefined}) => {
console.log(object); store.update(object.id, variables, mutation)
const newVisibility = !object.isPublic; .then(() => {
store.update(object.id, {isPublic: newVisibility}) $q.notify(successObject(variables.isPublic ? "Úspěšně publikováno" : "Úspěšně skryto"));
.then(() => { });
$q.notify(successObject(newVisibility ? "Úspěšně publikováno" : "Úspěšně skryto"));
});
} }
+32 -28
View File
@@ -1,41 +1,45 @@
const stripHtml = (str) => { const stripHtml = (str) => {
if (!str) if (!str)
return ''; return '';
else else
str = str.toString(); str = str.toString();
return str.replace(/<[^>]+>/ig, ''); return str.replace(/<[^>]+>/ig, '');
} }
export const commonKeywords = [ export const commonKeywords = [
"zápisník", "létání", "ultralight", "letectví", "fotogalerie", "výlet", "vyhlídkový let", "navigační let" "zápisník", "létání", "ultralight", "letectví", "fotogalerie", "výlet", "vyhlídkový let", "navigační let"
]; ];
export const metaData = ({title, description, keywords, image}) => { export const aircraftKeywords = (aircraft) => {
return [aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
};
const filteredKeywords = (keywords || []).filter(kw => !!kw); export const metaData = ({title, description, keywords, image, index = true}) => {
const filteredDescription = stripHtml(description); const filteredKeywords = (keywords || []).filter(kw => !!kw);
const filteredDescription = stripHtml(description);
return { return {
title: title || "", title: title,
titleTemplate(title) { titleTemplate(title) {
return title !== '' ? `${title} - Polétání.cz` : 'Polétání.cz' return title !== '' ? `${title} - Polétání.cz: fotogalerie ze vzduchu` : 'Polétání.cz'
}, },
meta: { meta: {
description: {name: "description", content: filteredDescription || ""}, description: {name: "description", content: filteredDescription || ""},
keywords: {name: "keywords", content: (filteredKeywords || commonKeywords).join(", ")}, keywords: {name: "keywords", content: (filteredKeywords || commonKeywords).join(", ")},
ogImage: image ? {property: "og:image", content: image} : undefined, ogImage: image ? {property: "og:image", content: image} : undefined,
ogTitle: { ogTitle: {
property: "og:title", property: "og:title",
content: title, content: title,
template(ogTitle) { template(ogTitle) {
return (!ogTitle || ogTitle === '') ? "Polétání.cz" : `${ogTitle} - Polétání.cz` return (!ogTitle || ogTitle === '') ? "Polétání.cz" : `${ogTitle} - Polétání.cz`
}
},
ogDescription: {
property: "og:description", content: filteredDescription,
}
} }
},
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-left: auto;
margin-right: 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 name
} }
} }
flights {
...flightListInfo
}
} }
${FRAGMENT_AIRCRAFT_FULL_INFO} ${FRAGMENT_AIRCRAFT_FULL_INFO}
${FRAGMENT_PHOTO_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 { fragment copilotFullInfo on Copilot {
id id
name name
isPublic
urlSlug
titlePhoto { titlePhoto {
id id
@@ -25,13 +27,7 @@ fragment copilotFullInfo on Copilot {
export const FRAGMENT_COPILOT_FULL_PRIVATE_INFO = gql` export const FRAGMENT_COPILOT_FULL_PRIVATE_INFO = gql`
fragment copilotFullPrivateInfo on Copilot { fragment copilotFullPrivateInfo on Copilot {
id ...copilotFullInfo
name
titlePhoto {
id
url
}
photos { photos {
...photoFullInfo ...photoFullInfo
@@ -49,4 +45,5 @@ fragment copilotFullPrivateInfo on Copilot {
} }
${FRAGMENT_PHOTO_FULL_INFO} ${FRAGMENT_PHOTO_FULL_INFO}
${FRAGMENT_COPILOT_FULL_INFO}
`; `;
+2
View File
@@ -6,6 +6,7 @@ fragment eventListInfo on Event {
name name
description description
isPublic isPublic
urlSlug
} }
`; `;
@@ -15,6 +16,7 @@ fragment eventFullInfo on Event {
name name
description description
isPublic isPublic
urlSlug
dateFrom dateFrom
dateTo dateTo
} }
+16 -11
View File
@@ -21,6 +21,7 @@ export const FRAGMENT_FLIGHT_FULL_INFO = gql`
fragment flightFullInfo on Flight { fragment flightFullInfo on Flight {
id id
name name
urlSlug
description description
takeoffDatetime takeoffDatetime
landingDatetime landingDatetime
@@ -53,6 +54,8 @@ fragment flightFullInfo on Flight {
copilots { copilots {
id id
name name
isPublic
urlSlug
} }
event { event {
@@ -100,21 +103,23 @@ ${FRAGMENT_WEATHER_FULL_INFO}
export const FRAGMENT_FLIGHT_FULL_PRIVATE_INFO = gql` export const FRAGMENT_FLIGHT_FULL_PRIVATE_INFO = gql`
fragment flightFullPrivateInfo on Flight { fragment flightFullPrivateInfo on Flight {
...flightFullInfo ...flightFullInfo
debriefingNotes
photos { photos {
...photoFullInfo ...photoFullInfo
pointOfInterest { pointOfInterest {
id id
name name
}
copilots {
id
name
}
} }
copilots {
id
name
}
}
} }
${FRAGMENT_FLIGHT_FULL_INFO} ${FRAGMENT_FLIGHT_FULL_INFO}
${FRAGMENT_PHOTO_FULL_INFO} ${FRAGMENT_PHOTO_FULL_INFO}
`; `;
+2
View File
@@ -6,6 +6,7 @@ export const FRAGMENT_FLIGHT_LIST_INFO = gql`
fragment flightListInfo on Flight { fragment flightListInfo on Flight {
id id
name name
urlSlug
takeoffDatetime takeoffDatetime
landingDatetime landingDatetime
durationMinCalculated durationMinCalculated
@@ -13,6 +14,7 @@ fragment flightListInfo on Flight {
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
url
} }
track { track {
-1
View File
@@ -12,7 +12,6 @@ export const FRAGMENT_PHOTO_FULL_INFO = gql`
aircraftId aircraftId
width width
height height
isFlightCover
gpsLatitude gpsLatitude
gpsLongitude gpsLongitude
gpsAltitude gpsAltitude
+2
View File
@@ -6,6 +6,7 @@ export const FRAGMENT_POI_LIST_INFO = gql`
fragment pointOfInterestListInfo on PointOfInterest { fragment pointOfInterestListInfo on PointOfInterest {
id id
name name
urlSlug
isPublic isPublic
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
@@ -21,6 +22,7 @@ export const FRAGMENT_POI_FULL_INFO = gql`
fragment pointOfInterestFullInfo on PointOfInterest { fragment pointOfInterestFullInfo on PointOfInterest {
id id
name name
urlSlug
description description
isPublic isPublic
gpsLatitude gpsLatitude
+8
View File
@@ -20,6 +20,14 @@ mutation editFlight($id: Int!, $input: EditFlightInput!) {
${FRAGMENT_FLIGHT_FULL_PRIVATE_INFO} ${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` export const DELETE_FLIGHT = gql`
mutation deleteFlight($id: Int!) { 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"; import {FRAGMENT_AIRCRAFT_FULL_INFO, FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO, FRAGMENT_AIRCRAFT_LIST_INFO} from "src/graphql/fragments/aircraft";
export const AIRCRAFTS_LIST = gql` export const AIRCRAFTS_LIST = gql`
query AircraftsList { query AircraftsList ($limit: Int!, $offset: Int) {
aircrafts { aircrafts(limit: $limit, offset: $offset) {
...aircraftListInfo totalItemsCount
items {
...aircraftListInfo
}
}
} }
}
${FRAGMENT_AIRCRAFT_LIST_INFO} ${FRAGMENT_AIRCRAFT_LIST_INFO}
`; `;
@@ -22,8 +25,8 @@ ${FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO}
`; `;
export const AIRCRAFT_PUBLIC_DETAIL = gql` export const AIRCRAFT_PUBLIC_DETAIL = gql`
query AircraftDetail($id: Int!) { query AircraftDetail($callSign: String!) {
aircraft(id: $id, public: true) { aircraft(callSign: $callSign, public: true) {
...aircraftFullInfo ...aircraftFullInfo
} }
} }
+2 -2
View File
@@ -34,8 +34,8 @@ ${FRAGMENT_FLIGHT_LIST_INFO}
`; `;
export const COPILOT_PUBLIC_DETAIL = gql` export const COPILOT_PUBLIC_DETAIL = gql`
query copilot($id: Int!, $pilotUsername: String) { query copilot($urlSlug: String!, $pilotUsername: String) {
copilot(id: $id, pilotUsername: $pilotUsername) { copilot(urlSlug: $urlSlug, pilotUsername: $pilotUsername, public: true) {
...copilotFullInfo ...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"; import {FRAGMENT_EVENT_FULL_INFO, FRAGMENT_EVENT_LIST_INFO} from "src/graphql/fragments/event";
export const EVENTS_LIST = gql` export const EVENTS_LIST = gql`
query eventsList($username: String) { query eventsList($limit: Int!, $offset: Int) {
events(username: $username) { events(limit: $limit, offset: $offset) {
...eventListInfo items {
...eventListInfo
flights { flights {
titlePhoto { titlePhoto {
thumbnailUrl 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` export const EVENT_DETAIL = gql`
query event($id: Int!, $username: String) { query event($id: Int!) {
event(id: $id, username: $username) { event(id: $id) {
...eventFullInfo ...eventFullInfo
flights { flights {
...flightListInfo titlePhoto {
thumbnailUrl
}
} }
} }
} }
${FRAGMENT_EVENT_FULL_INFO} ${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"; import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
export const FLIGHTS_LIST = gql` export const FLIGHTS_LIST = gql`
query FlightsList { query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $limit: Int!, $offset: Int) {
flights { flights(
...flightListInfo eventId: $eventId,
aircraftId: $aircraftId,
copilotId: $copilotId,
pointOfInterestId: $pointOfInterestId,
limit: $limit,
offset: $offset
) {
totalItemsCount
items {
...flightListInfo
}
} }
} }
${FRAGMENT_FLIGHT_LIST_INFO} ${FRAGMENT_FLIGHT_LIST_INFO}
`; `;
export const FLIGHTS_PUBLIC_LIST = gql` export const FLIGHTS_PUBLIC_LIST = gql`
query FlightsPublicList($username: String, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int) { query FlightsPublicList($username: String, $eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $limit: Int!, $offset: Int) {
flights(username: $username, aircraftId: $aircraftId, copilotId: $copilotId, pointOfInterestId: $pointOfInterestId, public: true) { flights(
...flightListInfo username: $username,
eventId: $eventId,
pilot { aircraftId: $aircraftId,
id copilotId: $copilotId,
publicUsername 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` export const FLIGHT_PUBLIC_DETAIL = gql`
query FlightPublicDetail($id: Int!, $username: String) { query FlightPublicDetail($urlSlug: String!, $username: String) {
flight(id: $id, username: $username, public: true) { flight(urlSlug: $urlSlug, username: $username, public: true) {
...flightFullInfo ...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"; import {FRAGMENT_POI_FULL_INFO, FRAGMENT_POI_FULL_PRIVATE_INFO, FRAGMENT_POI_LIST_INFO} from "src/graphql/fragments/poi";
export const POI_LIST = gql` export const POI_LIST = gql`
query poiList { query poiList($limit: Int!, $offset: Int, $search: String) {
pointsOfInterest { pointsOfInterest (limit: $limit, offset: $offset, search: $search) {
...pointOfInterestListInfo totalItemsCount
items {
...pointOfInterestListInfo
}
} }
} }
@@ -12,9 +15,12 @@ ${FRAGMENT_POI_LIST_INFO}
`; `;
export const POI_PUBLIC_LIST = gql` export const POI_PUBLIC_LIST = gql`
query poiList { query poiList($limit: Int!, $offset: Int, $search: String) {
pointsOfInterest(public: true) { pointsOfInterest(limit: $limit, offset: $offset, search: $search, public: true) {
...pointOfInterestListInfo totalItemsCount
items {
...pointOfInterestListInfo
}
} }
} }
@@ -32,8 +38,8 @@ ${FRAGMENT_POI_FULL_PRIVATE_INFO}
`; `;
export const POI_PUBLIC_DETAIL = gql` export const POI_PUBLIC_DETAIL = gql`
query publicPOIDetail($id: Int!) { query publicPOIDetail($urlSlug: String!) {
pointOfInterest(id: $id, public: true) { pointOfInterest(urlSlug: $urlSlug, public: true) {
...pointOfInterestFullInfo ...pointOfInterestFullInfo
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
import {FRAGMENT_USER_FULL_INFO} from "src/graphql/fragments/user"; import {FRAGMENT_USER_FULL_INFO} from "src/graphql/fragments/user";
export const USER_DETAIL = gql` export const PILOT_DETAIL = gql`
query userDetail($username: String!) { query pilotDetail($username: String!) {
user(username: $username) { user(username: $username) {
...userFullInfo ...userFullInfo
} }
+61 -21
View File
@@ -3,12 +3,12 @@
<q-header reveal elevated class="bg-primary text-white"> <q-header reveal elevated class="bg-primary text-white">
<q-toolbar> <q-toolbar>
<q-btn <q-btn
flat flat
dense dense
round round
icon="menu" icon="menu"
aria-label="Menu" aria-label="Menu"
@click="toggleLeftDrawer" @click="toggleLeftDrawer"
/> />
<q-toolbar-title> <q-toolbar-title>
@@ -21,12 +21,42 @@
</q-header> </q-header>
<q-drawer <q-drawer
v-model="leftDrawerOpen" v-model="leftDrawerOpen"
show-if-above show-if-above
bordered
> >
<q-img class="absolute-top" v-if="!isQueryRunning && user" :src="user.titleImageUrl" style="height: 150px"/> <q-img
<q-spinner v-else/> 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"> <q-scroll-area style="height: calc(100% - 150px); margin-top: 150px; border-right: 1px solid #ddd">
<SidebarNavigation/> <SidebarNavigation/>
@@ -35,23 +65,28 @@
<q-page-container> <q-page-container>
<BreadcrumbsNavigation :items="breadcrumbs || []"/> <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/> <router-view/>
</div> </div>
</q-page-container> </q-page-container>
</q-layout> </q-layout>
</template> </template>
<script> <script>
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
export default { export default {
preFetch() { preFetch({redirect, currentRoute}) {
return useUserStore().fetchLoggedUserDetail({refresh: true}); // 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>
<script setup> <script setup>
import {ref} from 'vue' import {ref, watch} from 'vue'
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue"; import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
@@ -61,24 +96,29 @@ import {successObject} from "src/composables/dialog";
import SidebarNavigation from "components/navigation/SidebarNavigation.vue"; import SidebarNavigation from "components/navigation/SidebarNavigation.vue";
import TopMenuButton from "components/navigation/TopMenuButton.vue"; import TopMenuButton from "components/navigation/TopMenuButton.vue";
import {metaData} from "src/composables/meta"; import {metaData} from "src/composables/meta";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import {useAuthStore} from "stores/Auth";
const $q = useQuasar(); const $q = useQuasar();
const $router = useRouter(); const $router = useRouter();
const {pageTitle, breadcrumbs} = storeToRefs(useAppStore()); const {pageTitle, breadcrumbs} = storeToRefs(useAppStore());
useMeta(() => metaData({ watch(() => pageTitle.value, () => {
title: pageTitle.value useMeta(() => metaData({
})); title: pageTitle.value
}));
})
const leftDrawerOpen = ref(false) const leftDrawerOpen = ref(false)
const toggleLeftDrawer = () => { const toggleLeftDrawer = () => {
leftDrawerOpen.value = !leftDrawerOpen.value leftDrawerOpen.value = !leftDrawerOpen.value
}; };
const {isQueryRunning, user} = storeToRefs(useUserStore()); const {isQueryRunning, user} = storeToRefs(useLoggedUserStore());
const logout = () => { const logout = () => {
useUserStore().logout().then(() => { useAuthStore().logout().then(() => {
$q.notify(successObject("Odhlášení bylo úspěšné")); $q.notify(successObject("Odhlášení bylo úspěšné"));
$router.push({name: "homepage"}); $router.push({name: "homepage"});
}) })
+7 -5
View File
@@ -1,5 +1,5 @@
<template> <template>
<q-layout view="lHh Lpr lFf"> <q-layout view="lHh Lpr lff">
<q-header> <q-header>
<q-toolbar> <q-toolbar>
<q-toolbar-title> <q-toolbar-title>
@@ -29,15 +29,16 @@
<router-view class="q-my-lg q-mx-lg"/> <router-view class="q-my-lg q-mx-lg"/>
</q-page-container> </q-page-container>
<AppFooter />
</q-layout> </q-layout>
</template> </template>
<script> <script>
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
export default { export default {
preFetch() { preFetch() {
return useUserStore().fetchLoggedUserDetail({refresh: true}); return useLoggedUserStore().fetchLoggedUserDetail({refresh: false}).catch(e => {});
} }
} }
</script> </script>
@@ -47,7 +48,8 @@ import {useAppStore} from "stores/App";
import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue"; import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue"; import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import BreadcrumbsSkeleton from "components/navigation/BreadcrumbsSkeleton.vue"; import BreadcrumbsSkeleton from "components/navigation/BreadcrumbsSkeleton.vue";
import AppFooter from "components/AppFooter.vue";
const {pageTitle, breadcrumbs} = storeToRefs(useAppStore()); const {pageTitle, breadcrumbs, appStatus} = storeToRefs(useAppStore());
const loggedUser = storeToRefs(useUserStore()).user; const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script> </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> <script setup>
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
import ProfileImagesForm from "components/profile/ProfileForm.vue"; import ProfileImagesForm from "components/profile/ProfileForm.vue";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
@@ -10,14 +10,14 @@ import ChangePasswordForm from "components/profile/ChangePasswordForm.vue";
import PublicProfileInfo from "components/profile/PublicProfileInfo.vue"; import PublicProfileInfo from "components/profile/PublicProfileInfo.vue";
useAppStore().setPageTitle("Profil"); useAppStore().setPageTitle("Profil");
const loggedUser = storeToRefs(useUserStore()).user; const loggedUser = storeToRefs(useLoggedUserStore()).user;
const $q = useQuasar(); const $q = useQuasar();
const handleSave = async (formData) => { const handleSave = async (formData) => {
await useUserStore().update(formData); await useLoggedUserStore().update(formData);
$q.notify(successObject("Úspěšně uloženo")); $q.notify(successObject("Úspěšně uloženo"));
}; };
const isRequestPending = storeToRefs(useUserStore()).isMutationRunning; const isRequestPending = storeToRefs(useLoggedUserStore()).isMutationRunning;
const openedTab = ref("profile"); const openedTab = ref("profile");
</script> </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> <script setup>
import {useRoute, useRouter} from "vue-router"; 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 AircraftDetailSkeleton from "components/aircrafts/skeletons/AircraftDetailSkeleton.vue";
import {confirmObject, successObject} from "src/composables/dialog"; import {confirmObject, successObject} from "src/composables/dialog";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import FlightsList from "components/flights/FlightsList.vue"; import FlightsList from "components/flights/FlightsList.vue";
import AircraftDetailCard from "components/aircrafts/AircraftInfo.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useAircraftsStore} from "stores/Aircrafts"; import {useAircraftsStore} from "stores/Aircrafts";
import {computed, watch} from "vue"; import {watch} from "vue";
import {metaData} from "src/composables/meta"; import {metaData} from "src/composables/meta";
import PhotoGallery from "components/photos/PhotoGallery.vue"; import PhotoGallery from "components/photos/PhotoGallery.vue";
import TitlePhotoWithGallery from "components/photos/TitlePhotoWithGallery.vue";
import ContentCard from "components/ContentCard.vue"; import ContentCard from "components/ContentCard.vue";
import AircraftInfo from "components/aircrafts/AircraftInfo.vue"; import AircraftInfo from "components/aircrafts/AircraftInfo.vue";
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
import {useFlightsStore} from "stores/Flights"; import {useFlightsStore} from "stores/Flights";
import {toggleVisibility} from "src/composables/form/formHandling"; import {toggleVisibility} from "src/composables/form/formHandling";
import ContentCardEditButton from "components/ContentCardEditButton.vue"; import ContentCardEditButton from "components/ContentCardEditButton.vue";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
const $q = useQuasar(); import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const $router = useRouter(); 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); const aircraftId = parseInt(useRoute().params.id);
useAircraftsStore().fetchDetail(aircraftId); useAircraftsStore().fetchDetail(aircraftId);
const loggedUser = storeToRefs(useUserStore()).user;
const {aircraft, isQueryRunning} = storeToRefs(useAircraftsStore()); const {aircraft, isQueryRunning} = storeToRefs(useAircraftsStore());
watch(() => aircraft.value, (newVal) => { watch(() => aircraft.value, (newVal) => {
if (!newVal) return; if (!newVal) return;
@@ -37,31 +37,31 @@ watch(() => aircraft.value, (newVal) => {
]) ])
}); });
const $q = useQuasar();
const $router = useRouter();
const deleteAircraft = (id) => { const deleteAircraft = (id) => {
$q.dialog(confirmObject('Opravdu chceš smazat toto letadlo?')) $q.dialog(confirmObject('Opravdu chceš smazat toto letadlo?'))
.onOk(() => { .onOk(() => {
useAircraftsStore().destroy(id).then(() => { useAircraftsStore().destroy(id).then(() => {
$router.push({name: "aircrafts"}); $router.push({name: "aircrafts"});
$q.notify(successObject("Letadlo úspěšně odstraněno")); $q.notify(successObject("Letadlo úspěšně odstraněno"));
});
}); });
});
}; };
const togglePublicVisibility = () => toggleVisibility({ const togglePublicVisibility = (variables) => toggleVisibility({
$q, $q, store: useAircraftsStore(), object: aircraft.value, variables
store: useAircraftsStore(),
object: aircraft.value
}); });
const copyLinkToClipboard = () => { filters.value = {aircraftId: aircraftId};
const url = $router.resolve({ const loadNextFlightsPage = (appendItems) => loadPage({
name: "publicAircraftDetail", fetchFunc: useFlightsStore().fetch,
params: {id: aircraftId} appendItems
}); });
copyToClipboard(window.location.origin + url.href).then(() => { const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
$q.notify(successObject("Odkaz zkopírován")) loadNextFlightsPage(false);
});
}; const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script> </script>
<template> <template>
@@ -71,18 +71,15 @@ const copyLinkToClipboard = () => {
<ContentCard :title-photo="aircraft.titlePhoto" :title="aircraft.callSign"> <ContentCard :title-photo="aircraft.titlePhoto" :title="aircraft.callSign">
<template #actions v-if="aircraft.createdById === loggedUser?.id"> <template #actions v-if="aircraft.createdById === loggedUser?.id">
<div class="col-grow"> <div class="col-grow">
<q-btn <PublishButton
@click="togglePublicVisibility" url-prefix="/letadla"
:icon="aircraft.isPublic ? 'visibility_off' : 'visibility'" :is-public="aircraft.isPublic"
flat :require-url-slug="false"
:label="aircraft.isPublic ? 'Skrýt' : 'Zveřejnit'" @change-visibility="togglePublicVisibility"
/> />
<q-btn <CopyLinkButton
icon="link" v-if="aircraft.isPublic"
@click="copyLinkToClipboard()" :route="{name: 'publicAircraftDetail', params: {callsign: aircraft.callSign}}"
flat
v-if="aircraft.isPublic"
label="Získat odkaz"
/> />
</div> </div>
<q-btn @click="deleteAircraft(aircraft.id)" icon="delete" flat text-color="red" label="Smazat"/> <q-btn @click="deleteAircraft(aircraft.id)" icon="delete" flat text-color="red" label="Smazat"/>
@@ -91,21 +88,33 @@ const copyLinkToClipboard = () => {
<q-card-section> <q-card-section>
<ContentCardEditButton <ContentCardEditButton
:to='{name: "aircraftEdit", params: {id: aircraft.id, step: "info"}}' v-if="aircraft.createdById === loggedUser?.id"
label="Upravit informace" :to='{name: "aircraftEdit", params: {id: aircraft.id, step: "info"}}'
label="Upravit informace"
/> />
<AircraftInfo :aircraft="aircraft"/> <AircraftInfo :aircraft="aircraft"/>
<ContentCardEditButton <ContentCardEditButton
:to='{name: "aircraftEdit", params: {id: aircraft.id, step: "photo"}}' v-if="aircraft.createdById === loggedUser?.id"
label="Titulní fotografie" :to='{name: "aircraftEdit", params: {id: aircraft.id, step: "photo"}}'
label="Titulní fotografie"
/> />
<PhotoGallery :photos="aircraft.photos"/> <PhotoGallery :photos="aircraft.photos"/>
<h3>Lety</h3> <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. Zatím žádné lety.
</q-banner> </BasicInfoBanner>
<FlightsList :items="aircraft.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)"
/>
</q-card-section> </q-card-section>
</template> </template>
</ContentCard> </ContentCard>
+18 -4
View File
@@ -6,18 +6,26 @@ import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useAircraftsStore} from "stores/Aircrafts"; import {useAircraftsStore} from "stores/Aircrafts";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useAppStore} from "stores/App"; 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"); useAppStore().setPageTitle("Letadla");
const loadNextAircraftsPage = (appendItems) => loadPage({
fetchFunc: useAircraftsStore().fetch, appendItems
});
loadNextAircraftsPage(false);
const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsStore());
</script> </script>
<template> <template>
<BasicListSkeleton v-if="isQueryRunning || !aircrafts.length"/> <BasicListSkeleton :items="paginationLimit" v-if="isQueryRunning && !totalItemsCount"/>
<BasicInfoBanner <BasicInfoBanner
v-if="!isQueryRunning && aircrafts.length === 0" v-if="!isQueryRunning && totalItemsCount === 0"
:route-add="{name: 'addAircraft'}" :route-add="{name: 'addAircraft'}"
label-add="Vytvořit letadlo" label-add="Vytvořit letadlo"
> >
@@ -29,6 +37,12 @@ useAppStore().setPageTitle("Letadla");
<AircraftsList <AircraftsList
v-else v-else
:items="aircrafts" :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'}"/> <FAB mode="add" :router-link="{name: 'addAircraft'}"/>
@@ -9,15 +9,16 @@ import {successObject} from "src/composables/dialog";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import AccessDeniedBanner from "components/banners/AccessDeniedBanner.vue"; import AccessDeniedBanner from "components/banners/AccessDeniedBanner.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
import PhotosEditorList from "components/photos/PhotosEditorList.vue"; import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue"; import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue"; import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const $router = useRouter(); const $router = useRouter();
const $q = useQuasar(); const $q = useQuasar();
const aircraftId = parseInt(useRoute().params.id); const aircraftId = parseInt(useRoute().params.id);
const loggedUser = storeToRefs(useUserStore()).user; const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {aircraft, isMutationRunning, isQueryRunning} = storeToRefs(useAircraftsStore()); const {aircraft, isMutationRunning, isQueryRunning} = storeToRefs(useAircraftsStore());
@@ -56,9 +57,10 @@ const formStep = computed(() => useRoute().params.step || "info");
<template v-else> <template v-else>
<CommonEditSwitcher :route="{name: 'aircraftEdit', params: {id: aircraft?.id}}"/> <CommonEditSwitcher :route="{name: 'aircraftEdit', params: {id: aircraft?.id}}"/>
<template v-if="formStep === 'basic'"> <template v-if="formStep === 'info'">
<AircraftEditSkeleton v-if="isQueryRunning || !aircraft"/> <AircraftEditSkeleton v-if="isQueryRunning || !aircraft"/>
<AircraftForm <AircraftForm
v-else
@save="handleSave" @save="handleSave"
:default-values="aircraft" :default-values="aircraft"
:organizations="loggedUser.organizations" :organizations="loggedUser.organizations"
@@ -68,6 +70,9 @@ const formStep = computed(() => useRoute().params.step || "info");
<template v-else-if="formStep === 'photo'"> <template v-else-if="formStep === 'photo'">
<PhotoGallerySkeleton v-if="isQueryRunning || !aircraft"/> <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 <PhotosEditorList
v-else v-else
:photos="aircraft.photos" :photos="aircraft.photos"
+78 -40
View File
@@ -8,57 +8,95 @@ import {useCopilotsStore} from "stores/Copilots";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import PhotoGallery from "components/photos/PhotoGallery.vue"; import PhotoGallery from "components/photos/PhotoGallery.vue";
import ContentCard from "components/ContentCard.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); const copilotId = parseInt(useRoute().params.id);
onMounted(() => { useCopilotsStore().fetchDetail(copilotId).then(() => {
useCopilotsStore().fetchDetail(copilotId).then(() => { useAppStore().setPageTitle(copilot.value.name);
useAppStore().setPageTitle(copilot.value.name); useAppStore().setBreadcrumbs([
useAppStore().setBreadcrumbs([ {to: {name: "copilots"}, label: "Kopiloti"},
{to: {name: "copilots"}, label: "Kopiloti"}, {label: copilot.value.name}
{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 {isQueryRunning, copilot} = storeToRefs(useCopilotsStore());
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script> </script>
<template> <template>
<CopilotDetailSkeleton v-if="isQueryRunning || !copilot"/> <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"> <PublishButton
<template #actions> :url-prefix="`/${loggedUser?.publicUsername}/a/`"
<div class="col-grow"> :is-public="copilot.isPublic"
<q-btn :require-url-slug="copilot.urlSlug === ''"
flat @change-visibility="togglePublicVisibility"
:to="{name: 'copilotEdit', params: {id: copilotId}}" />
icon="edit" <CopyLinkButton
text-color="primary" v-if="copilot.isPublic"
label="Upravit" :route="{
/> name: 'publicCopilotDetail', params: {
</div> username: loggedUser.publicUsername,
</template> copilotSlug: copilot.urlSlug
<template #content> }}"
<q-card-section> />
<PhotoGallery :photos="copilot.photos" v-if="copilot.photos.length > 0"/> </div>
<q-banner v-else class="bg-light-blue text-white"> </template>
Zatím tu nemáš žádné společné fotky. Po nahrání fotky můžeš označit tuto osobu a takto označené <template #content>
fotografie se zobrazí v galerii. <q-card-section>
</q-banner> <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"> <FlightsListSkeleton v-if="areFlightsLoading && !flights.length"/>
Zatím tu nemáš uložené žádné společné lety. <FlightsList
</q-banner> v-else-if="flights.length > 0"
:public="false"
<FlightsList :items="copilot.flights" :public="false" v-else/> :items="flights"
</q-card-section> :total-items-count="totalItemsCount"
</template> :is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
</ContentCard> @load-more="loadNextFlightsPage(true)"
/>
</template> <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> </template>
@@ -11,6 +11,7 @@ import {storeToRefs} from "pinia";
import PhotosEditorList from "components/photos/PhotosEditorList.vue"; import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue"; import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue"; import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const $q = useQuasar(); const $q = useQuasar();
const $router = useRouter(); const $router = useRouter();
@@ -61,6 +62,9 @@ const setTitlePhoto = (titlePhoto) => {
<template v-else-if="formStep === 'photo'"> <template v-else-if="formStep === 'photo'">
<PhotoGallerySkeleton v-if="isQueryRunning || !copilot" /> <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 <PhotosEditorList
v-else v-else
:photos="copilot.photos" :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 {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
import {useEventsStore} from "stores/Events"; import {useEventsStore} from "stores/Events";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {filters, loadPage, paginationLimit} from "src/composables/pagination";
import {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); const eventId = parseInt(useRoute().params.id);
onMounted(() => { onMounted(() => {
@@ -18,9 +26,25 @@ onMounted(() => {
{label: event.value.name} {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()); 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> </script>
<template> <template>
@@ -41,6 +65,16 @@ const {isQueryRunning, event} = storeToRefs(useEventsStore());
</q-item> </q-item>
</q-list> </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"/> <article v-html="event.description"/>
<h3>Lety</h3> <h3>Lety</h3>
@@ -49,8 +83,15 @@ const {isQueryRunning, event} = storeToRefs(useEventsStore());
Zatím tu nemáš uložené žádné lety. Zatím tu nemáš uložené žádné lety.
</q-banner> </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}}"/> <FAB mode="edit" :router-link="{name: 'eventEdit', params: {id: eventId}}"/>
</template> </template>
</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 EventsList from "components/event/EventsList.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useEventsStore} from "stores/Events"; import {useEventsStore} from "stores/Events";
import {onBeforeMount} from "vue";
useEventsStore().fetch();
useAppStore().setPageTitle("Letecké události"); useAppStore().setPageTitle("Letecké události");
onBeforeMount(() => {
useEventsStore().fetch({});
});
const {isQueryRunning, events} = storeToRefs(useEventsStore()); const {isQueryRunning, events} = storeToRefs(useEventsStore());
</script> </script>
@@ -31,6 +35,7 @@ const {isQueryRunning, events} = storeToRefs(useEventsStore());
<EventsList <EventsList
v-else v-else
:items="events" :items="events"
:route-detail="{name: 'eventDetail', params: {}}"
/> />
<FAB :router-link="{name: 'addEvent'}" mode="add"/> <FAB :router-link="{name: 'addEvent'}" mode="add"/>
+34 -26
View File
@@ -42,11 +42,11 @@ watch(() => $route.params.step, (val) => formStep.value = val);
onMounted(() => { onMounted(() => {
formStep.value = $route.params.step || "basic"; formStep.value = $route.params.step || "basic";
useAircraftsStore().fetch(); useAircraftsStore().fetch({});
usePointsOfInterestStore().fetch(); usePointsOfInterestStore().fetch({});
useAirportsStore().fetch(); useAirportsStore().fetch();
useCopilotsStore().fetch(); useCopilotsStore().fetch();
useEventsStore().fetch(); useEventsStore().fetch({});
useFlightsStore().fetchDetail(flightId).then((f) => { useFlightsStore().fetchDetail(flightId).then((f) => {
const flightNameFormatted = flightName(f); const flightNameFormatted = flightName(f);
@@ -133,7 +133,7 @@ const updateSinglePhoto = (id, formData) => {
return usePhotosStore().update(id, formData).then((data) => { return usePhotosStore().update(id, formData).then((data) => {
const idx = _findPhotoIndex(data.id); const idx = _findPhotoIndex(data.id);
flight.value.photos[idx] = data; flight.value.photos[idx] = data;
usePointsOfInterestStore().fetch(); usePointsOfInterestStore().fetch({});
}); });
} }
@@ -208,38 +208,44 @@ const handleBatchPhotoEdit = async (flightId, data) => {
<q-tab-panel name="basic"> <q-tab-panel name="basic">
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/> <FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
<FlightFormBasic <FlightFormBasic
@save="handleFlightEdit" v-else
:flight="flight" @save="handleFlightEdit"
:locations="pointsOfInterest" :flight="flight"
:airports="airports" :locations="pointsOfInterest"
:aircrafts="aircrafts" :airports="airports"
:aircrafts="aircrafts"
/> />
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="copilot"> <q-tab-panel name="copilot">
<FlightEditCopilotSkeleton :step="formStep" v-if="isQueryRunning || !flight"/> <FlightEditCopilotSkeleton v-if="isQueryRunning || !flight"/>
<FlightFormCopilot @save="handleFlightEdit" :flight="flight" :copilots="copilots"/> <FlightFormCopilot
v-else
@save="handleFlightEdit"
:flight="flight"
:copilots="copilots"/>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="track"> <q-tab-panel name="track">
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/> <FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
<FlightFormTrack <FlightFormTrack
v-if="pointsOfInterest" v-if="flight && pointsOfInterest"
:flight="flight" :flight="flight"
:locations="pointsOfInterest" :locations="pointsOfInterest"
:airports="airports" :airports="airports"
@save="handleFlightEdit" @save="handleFlightEdit"
/> />
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="photos"> <q-tab-panel name="photos">
<FlightFormPhotos <FlightFormPhotos
:flight="flight" :flight="flight"
:is-request-pending="isRequestPending || uploadInProgress" :is-request-pending="isRequestPending || uploadInProgress"
@save="handlePhotosUpload" @save="handlePhotosUpload"
/> />
<PhotosEditorList <template v-if="flight">
<PhotosEditorList
:title-photo="flight.titlePhoto" :title-photo="flight.titlePhoto"
:photos="flight.photos" :photos="flight.photos"
:show-actions="true" :show-actions="true"
@@ -248,19 +254,19 @@ const handleBatchPhotoEdit = async (flightId, data) => {
@batch-rotate="rotatePhotos" @batch-rotate="rotatePhotos"
@delete="deletePhoto" @delete="deletePhoto"
@set-title-photo="setTitlePhoto" @set-title-photo="setTitlePhoto"
/> />
<!-- Dialogs for photo edit --> <!-- Dialogs for photo edit -->
<PhotoFormDialog <PhotoFormDialog
v-model="openEdit" v-model="openEdit"
:copilots="flight.copilots" :copilots="flight.copilots"
:aircrafts="aircrafts" :aircrafts="aircrafts"
:locations="pointsOfInterest" :locations="pointsOfInterest"
:photo="editPhoto" :photo="editPhoto"
@save="handlePhotoUpdate(flightId, $event)" @save="handlePhotoUpdate(flightId, $event)"
/> />
<BatchPhotoFormDialog <BatchPhotoFormDialog
v-model="openBatchEdit" v-model="openBatchEdit"
:locations="pointsOfInterest" :locations="pointsOfInterest"
:photos="editBatchPhotos" :photos="editBatchPhotos"
@@ -268,7 +274,9 @@ const handleBatchPhotoEdit = async (flightId, data) => {
:aircrafts="aircrafts" :aircrafts="aircrafts"
@save="handleBatchPhotoEdit(flightId, $event)" @save="handleBatchPhotoEdit(flightId, $event)"
@delete="deletePhoto" @delete="deletePhoto"
/> />
</template>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="notes"> <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> <script setup>
import {useRoute, useRouter} from "vue-router"; 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 FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue";
import {flightName} from "src/composables/flight"; import {flightName} from "src/composables/flight";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import FlightBasicInfo from "components/flights/FlightBasicInfo.vue"; import FlightBasicInfo from "components/flights/FlightBasicInfo.vue";
import {copyToClipboard, useQuasar} from "quasar"; import {useQuasar} from "quasar";
import {confirmObject, successObject} from "src/composables/dialog"; import {confirmObject, successObject} from "src/composables/dialog";
import {useFlightsStore} from "stores/Flights";
import FlightTrackMap from "components/flights/FlightTrackMap.vue"; import FlightTrackMap from "components/flights/FlightTrackMap.vue";
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue"; import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import PhotoGallery from "components/photos/PhotoGallery.vue"; import PhotoGallery from "components/photos/PhotoGallery.vue";
import TitlePhotoWithGallery from "components/photos/TitlePhotoWithGallery.vue";
import ContentCard from "components/ContentCard.vue"; import ContentCard from "components/ContentCard.vue";
import ContentCardEditButton from "components/ContentCardEditButton.vue"; import ContentCardEditButton from "components/ContentCardEditButton.vue";
import {toggleVisibility} from "src/composables/form/formHandling"; 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 flightId = parseInt(useRoute().params.id);
const loggedUser = storeToRefs(useUserStore()).user; const loggedUser = storeToRefs(useLoggedUserStore()).user;
useFlightsStore().fetchDetail(flightId);
const {isQueryRunning, flight} = storeToRefs(useFlightsStore()); const {isQueryRunning, flight} = storeToRefs(useFlightsStore());
watch(() => flight.value, (f) => { watch(() => flight.value, (f) => {
if (!f) return; if (!f) return;
@@ -35,113 +43,107 @@ watch(() => flight.value, (f) => {
]); ]);
}); });
const $q = useQuasar();
const $router = useRouter();
const handleDeleteAndRedirect = () => { const handleDeleteAndRedirect = () => {
$q.dialog(confirmObject("Opravdu chceš smazat tento let?")) $q.dialog(confirmObject("Opravdu chceš smazat tento let?"))
.onOk(() => { .onOk(() => {
useFlightsStore().destroy(flightId).then(() => { useFlightsStore().destroy(flightId).then(() => {
$router.push({name: "myFlights"}); $router.push({name: "myFlights"});
$q.notify(successObject(`Úspěšně smazáno`)); $q.notify(successObject(`Úspěšně smazáno`));
});
}); });
});
} }
const togglePublicVisibility = () => toggleVisibility({ const togglePublicVisibility = (variables) => toggleVisibility({
$q, $q, store: useFlightsStore(), object: flight.value, mutation: TOGGLE_FLIGHT_PUBLISH, variables
store: useFlightsStore(),
object: flight.value
}); });
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> </script>
<template> <template>
<FlightDetailSkeleton v-if="isQueryRunning || !flight"/> <FlightDetailSkeleton v-if="isQueryRunning || !flight"/>
<ContentCard :title-photo="flight.titlePhoto" :title="flightName(flight)" v-else> <ContentCard :title-photo="flight.titlePhoto" :title="flightName(flight)" v-else>
<template #actions> <template #actions>
<div class="col-grow"> <template v-if="loggedUser.publicUsername">
<q-btn <div class="col-grow">
@click="togglePublicVisibility" <PublishButton
:icon="flight.isPublic ? 'visibility_off' : 'visibility'" :url-prefix="`/${loggedUser.publicUsername}/`"
:disable="!loggedUser?.publicUsername" :is-public="flight.isPublic"
flat :require-url-slug="!flight.urlSlug"
:label="flight.isPublic ? 'Skrýt' : 'Zveřejnit'" @change-visibility="togglePublicVisibility"
/> />
<CopyLinkButton
<q-btn :route='{
icon="link" name: "publicFlightDetail",
@click="copyLinkToClipboard()" params: {flightSlug: flight.urlSlug, username: loggedUser.publicUsername}
flat }'
v-if="flight.isPublic" v-if="flight.isPublic"
label="Získat odkaz" />
/> </div>
</div> </template>
<q-btn @click="handleDeleteAndRedirect" icon="delete" flat text-color="red" label="Smazat"/> <q-btn @click="handleDeleteAndRedirect" icon="delete" flat text-color="red" label="Smazat"/>
</template> </template>
<template #content> <template #content>
<q-card-section> <q-card-section>
<ContentCardEditButton <ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'basic'}}" :to="{name: 'flightEdit', params: {id: flightId, step: 'basic'}}"
label="Upravit informace" label="Upravit informace"
/> />
<FlightBasicInfo <FlightBasicInfo
:flight="flight" :flight="flight"
:poi-detail-route="{name: 'poiDetail'}" :poi-detail-route="{name: 'poiDetail'}"
:aircraft-detail-route="{name: 'aircraftDetail'}" :aircraft-detail-route="{name: 'aircraftDetail'}"
:copilot-detail-route="{name: 'copilotDetail'}" :copilot-detail-route="{name: 'copilotDetail'}"
/> />
<ContentCardEditButton <ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'notes'}}" :to="{name: 'flightEdit', params: {id: flightId, step: 'notes'}}"
label="Upravit text" label="Upravit text"
/> />
<article class="q-my-lg" v-html="flight.description"/> <article class="q-my-lg" v-html="flight.description"/>
<BasicInfoBanner v-if="flight.description === ''">Popiš, jaký byl let.</BasicInfoBanner> <BasicInfoBanner v-if="flight.description === ''">Popiš, jaký byl let.</BasicInfoBanner>
<q-separator/> <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 <ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}" :to="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
label="Upravit fotografie" label="Upravit fotografie"
/> />
<BasicInfoBanner <BasicInfoBanner
v-if="flight.photos.length === 0" v-if="flight.photos.length === 0"
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}" :route-add="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
> >
U tohoto letu nemáš nahrané žádné fotografie. U tohoto letu nemáš nahrané žádné fotografie.
</BasicInfoBanner> </BasicInfoBanner>
<PhotoGallery <PhotoGallery
v-else v-else
:photos="flight.photos" :photos="flight.photos"
/> />
<q-separator/> <q-separator/>
<ContentCardEditButton <ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'track'}}" :to="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
label="Upravit trasu" label="Upravit trasu"
/> />
<BasicInfoBanner <BasicInfoBanner
v-if="flight.track.length === 0 && !flight.gpxTrack" v-if="flight.track.length === 0 && !flight.gpxTrack"
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'track'}}" :route-add="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
> >
Nemáš nastavenou žádnou trasu. Nemáš nastavenou žádnou trasu.
</BasicInfoBanner> </BasicInfoBanner>
<FlightTrackMap <FlightTrackMap
v-else v-else
:flight="flight" :flight="flight"
:photos="flight.photos" :photos="flight.photos"
/> />
</q-card-section> </q-card-section>
</template> </template>
+19 -5
View File
@@ -6,24 +6,38 @@ import {useAppStore} from "stores/App";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue"; import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useFlightsStore} from "stores/Flights"; import {useFlightsStore} from "stores/Flights";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {loadPage, paginationLimit} from "src/composables/pagination";
useAppStore().setPageTitle("Moje lety"); 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> </script>
<template> <template>
<FlightsListSkeleton v-if="isQueryRunning"/> <FlightsListSkeleton v-if="isQueryRunning && flights.length === 0"/>
<BasicInfoBanner <BasicInfoBanner
v-else-if="!isQueryRunning && flights.length === 0" v-else-if="!isQueryRunning && totalItemsCount === 0"
:route-add="{name: 'addFlight'}" :route-add="{name: 'addFlight'}"
label-add="Přidat let" label-add="Přidat let"
> >
Nemáš zaznamenaný žádný let. Nemáš zaznamenaný žádný let.
</BasicInfoBanner> </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'}"/> <FAB mode="add" :router-link="{name: 'addFlight'}"/>
</template> </template>
+3 -4
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {computed, onMounted, ref} from "vue"; import {computed, onMounted} from "vue";
import {useAircraftsStore} from "stores/Aircrafts"; import {useAircraftsStore} from "stores/Aircrafts";
import {useFlightsStore} from "stores/Flights"; import {useFlightsStore} from "stores/Flights";
import {useRouter} from "vue-router"; 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"; import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
onMounted(() => { onMounted(() => {
useAircraftsStore().fetch() useAircraftsStore().fetch({})
usePointsOfInterestStore().fetch() usePointsOfInterestStore().fetch({})
useFlightsStore().fetch()
useAirportsStore().fetch() useAirportsStore().fetch()
useAppStore().setPageTitle("Nový let"); useAppStore().setPageTitle("Nový let");
@@ -4,7 +4,7 @@ import {computed, onMounted} from "vue";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import FAB from "components/FAB.vue"; import FAB from "components/FAB.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue"; import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import AircraftsList from "components/aircrafts/AircraftsList.vue"; import AircraftsList from "components/aircrafts/AircraftsList.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue"; import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
@@ -13,7 +13,7 @@ import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import {useOrganizationsStore} from "stores/Organizations"; import {useOrganizationsStore} from "stores/Organizations";
const organizationId = parseInt(useRoute().params.id); const organizationId = parseInt(useRoute().params.id);
const loggedUser = storeToRefs(useUserStore()).user; const loggedUser = storeToRefs(useLoggedUserStore()).user;
const $q = useQuasar(); const $q = useQuasar();
onMounted(() => { onMounted(() => {
@@ -67,7 +67,11 @@ const {isQueryRunning, organization} = storeToRefs(useOrganizationsStore());
<BasicListSkeleton :items-count-min="1" :items-count-max="5" v-if="isQueryRunning || !organization"/> <BasicListSkeleton :items-count-min="1" :items-count-max="5" v-if="isQueryRunning || !organization"/>
<template v-else> <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> <BasicInfoBanner v-else>
Žádné letadlo Žádné letadlo
</BasicInfoBanner> </BasicInfoBanner>
@@ -7,12 +7,12 @@ import OrganizationsList from "components/organizations/OrganizationsList.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue"; import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useOrganizationsStore} from "stores/Organizations"; import {useOrganizationsStore} from "stores/Organizations";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useUserStore} from "stores/User"; import {useLoggedUserStore} from "stores/LoggedUser";
useOrganizationsStore().fetch(); useOrganizationsStore().fetch();
useAppStore().setPageTitle("Organizace"); 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 myOrganizationIds = computed(() => (loggedUser.value && loggedUser.value.organizations?.map(o => o.id)) || []);
const {isQueryRunning, organizations} = storeToRefs(useOrganizationsStore()); const {isQueryRunning, organizations} = storeToRefs(useOrganizationsStore());
@@ -9,12 +9,13 @@ import {successObject} from "src/composables/dialog";
import {usePointsOfInterestTypeStore} from "stores/PointOfInterestType"; import {usePointsOfInterestTypeStore} from "stores/PointOfInterestType";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import POIForm from "components/poi/POIForm.vue"; 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 AccessDeniedBanner from "components/banners/AccessDeniedBanner.vue";
import PhotoEditorSkeleton from "components/photos/skeletons/PhotoEditorSkeleton.vue"; import PhotoEditorSkeleton from "components/photos/skeletons/PhotoEditorSkeleton.vue";
import PhotosEditorList from "components/photos/PhotosEditorList.vue"; import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue"; import CommonEditSwitcher from "components/navigation/CommonEditSwitcher.vue";
import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue"; import PhotoGallerySkeleton from "components/photos/skeletons/PhotoGallerySkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const poiId = parseInt(useRoute().params.id); const poiId = parseInt(useRoute().params.id);
@@ -32,7 +33,7 @@ usePointsOfInterestStore().fetchDetail(poiId).then((p) => {
usePointsOfInterestTypeStore().fetch(); usePointsOfInterestTypeStore().fetch();
const poiTypes = storeToRefs(usePointsOfInterestTypeStore()).types; const poiTypes = storeToRefs(usePointsOfInterestTypeStore()).types;
const loggedUser = storeToRefs(useUserStore()).user; const loggedUser = storeToRefs(useLoggedUserStore()).user;
const {isQueryRunning, pointOfInterest, isMutationRunning} = storeToRefs(usePointsOfInterestStore()); const {isQueryRunning, pointOfInterest, isMutationRunning} = storeToRefs(usePointsOfInterestStore());
const $q = useQuasar(); const $q = useQuasar();
@@ -69,6 +70,9 @@ const formStep = computed(() => useRoute().params.step || "info");
<template v-else-if="formStep === 'photo'"> <template v-else-if="formStep === 'photo'">
<PhotoGallerySkeleton v-if="isQueryRunning || !pointOfInterest"/> <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 <PhotosEditorList
v-else v-else
:photos="pointOfInterest.photos" :photos="pointOfInterest.photos"

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