58 Commits
Author SHA1 Message Date
Michal Kváček 160bc1b031 Fix nacitani vsech letu do profilove casti 2026-09-14 12:28:50 +02:00
Michal Kváček ae7072ddee bugfixing 2026-09-14 11:08:22 +02:00
Michal Kváček f7845b963d Snad fix memory leaku kvuli axiosu 2026-09-14 10:48:55 +02:00
Michal Kváček 65ea19664c Bugfixing infinite scrollu u seznamu letu, novy vzhled detailu letu 2026-01-26 16:24:15 +01:00
Michal Kváček 348025afee Nastrel nove podoby detailu letu 2026-01-19 22:26:14 +01:00
Michal Kváček 33e7aa6e36 lepsi infinite scroll 2026-01-06 22:02:24 +01:00
Michal Kváček ea88a29750 Zfunkcneni aplikace, aktualizace zavislosti 2025-09-24 16:10:11 +02:00
Michal Kváček 448d97e310 Fix map 2025-09-18 22:04:37 +02:00
Michal Kváček 018153aff1 Bugfixing 2025-03-17 16:38:28 +01:00
Michal Kváček 87c7f17116 Upgrade quasaru, bugfixing 2025-03-05 13:23:56 +01:00
Michal Kváček 532aee9f32 Commit po dlohe dobe 2025-01-27 16:41:32 +01:00
Michal Kváček 92c9e53ea0 Bugfixing 2024-10-14 06:38:52 +02:00
Michal Kváček be977ee241 Pridani informace o planovaci na homepage 2024-10-09 06:57:21 +02:00
Michal Kváček bffdd96be2 Rozdeleni editace letu do samostatnych views 2024-10-03 22:41:14 +02:00
Michal Kváček 86e8dbf8e7 Rozdeleni planovace do samostatnych routes 2024-09-30 06:43:58 +02:00
Michal Kváček 786c59cc4a Zobrazeni pocasi po trase planu 2024-09-09 17:41:34 +02:00
Michal Kváček 7011aa3223 Nasazeni cookie listyz pres GTM, sentry jen v produkci, doplneni textovych stranek 2024-08-27 18:30:13 +02:00
Michal Kváček e8dc51a80d Vykreslovat trasu na zaklade dat z z db 2024-07-25 19:51:15 +02:00
Michal Kváček 88a760846c Pridana moznost zobrazeni planu v detailu letu 2024-07-11 07:26:56 +02:00
Michal Kváček a8ad8467ce Ve verejne casti s detailem planu ukazovat jen pouzite POI 2024-06-30 02:38:31 +02:00
Michal Kváček ba1089af3d Zakladni vytvareni letu na zaklade planu 2024-06-30 02:31:16 +02:00
Michal Kváček 16650cc0cb Fix z-indexu pro navigaci, pridani prostoru do verejne casti detialu letu 2024-06-25 22:27:23 +02:00
Michal Kváček 25b1735c87 Redesign sidebaru v planovaci, fix fullsreenu, zobrazovani mista kliku na mape 2024-06-25 21:14:35 +02:00
Michal Kváček 5280f60f34 Kurzy po planovane trase 2024-06-25 07:39:32 +02:00
Michal Kváček ffbb3ff178 Sentry, drobnosti 2024-06-24 16:23:31 +02:00
Michal Kváček e01bafee73 Detail planovace, verejne planovain 2024-06-11 22:03:53 +02:00
Michal Kváček 852d00b932 Pridani tabulky s necim jako navigacni stitek do planovace 2024-06-10 16:42:00 +02:00
Michal Kváček aa6bed6f7f Mapa s POI ve verejne casti 2024-06-04 10:56:14 +02:00
Michal Kváček 70bc57d3c7 Planovac, lepsi social image 2024-06-03 23:07:41 +02:00
Michal Kváček 32ff47a3a7 Optimalizace planovace, ikony pro letiste 2024-05-27 22:31:06 +02:00
Michal Kváček 97d1787642 Zobrazovani informaci o RWY, vylepseni zobrazeni vysek 2024-05-15 09:33:16 +02:00
Michal Kváček 8cc1746be7 Update vue na 3.4 2024-05-09 13:31:50 +02:00
Michal Kváček 3c51631bcd Aktualizace quasaru 2024-05-09 13:17:44 +02:00
Michal Kváček 9daea5974b Bugfixing 2024-05-07 08:24:57 +02:00
Michal Kváček a56c6828b1 Fix geocode u poi 2024-05-06 16:30:06 +02:00
Michal Kváček 670e80d998 Pridani linku na planovac do menu, drobny bugfixing 2024-05-06 09:40:25 +02:00
Michal Kváček be8966b5c1 Fullscreen u planovace 2024-05-02 16:29:17 +02:00
Michal Kváček 8dd0a16918 Ukladani metadat k planum 2024-04-30 22:34:41 +02:00
Michal Kváček 0760e6fb1c Vicemene funkcni mapova cast planovace 2024-04-29 06:45:29 +02:00
Michal Kváček bc0b5e2f15 Vkladani mista na konkretni pozici 2024-04-18 16:37:46 +02:00
Michal Kváček de0b75163d Dalsi iterace planovace, snad jiz kompletni vrstvy mapy 2024-04-17 13:21:32 +02:00
Michal Kváček cee979fec8 Zakladni verze planovace 2024-04-15 18:13:22 +02:00
Michal Kváček 338ec13db2 Merge branch 'master' of kvacek.cz:poletani.cz/app into planovac 2024-04-02 06:13:48 +02:00
Michal Kváček 9af80cda06 draft planovace 2024-03-25 21:59:55 +01:00
Michal Kváček 02b839674e Planovac, zobrazeni vzdusnych prostoru 2024-03-21 16:40:41 +01:00
Michal Kváček 275c48986e Fix zorbazovani udalosti 2024-03-14 06:27:30 +01:00
Michal Kváček 691bd4c7c5 Bootstrap na planovac 2024-03-14 06:27:01 +01:00
Michal Kváček c0cfd7115c uprava breadcrumbs v privatni casti, nasazeni prefetche 2024-03-13 16:16:08 +01:00
Michal Kváček 9379d3bfd6 Prusunuti nacitani detailu do prefetche (neverejna cast), zruseni resetu breadcrumbs a title pred zmenou routy 2024-03-13 07:31:53 +01:00
Michal Kváček 48f57cde0d Breadcrumbs resit pres funkce 2024-03-13 06:35:26 +01:00
Michal Kváček 5cff3580b0 Pagination prepsano do inteligentnejsi composable 2024-03-09 23:16:37 +01:00
Michal Kváček 883b4fa97f Init lightboxu az po otevreni fotky 2024-03-09 23:02:30 +01:00
Michal Kváček 722e832f1d Docasne vypnuti intersection observeru u fotek - nefunguje pak proklik na titulni fotku 2024-03-07 10:13:31 +01:00
Michal Kváček 415ff15ab9 Pridan intersection na galeriii a mapu -> zrychleni 2024-03-07 07:08:03 +01:00
Michal Kváček cd9c7351e9 Moznost nahravat fotky copilotem 2024-03-07 06:45:22 +01:00
Michal Kváček f0a4c439dc Fix nacitani spatnych dat do mapy u udalosti 2024-02-29 08:19:24 +01:00
Michal Kváček 7a45a0133e Mapa s trasami letu u eventu, drobna uprava vizualu eventu 2024-02-28 09:41:15 +01:00
Michal Kváček 92678ec829 Zobrazeni linku na event u detailu letu 2024-02-26 09:45:23 +01:00
179 changed files with 8019 additions and 3476 deletions
+2
View File
@@ -28,3 +28,5 @@ yarn-error.log*
*.njsproj *.njsproj
*.sln *.sln
.vscode .vscode
quasar.config.js.temporary.*
+6 -3
View File
@@ -1,5 +1,5 @@
# develop stage # develop stage
FROM node:20-alpine as develop-stage FROM node:22-alpine AS develop-stage
ENV TZ=Europe/Prague ENV TZ=Europe/Prague
@@ -12,12 +12,15 @@ RUN npx update-browserslist-db@latest
COPY . . COPY . .
# build stage # build stage
FROM develop-stage as build-stage FROM develop-stage AS build-stage
ENV NODE_ENV production ENV NODE_ENV production
ENV API_URL "https://api.poletani.cz"
ENV GOOGLE_MAPS_API_KEY "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
RUN quasar build -m ssr RUN quasar build -m ssr
RUN cp /app/dist/ssr/server/server-entry.mjs /app/dist/ssr/server/server-entry.js
FROM node:20-alpine as production-stage FROM node:22-alpine AS production-stage
ENV NODE_ENV production ENV NODE_ENV production
ENV TZ=Europe/Prague ENV TZ=Europe/Prague
-201
View File
@@ -1,201 +0,0 @@
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.
+3 -9
View File
@@ -24,7 +24,9 @@ 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 {
@@ -32,10 +34,6 @@ 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;
@@ -48,9 +46,5 @@ http {
expires $expires; expires $expires;
try_files $uri /index.html; try_files $uri /index.html;
} }
location /sitemap.xml {
try_files $uri $api_url;
}
} }
} }
-7
View File
@@ -2,13 +2,6 @@ version: "3.4"
services: services:
poletani-app: poletani-app:
image: "docker.kvacek.cz/poletani/app:latest" image: "docker.kvacek.cz/poletani/app:latest"
# build:
# context: .
# dockerfile: Dockerfile
# target: "production-stage"
restart: always restart: always
ports: ports:
# - 9000:80
- 9000:3000 - 9000:3000
environment:
API_URL: "https://api.poletani.cz"
+2 -35
View File
@@ -1,30 +1,5 @@
services: services:
#
# db:
# image: mariadb:10.11
# ports:
# - 3306:3306
# environment:
# - MYSQL_USER=root
# - MYSQL_DATABASE=ull_tracker
# - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=true
# volumes:
# - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
# - db-data:/var/lib/mysql:rw
# api:
# build:
# context: ../api
# ports:
# - 8000:8000
# volumes:
# - ../api:/app
# environment:
# TZ: "Europe/Prague"
## command: ls -l /app
app: app:
network_mode: host
build: build:
context: . context: .
target: "develop-stage" target: "develop-stage"
@@ -32,19 +7,11 @@ services:
- "9000:9000" - "9000:9000"
- "9001:9100" - "9001:9100"
volumes: volumes:
- ./src:/app/src - ./:/app
- ./src-ssr:/app/src-ssr
- ./public:/app/public
# - ./node_modules:/app/node_modules:delegated
command: quasar dev # -m ssr command: quasar dev # -m ssr
environment: environment:
API_URL: "http://127.0.0.1:8000" API_URL: "http://127.0.0.1:8000"
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
# nginx:
# image:
#
#
volumes: volumes:
db-data: { } db-data: { }
+16
View File
@@ -11,8 +11,24 @@
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico"> <link rel="icon" type="image/ico" href="favicon.ico">
<!-- Google Tag Manager -->
<script v-if="process.env.NODE_ENV === 'production'">
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-WC8BTPJ4');
</script>
<!-- End Google Tag Manager -->
</head> </head>
<body> <body>
<!-- Google Tag Manager (noscript) -->
<noscript v-if="process.env.NODE_ENV === 'production'">
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WC8BTPJ4" height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->
<!-- quasar:entry-point --> <!-- quasar:entry-point -->
</body> </body>
</html> </html>
+37 -22
View File
@@ -10,34 +10,49 @@
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore" "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.7.14", "@apollo/client": "^4.0.5",
"@quasar/extras": "^1.15.11", "@quasar/extras": "^1.16.16",
"@vue/apollo-composable": "4.0.0-beta.2", "@sentry/browser": "^10.14.0",
"@vue/apollo-option": "^4.0.0-beta.5", "@sentry/integrations": "^7.114.0",
"apollo-upload-client": "^17.0.0", "@sentry/vue": "^10.14.0",
"axios": "^1.6.2", "@vue/apollo-composable": "4.2.2",
"chart.js": "^4.4.1", "@vue/apollo-option": "^4.2.0",
"graphql": "16.6.0", "apollo-upload-client": "^19.0.0",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"graphql": "16.11.0",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"photoswipe": "^5.4.2", "photoswipe": "^5.4.4",
"photoswipe-dynamic-caption-plugin": "^1.2.7", "photoswipe-dynamic-caption-plugin": "^1.2.7",
"pinia": "^2.0.33", "pinia": "^3.0.3",
"quasar": "^2.14.1", "quasar": "2.18.2",
"vue": "^3.3.11", "rxjs": "^7.8.2",
"vue-chartjs": "^5.2.0", "vue": "3.5.21",
"vue-chartjs": "^5.3.2",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",
"vue-router": "^4.2.5", "vue-router": "^4.5.0",
"vue3-google-map": "^0.15.0" "vue3-google-map": "^0.24.1"
}, },
"devDependencies": { "devDependencies": {
"@quasar/app-vite": "^1.7.1", "@quasar/app-vite": "2.4.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.21",
"eslint": "^8.55.0", "eslint": "^9.36.0",
"eslint-config-prettier": "^8.10.0", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^9.19.2", "eslint-plugin-vue": "^10.5.0",
"postcss": "^8.4.14", "postcss": "^8.5.6",
"prettier": "^2.8.8" "prettier": "^3.6.2"
}, },
"browserslist": [
"last 10 Chrome versions",
"last 10 Firefox versions",
"last 4 Edge versions",
"last 7 Safari versions",
"last 8 Android versions",
"last 8 ChromeAndroid versions",
"last 8 FirefoxAndroid versions",
"last 10 iOS versions",
"last 5 Opera versions"
],
"engines": { "engines": {
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
"yarn": ">= 1.21.1" "yarn": ">= 1.21.1"
-27
View File
@@ -1,27 +0,0 @@
/* eslint-disable */
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
plugins: [
// https://github.com/postcss/autoprefixer
require('autoprefixer')({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
})
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
]
}
+6
View File
@@ -3,3 +3,9 @@ apps:
script: /app/index.js script: /app/index.js
exec_mode: cluster exec_mode: cluster
instances: 3 instances: 3
# Pojistka proti bodu 1 (a podobným leakům): pokud paměť workeru
# přeroste limit, PM2 ho sám restartuje dřív, než dojde k OOM na
# celém VPS. Hodnotu je potřeba doladit dle reálné spotřeby jednoho
# SSR workeru po opravě bodu 1 (pm2 monit / pm2 list) - viz
# handoffs/05-pm2-docker-memory-limits.md.
max_memory_restart: '400M'
Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

+13 -8
View File
@@ -8,8 +8,7 @@
// Configuration for your app // Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const {configure} = require('quasar/wrappers'); import { configure } from 'quasar/wrappers'
module.exports = configure(function (ctx) { module.exports = configure(function (ctx) {
return { return {
@@ -29,6 +28,12 @@ module.exports = configure(function (ctx) {
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files // https://v2.quasar.dev/quasar-cli/boot-files
boot: [ boot: [
// client-only: Sentry browser SDK (browserTracingIntegration,
// replayIntegration) is DOM-only and has no business running per SSR
// request - server: false means Quasar excludes this boot file from
// the server bundle entirely (not just a runtime guard).
// See handoffs/04-sentry-ssr-guard.md.
{path: "sentry", server: false},
"cookies", "cookies",
"axios", "axios",
"apollo", "apollo",
@@ -56,13 +61,13 @@ module.exports = configure(function (ctx) {
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: { build: {
target: { target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'], browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20' node: 'node20'
}, },
env: { env: {
API_URL: ctx.dev ? 'http://localhost:8000' : 'https://api.poletani.cz', API_URL: process.env.API_URL,
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio" GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY
}, },
vueRouterMode: 'history', // available values: 'hash', 'history' vueRouterMode: 'history', // available values: 'hash', 'history'
@@ -80,8 +85,7 @@ module.exports = configure(function (ctx) {
minify: true, minify: true,
// polyfillModulePreload: true, // polyfillModulePreload: true,
// distDir // distDir
// extendViteConf(viteConf, {isServer}) {},
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {}, // viteVuePluginOptions: {},
// vitePlugins: [] // vitePlugins: []
@@ -140,7 +144,8 @@ module.exports = configure(function (ctx) {
ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name! ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR // will mess up SSR
extendSSRWebserverConf (esbuildConf) {}, extendSSRWebserverConf(esbuildConf) {
},
// extendPackageJson (json) {}, // extendPackageJson (json) {},
pwa: false, pwa: false,
+8 -8
View File
@@ -1,10 +1,10 @@
import { ssrMiddleware } from 'quasar/wrappers' import { defineSsrMiddleware } from '#q-app/wrappers'
// This middleware should execute as last one // This middleware should execute as last one
// since it captures everything and tries to // since it captures everything and tries to
// render the page with Vue // render the page with Vue
export default ssrMiddleware(({ app, resolve, render, serve }) => { export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
// we capture any other Express route and hand it // we capture any other Express route and hand it
// over to Vue and Vue Router to render our page // over to Vue and Vue Router to render our page
app.get(resolve.urlPath('*'), (req, res) => { app.get(resolve.urlPath('*'), (req, res) => {
@@ -28,12 +28,9 @@ 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
@@ -42,7 +39,6 @@ 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
@@ -51,7 +47,11 @@ 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.redirect(resolve.urlPath('500')) res.status(500).send('500 | Internal Server Error')
if (process.env.DEBUGGING) {
console.error(err.stack)
}
} }
}) })
}) })
+46 -25
View File
@@ -12,21 +12,22 @@
import express from 'express' import express from 'express'
import compression from 'compression' import compression from 'compression'
import { import {
ssrClose, defineSsrCreate,
ssrCreate, defineSsrInjectDevMiddleware,
ssrListen, defineSsrListen,
ssrRenderPreloadTag, defineSsrClose,
ssrServeStaticContent defineSsrServeStaticContent,
} from 'quasar/wrappers' defineSsrRenderPreloadTag
} from '#q-app/wrappers'
/** /**
* Create your webserver and return its instance. * Create your webserver and return its instance.
* If needed, prepare your webserver to receive * If needed, prepare your webserver to receive
* connect-like middlewares. * connect-like middlewares.
* *
* Should NOT be async! * Can be async: defineSsrCreate(async ({ ... }) => { ... })
*/ */
export const create = ssrCreate((/* { ... } */) => { export const create = defineSsrCreate((/* { ... } */) => {
const app = express() const app = express()
// attackers can use this header to detect apps running Express // attackers can use this header to detect apps running Express
@@ -42,6 +43,19 @@ export const create = ssrCreate((/* { ... } */) => {
return app return app
}) })
/**
* Used by Quasar SSR dev server to inject middleware into the webserver.
* It uses it to handle Vite dev server, handle public paths, etc.
* The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
*
* Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
*/
export const injectDevMiddleware = defineSsrInjectDevMiddleware(({ app }) => {
return (middleware) => {
app.use(middleware)
}
})
/** /**
* You need to make the server listen to the indicated port * You need to make the server listen to the indicated port
* and return the listening instance or whatever you need to * and return the listening instance or whatever you need to
@@ -52,10 +66,12 @@ export const create = ssrCreate((/* { ... } */) => {
* *
* For production, you can instead export your * For production, you can instead export your
* handler for serverless use or whatever else fits your needs. * handler for serverless use or whatever else fits your needs.
*
* Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
*/ */
export const listen = ssrListen(async ({ app, port, isReady }) => { export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
await isReady() const server = devHttpsApp || app
return app.listen(port, () => { return server.listen(port, () => {
if (process.env.PROD) { if (process.env.PROD) {
console.log('Server listening at port ' + port) console.log('Server listening at port ' + port)
} }
@@ -70,9 +86,9 @@ export const listen = ssrListen(async ({ app, port, isReady }) => {
* Should you need the result of the "listen()" call above, * Should you need the result of the "listen()" call above,
* you can use the "listenResult" param. * you can use the "listenResult" param.
* *
* Can be async. * Can be async: defineSsrClose(async ({ listenResult }) => { ... })
*/ */
export const close = ssrClose(({ listenResult }) => { export const close = defineSsrClose(({ listenResult }) => {
return listenResult.close() return listenResult.close()
}) })
@@ -81,14 +97,19 @@ const maxAge = process.env.DEV
: 1000 * 60 * 60 * 24 * 30 : 1000 * 60 * 60 * 24 * 30
/** /**
* Should return middleware that serves the indicated path * Should return a function that will be used to configure the webserver
* with static content. * to serve static content at "urlPath" from "pathToServe" folder/file.
*
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
*
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
*/ */
export const serveStaticContent = ssrServeStaticContent((path, opts) => { export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
return express.static(path, { return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
maxAge, const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
...opts app.use(resolve.urlPath(urlPath), serveFn)
}) }
}) })
const jsRE = /\.js$/ const jsRE = /\.js$/
@@ -103,13 +124,13 @@ const pngRE = /\.png$/
* Should return a String with HTML output * Should return a String with HTML output
* (if any) for preloading indicated file * (if any) for preloading indicated file
*/ */
export const renderPreloadTag = ssrRenderPreloadTag((file) => { export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
if (jsRE.test(file) === true) { if (jsRE.test(file) === true) {
return `<link rel="modulepreload" href="${file}" crossorigin>` return `<link rel="modulepreload" href="${file}" crossorigin>`
} }
if (cssRE.test(file) === true) { if (cssRE.test(file) === true) {
return `<link rel="stylesheet" href="${file}">` return `<link rel="stylesheet" href="${file}" crossorigin>`
} }
if (woffRE.test(file) === true) { if (woffRE.test(file) === true) {
@@ -121,15 +142,15 @@ export const renderPreloadTag = ssrRenderPreloadTag((file) => {
} }
if (gifRE.test(file) === true) { if (gifRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/gif">` return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
} }
if (jpgRE.test(file) === true) { if (jpgRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg">` return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
} }
if (pngRE.test(file) === true) { if (pngRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/png">` return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
} }
return '' return ''
-10
View File
@@ -1,10 +0,0 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
ssr: true;
}
}
+6
View File
@@ -4,6 +4,8 @@ import {computed} from "vue";
import ErrorNotFound from "pages/errors/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";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
const route = useRoute(); const route = useRoute();
@@ -13,6 +15,10 @@ const layoutComponent = computed(() => {
}); });
const appStatus = storeToRefs(useAppStore()).appStatus; const appStatus = storeToRefs(useAppStore()).appStatus;
useMeta(() => metaData({
title: useAppStore().pageTitle,
}));
</script> </script>
<template> <template>
View File
+126 -82
View File
@@ -1,105 +1,149 @@
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core' import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core'
import {onError} from "@apollo/client/link/error"; import {ErrorLink} from "@apollo/client/link/error";
import {defineStore} from "pinia";
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 {useAuthStore} from "stores/Auth"; import {useAuthStore} from "stores/Auth";
import {useRouter} from "vue-router"; import UploadHttpLink from "apollo-upload-client/UploadHttpLink.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(useAuthStore(store).refreshAccessToken() return 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í vypršelo. Přihlaš se prosím znovu."));
router.push({name: "login"}) router.push({name: "login"})
})) })
.filter((value) => Boolean(value)) .then(value => value.flatMap((accessToken) => {
.flatMap((accessToken) => { const oldHeaders = operation.getContext().headers;
const oldHeaders = operation.getContext().headers; operation.setContext({
operation.setContext({ headers: {
headers: { ...oldHeaders,
...oldHeaders, authorization: `Bearer ${accessToken}`,
authorization: `Bearer ${accessToken}`, },
}, });
});
// retry the request, returning the new observable // retry the request, returning the new observable
return forward(operation); return forward(operation);
}); }))
} }
export const apolloWrapper = {}; // SSR: boot() runs again for every request. The client/query/mutate below
export default (({store, router}) => { // used to live on a shared module-level `apolloWrapper` object that every
const uploadLink = createUploadLink({ // request's boot() reassigned in place - under concurrent SSR requests, one
uri: process.env.API_URL + '/graphql', // request could end up calling into another request's (still-being-set-up
credentials: 'include' // or already-overwritten) apolloClient/store, i.e. querying/mutating with
}) // the wrong user's auth context. See
// handoffs/02-apollo-wrapper-race-condition.md for the full analysis.
//
// Fix: make this a Pinia store instead of a plain shared object. `init()`
// is called from boot() with the request's own `store`/`router` explicitly
// (same proven pattern as useAuthStore(store) elsewhere in this file), so
// the closures below always close over the correct request. `query`/
// `mutate` are then read back through Pinia's own SSR-safe per-request
// resolution (useApolloStore() with no arg, called from preFetch/
// onServerPrefetch-triggered store actions - the same mechanism every
// other useXStore() call in this codebase already relies on), instead of
// through a shared mutable object.
export const useApolloStore = defineStore('Apollo', () => {
let client = null
let queryImpl = null
let mutateImpl = null
const authLink = new ApolloLink((operation, forward) => { const init = ({store, router}) => {
const accessToken = useAuthStore(store).getAccessToken(); if (client) return // boot() already initialized this request's store
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
});
}
return forward(operation);
});
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => { const uploadLink = new UploadHttpLink({
if (networkError?.statusCode === 401) { uri: process.env.API_URL + '/graphql',
return handleRefreshToken(store, router, forward, operation); credentials: 'include'
} })
if (graphQLErrors) { const authLink = new ApolloLink((operation, forward) => {
for (const error of graphQLErrors) { const accessToken = useAuthStore(store).getAccessToken();
if (error.message.includes("401") || error.message.includes("Not authorized")) { if (accessToken) {
return handleRefreshToken(store, router, forward, operation); operation.setContext({
} else if (error.message.includes("404") || error.message.includes("Not found")) { headers: {"Authorization": `Bearer ${accessToken}`},
useAppStore(store).appStatus = 404; });
} else { }
Notify.create(errorObject("API error: " + error.message)); return forward(operation);
});
const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => {
if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation);
}
if (graphQLErrors) {
for (const error of graphQLErrors) {
if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation);
} else if (error.message.includes("404") || error.message.includes("Not found")) {
useAppStore(store).appStatus = 404;
} else {
Notify.create(errorObject("API error: " + error.message));
}
} }
} }
}
});
const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, uploadLink]), cache: new InMemoryCache(), connectToDevTools: true,
});
apolloWrapper.query = (query, variables = {}) => {
return apolloClient.query({
query: query,
fetchPolicy: 'no-cache',
variables: variables
}) })
.then((response) => response)
.catch(err => { client = new ApolloClient({
const errStr = err.toString(); link: ApolloLink.from([authLink, errorLink, uploadLink]),
if (errStr.includes('Not found') || errStr.includes("No row was found")) { cache: new InMemoryCache(),
useAppStore(store).appStatus = 404; connectToDevTools: true,
return Promise.resolve(); ssrMode: true,
} });
else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err) queryImpl = (query, variables = {}) => {
} return client.query({
return Promise.reject(err); query: query,
})
};
apolloWrapper.mutate = (mutation, variables) => {
return apolloClient
.mutate({
mutation: mutation,
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
variables: variables, variables: variables
}) })
.then((response) => response) .then((response) => response)
.catch(err => { .catch(err => {
console.error("Error during mutation: ", err); const errStr = err.toString();
}) if (errStr.includes('Not found') || errStr.includes("No row was found")) {
}; useAppStore(store).appStatus = 404;
return Promise.resolve();
} else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err)
}
return Promise.reject(err);
})
};
mutateImpl = (mutation, variables) => {
return client
.mutate({
mutation: mutation,
fetchPolicy: 'no-cache',
variables: variables,
})
.then((response) => response)
.catch(err => {
console.error("Error during mutation: ", err);
})
};
}
const query = (query, variables) => queryImpl(query, variables)
const mutate = (mutation, variables) => mutateImpl(mutation, variables)
return {init, query, mutate}
})
// Backward-compatible shape so existing call sites
// (`apolloWrapper.query(...)`, `apolloWrapper.mutate(...)` in
// stores/helpers.js and stores/Organizations.js) keep working unchanged,
// now delegating to the request-scoped store above instead of a shared
// mutable singleton.
export const apolloWrapper = {
query: (...args) => useApolloStore().query(...args),
mutate: (...args) => useApolloStore().mutate(...args),
}
export default (({store, router}) => {
useApolloStore(store).init({store, router})
}); });
+18 -2
View File
@@ -13,8 +13,24 @@ const api = axios.create({
}, },
}) })
// SSR: boot() runs again for every request, but `api` above is a
// module-level singleton created once. Without ejecting the previous
// request's interceptors before adding new ones, api.interceptors.*
// would grow without bound for the lifetime of the process, each
// handler closing over that request's (by-then-stale) store - see
// handoffs/01-axios-interceptors-leak.md for the full analysis.
let requestInterceptorId
let responseInterceptorId
export default boot(({store}) => { export default boot(({store}) => {
api.interceptors.request.use((config) => { if (requestInterceptorId !== undefined) {
api.interceptors.request.eject(requestInterceptorId)
}
if (responseInterceptorId !== undefined) {
api.interceptors.response.eject(responseInterceptorId)
}
requestInterceptorId = api.interceptors.request.use((config) => {
const token = useAuthStore(store).getAccessToken(); const token = useAuthStore(store).getAccessToken();
if (token) { if (token) {
config.headers['Authorization'] = `Bearer ${token}` config.headers['Authorization'] = `Bearer ${token}`
@@ -28,7 +44,7 @@ export default boot(({store}) => {
}) })
// Response interceptor for API calls // Response interceptor for API calls
api.interceptors.response.use( responseInterceptorId = api.interceptors.response.use(
(response) => response, (response) => response,
async function (error) { async function (error) {
const originalRequest = error.config; const originalRequest = error.config;
+28 -5
View File
@@ -1,9 +1,32 @@
import {Cookies} from 'quasar' import {Cookies} from 'quasar'
import {boot} from "quasar/wrappers"; import {boot} from "quasar/wrappers";
import {defineStore} from 'pinia'
let cookies = Cookies; // SSR: boot() runs again for every request. `cookies` used to be a single
export default boot(({ssrContext}) => { // shared module-level variable reassigned in place by every request's
cookies = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies // boot() - under concurrent SSR requests, one request's
// Cookies.parseSSR(ssrContext) result (which carries that request's own
// accessToken) could overwrite another still-in-flight request's
// `cookies` before it finished reading it, leaking one user's access
// token to another. See handoffs/03-cookies-shared-state.md.
//
// Fix: make this a Pinia store, initialized explicitly per request from
// boot() with that request's own `store` (same proven pattern as
// boot/apollo.js), instead of a shared mutable variable.
export const useCookiesStore = defineStore('Cookies', () => {
let instance = null
const init = (ssrContext) => {
instance = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
}
const get = (...args) => instance.get(...args)
const set = (...args) => instance.set(...args)
const remove = (...args) => instance.remove(...args)
return {init, get, set, remove}
})
export default boot(({store, ssrContext}) => {
useCookiesStore(store).init(ssrContext)
}); });
export {cookies}
+23
View File
@@ -0,0 +1,23 @@
import {boot} from "quasar/wrappers";
import * as Sentry from "@sentry/browser";
// Client-only boot file (see quasar.config.js: {path: "sentry", server:
// false} - excludes this from the server bundle entirely). The guard below
// is a defensive second layer in case that config entry is ever changed
// back to a plain string. See handoffs/04-sentry-ssr-guard.md.
export default boot(({app, router}) => {
if (process.env.SERVER) return;
if (process.env.NODE_ENV !== 'production') return;
Sentry.init({
app,
dsn: "https://b2580fb40f4f0037d24f32e89d3e5df2@o472821.ingest.us.sentry.io/4506051015802880",
integrations: [
Sentry.browserTracingIntegration({router}),
Sentry.replayIntegration(),
],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
});
});
+56
View File
@@ -0,0 +1,56 @@
<script setup>
import {computed} from "vue";
import {useAltitudeUtils} from "src/composables/airspaces";
const props = defineProps({
lowerLimit: {
type: [String, Number],
required: true,
},
lowerLimitUnit: {
type: String,
required: false // GND nema jednotky
},
upperLimit: {
type: [String, Number],
required: true,
},
upperLimitUnit: {
type: String,
required: false // GND nema jednotky
}
})
const { altitudeLimitFormatted } = useAltitudeUtils();
const upper = computed(() => altitudeLimitFormatted(props.upperLimit, props.upperLimitUnit));
const lower = computed(() => altitudeLimitFormatted(props.lowerLimit, props.lowerLimitUnit));
</script>
<template>
<div class="altitude-limit">
<span class="upper">
{{ upper}}
</span>
<span class="lower">
{{ lower }}
</span>
</div>
</template>
<style lang="scss">
.altitude-limit {
display: inline-block;
text-align: center;
vertical-align: middle;
font-size: 12px;
.upper, .lower {
display: block;
}
.upper {
border-bottom: 1px solid black;
}
}
</style>
+5 -2
View File
@@ -13,7 +13,6 @@ const props = defineProps({
}) })
const config = computed(() => { const config = computed(() => {
if (props.mode === "add") { if (props.mode === "add") {
return {icon: "add", label: "Přidat"}; return {icon: "add", label: "Přidat"};
} else if (props.mode === "edit") { } else if (props.mode === "edit") {
@@ -26,7 +25,11 @@ const config = computed(() => {
</script> </script>
<template> <template>
<q-page-sticky location="bottom" :offset="[30, 30]"> <q-page-sticky
location="bottom"
:offset="[30, 30]"
v-if="$q.screen.lt.md"
>
<q-fab :icon="config.icon" color="primary" :label="config.label" @click="$router.push(props.routerLink)"/> <q-fab :icon="config.icon" color="primary" :label="config.label" @click="$router.push(props.routerLink)"/>
</q-page-sticky> </q-page-sticky>
</template> </template>
+1 -1
View File
@@ -30,6 +30,6 @@ const saveForm = () => {
<template> <template>
<q-form @submit.prevent="saveForm" ref="form"> <q-form @submit.prevent="saveForm" ref="form">
<slot name="default"/> <slot name="default"/>
<FormSubmitButton v-if="saveButton" :save-button="saveButton"/> <FormSubmitButton v-if="saveButton" :save-button="saveButton" :is-request-pending="isRequestPending" />
</q-form> </q-form>
</template> </template>
+14 -5
View File
@@ -38,14 +38,23 @@ onMounted(() => {
if (search) { if (search) {
filters.value.search = search; filters.value.search = search;
} else {
filters.value = {};
} }
}) })
</script> </script>
<template> <template>
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable> <div class="flex">
<template #prepend> <div class="col-grow">
<q-icon name="search"/> <q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
</template> <template #prepend>
</q-input> <q-icon name="search"/>
</template>
</q-input>
</div>
<div class="flex items-center">
<slot name="append" class="full-height"/>
</div>
</div>
</template> </template>
-41
View File
@@ -1,41 +0,0 @@
<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>
+46
View File
@@ -0,0 +1,46 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
mode: {
type: String,
required: true
},
routerLink: {
type: Object,
required: true
}
})
const config = computed(() => {
if (props.mode === "add") {
return {icon: "add", label: "Přidat"};
} else if (props.mode === "edit") {
return {icon: "edit", label: "Upravit"};
}
return {icon: "unknown", label: `Neznámý typ ${props.mode}`}
})
</script>
<template>
<div>
<q-toolbar class="justify-end q-pr-none" v-if="$q.screen.gt.sm">
<q-btn color="primary" size="lg" :icon="config.icon" :to="props.routerLink">{{ config.label }}</q-btn>
</q-toolbar>
<q-page-sticky
location="bottom"
:offset="[30, 30]"
v-else
>
<q-fab
:icon="config.icon"
color="primary"
:label="config.label"
@click="$router.push(props.routerLink)"
/>
</q-page-sticky>
</div>
</template>
+39 -39
View File
@@ -6,17 +6,6 @@ import {isNumber, isRequired, maxLength, minLength} from "src/composables/form/r
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
const formData = reactive({
id: null,
callSign: "",
model: "",
seats: null,
manufacturer: "",
description: "",
organization: null,
isPublic: false
});
const props = defineProps({ const props = defineProps({
defaultValues: { defaultValues: {
type: Object, type: Object,
@@ -34,6 +23,17 @@ const props = defineProps({
} }
}); });
const formData = reactive({
id: null,
callSign: "",
model: "",
seats: null,
manufacturer: "",
description: "",
organization: null,
isPublic: false
});
const rules = { const rules = {
callSign: [isRequired, minLength(4), maxLength(8)], callSign: [isRequired, minLength(4), maxLength(8)],
seats: [isRequired, isNumber] seats: [isRequired, isNumber]
@@ -54,48 +54,48 @@ onMounted(() => {
<q-img :src="props.defaultValues.photoUrl" v-if="props.defaultValues.photoUrl"/> <q-img :src="props.defaultValues.photoUrl" v-if="props.defaultValues.photoUrl"/>
<q-input <q-input
type="text" type="text"
:rules="rules.callSign" :rules="rules.callSign"
v-model="formData.callSign" v-model="formData.callSign"
label="Volací značka" label="Volací značka"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-input <q-input
type="number" type="number"
v-model.number="formData.seats" v-model.number="formData.seats"
label="Počet míst (včetně pilota)" label="Počet míst (včetně pilota)"
min="1" min="1"
:rules="rules.seats" :rules="rules.seats"
v-bind="commonProps" v-bind="commonProps"
/> />
<ComboboxInput <ComboboxInput
:options="organizations" :options="organizations"
label="Organizace" label="Organizace"
v-model="formData.organization" v-model="formData.organization"
v-bind="commonProps" v-bind="commonProps"
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace." hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
/> />
<q-input <q-input
type="text" type="text"
v-model="formData.manufacturer" v-model="formData.manufacturer"
label="Výrobce" label="Výrobce"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-input <q-input
type="text" type="text"
v-model="formData.model" v-model="formData.model"
label="Model" label="Model"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-input <q-input
type="textarea" type="textarea"
v-model="formData.description" v-model="formData.description"
label="Popis" label="Popis"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/> <q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/>
+41 -56
View File
@@ -1,68 +1,53 @@
<script setup> <script setup>
import LoadMoreList from "components/LoadMoreList.vue"; import {useInfiniteScroll} from "src/composables/pagination";
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: { ...useInfiniteScroll().propsDef
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
}); });
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script> </script>
<template> <template>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length"> <q-infinite-scroll
<template #items> :disable="!hasMoreData"
<q-list bordered separator> @load="emitLoadMore"
<q-item :offset="250"
clickable >
v-ripple <q-list bordered separator>
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}" <q-item
v-for="aircraft of props.items" :key="aircraft" clickable
> v-ripple
<q-item-section avatar> :to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
<q-avatar v-if="aircraft.titlePhoto"> v-for="aircraft of props.items" :key="aircraft"
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/> >
</q-avatar> <q-item-section avatar>
<q-avatar color="teal" text-color="white" icon="flight" v-else/> <q-avatar v-if="aircraft.titlePhoto">
</q-item-section> <img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
<q-item-section> </q-avatar>
<q-item-label label> <q-avatar color="teal" text-color="white" icon="flight" v-else/>
{{ aircraft.callSign }} </q-item-section>
</q-item-label> <q-item-section>
<q-item-label caption> <q-item-label label>
{{ aircraft.model }} {{ aircraft.callSign }}
<template v-if="aircraft.manufacturer"> </q-item-label>
({{ aircraft.manufacturer }}) <q-item-label caption>
</template> {{ aircraft.model }}
</q-item-label> <template v-if="aircraft.manufacturer">
<q-item-label caption v-if="aircraft.organization"> ({{ aircraft.manufacturer }})
Provozovatel: {{ aircraft.organization.name }} </template>
</q-item-label> </q-item-label>
</q-item-section> <q-item-label caption v-if="aircraft.organization">
</q-item> Provozovatel: {{ aircraft.organization.name }}
</q-list> </q-item-label>
</template> </q-item-section>
<template #skeleton> </q-item>
<BasicListSkeleton :items="itemsPerPage"/> </q-list>
</template> </q-infinite-scroll>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
+33 -3
View File
@@ -1,4 +1,6 @@
<script setup> <script setup>
import {computed} from "vue";
const props = defineProps({ const props = defineProps({
routeAdd: { routeAdd: {
type: Object, type: Object,
@@ -8,16 +10,44 @@ const props = defineProps({
type: String, type: String,
required: false, required: false,
default: "Přidat" default: "Přidat"
},
color: {
type: String,
required: false,
default: 'info'
} }
}); });
const bannerClass = computed(() => {
const defaultClasses = 'bg-light-blue text-white';
const classMap = {
info: defaultClasses,
warning: 'bg-orange text-white',
error: 'bg-red text-white',
}
return classMap[props.color] || defaultClasses;
});
const icon = computed(() => {
const iconsMap = {
info: "info",
warning: "warning",
error: "error",
}
return iconsMap[props.color];
});
</script> </script>
<template> <template>
<div class="q-pa-md q-gutter-sm"> <div class="q-pa-md q-gutter-sm">
<q-banner class="bg-light-blue text-white"> <q-banner :class=bannerClass>
<template v-slot:avatar> <template v-slot:avatar v-if="icon">
<q-icon name="info"/> <q-icon :name="icon"/>
</template> </template>
<slot/> <slot/>
<template v-slot:action v-if="routeAdd"> <template v-slot:action v-if="routeAdd">
+22
View File
@@ -0,0 +1,22 @@
<script setup>
import {FORMAT_DATE, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
const props = defineProps({
event: {
type: Object,
required: true
}
})
</script>
<template>
<q-chip icon="date_range" v-if="event.dateFrom || event.dateTo">
<template v-if="event.dateFrom !== event.dateTo">
{{ formatDatetime(event.dateFrom, FORMAT_DATE) }} - {{ formatDatetime(event.dateTo, FORMAT_DATE) }}
</template>
<template v-else>
{{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
</template>
</q-chip>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import MapWithMarkers from "components/map/MapWithMarkers.vue";
import {ref} from "vue";
import {useEventsStore} from "stores/Events";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {pluralize} from "src/composables/utils";
const props = defineProps({
eventId: {
type: Number,
required: false
},
eventUrlSlug: {
type: String,
required: false,
},
onlyPublic: {
type: Boolean,
required: true
}
})
const tracks = ref([]);
const totalTime = ref(0);
const totalFlights = ref(0);
useEventsStore().fetchFlightsTracks({
eventId: props.eventId,
eventUrlSlug: props.eventUrlSlug,
onlyPublic: props.onlyPublic
}).then((data) => {
if (!data.flights) return;
totalFlights.value = data.flights.length;
for (const flightTrack of data.flights) {
totalTime.value += flightTrack.durationMinCalculated;
if (flightTrack.track) {
tracks.value.push(flightTrack.track.points.coordinates);
}
}
});
</script>
<template>
<template v-if="tracks.length > 1">
<h3>Trasy letů</h3>
<MapWithMarkers
style="width: 100%; height: 500px;"
:tracks="tracks"
/>
<div class="text-center q-my-md">
<q-chip>
<q-avatar>{{ totalFlights }}</q-avatar>
{{ pluralize(totalFlights, "let", "lety", "letů") }}
</q-chip>
<q-chip icon="schedule">
{{ minutesToHumanReadable(totalTime) }}
</q-chip>
</div>
</template>
</template>
+2 -4
View File
@@ -14,7 +14,8 @@ import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
event: { event: {
type: Object type: Object,
required: false,
}, },
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
@@ -72,9 +73,6 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
</div> </div>
</div> </div>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/> <RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
</FormWrapper> </FormWrapper>
</template> </template>
@@ -0,0 +1,48 @@
<script setup>
import DatetimePicker from "components/inputs/DatetimePicker.vue";
import {reactive} from "vue";
import {commonProps} from "src/composables/inputs";
import FormWrapper from "components/FormWrapper.vue";
import {useFlightPlanCalculations} from "src/composables/flightPlan";
const props = defineProps({
flightPlan: {
type: Object,
required: true
}
});
const {getTotalDuration} = useFlightPlanCalculations();
const formData = reactive({
gpxTrackFile: null,
takeoffDatetime: props.flightPlan.plannedTakeoffDatetime,
landingDatetime: new Date(
(new Date(props.flightPlan.plannedTakeoffDatetime)).getTime() + 60 * 1000 * getTotalDuration(props.flightPlan)
),
copyTrack: true,
createPointsOfInterests: true,
flightPlan: props.flightPlan,
});
</script>
<template>
<FormWrapper v-model="formData" :save-button="{label: 'Vytvořit let z plánu', icon: 'save'}">
<template v-if="!formData.gpxTrackFile">
<DatetimePicker v-model="formData.takeoffDatetime" label="Čas vzletu"/>
<DatetimePicker v-model="formData.landingDatetime" label="Čas přistání"/>
</template>
<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."
/>
<q-checkbox
v-model="formData.copyTrack"
label="Zkopírovat otočné body (letiště a POI)?"
/>
</FormWrapper>
</template>
@@ -0,0 +1,69 @@
<script setup>
import {commonProps} from "src/composables/inputs";
import DatetimePicker from "components/inputs/DatetimePicker.vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import FormWrapper from "components/FormWrapper.vue";
import {computed, reactive} from "vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import {useLoggedUserStore} from "stores/LoggedUser";
import {storeToRefs} from "pinia";
const props = defineProps({
flightPlan: {
type: Object
},
aircrafts: {
type: Array,
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
const formData = reactive({
name: props.flightPlan.name || null,
isDefaultName: props.flightPlan ? props.flightPlan.isDefaultName : true,
isEditableByCopilot: props.flightPlan.isEditableByCopilot || false,
isPublic: props.flightPlan.isPublic || false,
plannedTakeoffDatetime: props.flightPlan.plannedTakeoffDatetime || null,
aircraft: props.flightPlan && props.flightPlan.aircraft ? {
id: props.flightPlan.aircraft.id, name: props.flightPlan.aircraft.callSign
} : null,
plannedSpeed: props.flightPlan.plannedSpeed || 150,
description: props.flightPlan.description || '',
});
const {user} = storeToRefs(useLoggedUserStore());
</script>
<template>
<FormWrapper v-model="formData" :is-request-pending="isRequestPending">
<div class="row">
<div class="col-sm-8 col-md-10" v-if="!formData.isDefaultName">
<q-input v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
</div>
<div class="col">
<q-checkbox v-model="formData.isDefaultName" label="Automatické jméno" v-bind="commonProps"/>
</div>
</div>
<q-checkbox v-model="formData.isPublic" label="Zveřejnit"/>
<CopyLinkButton
v-if="flightPlan.isPublic"
:route="{name: 'publicFlightPlanDetail', params: {username: user.publicUsername, id: flightPlan.id}}"
/>
<q-editor v-model="formData.description" label="Popis" v-bind="commonProps"/>
<DatetimePicker v-model="formData.plannedTakeoffDatetime" label="Plánovaný datum a čas vzletu" v-bind="commonProps"/>
<ComboboxInput v-model="formData.aircraft" :options="aircrafts" label="Letadlo" v-bind="commonProps"/>
<q-input v-model.number="formData.plannedSpeed" type="number" step="5" label="Plánovaná rychlost" v-bind="commonProps">
<template #append>km/h</template>
</q-input>
</FormWrapper>
</template>
@@ -0,0 +1,183 @@
<script setup>
import {computed, ref, watch} from "vue";
import {Marker, CustomMarker, Polyline} from "vue3-google-map";
import MapWrapper from "components/map/MapWrapper.vue";
import {useFlightPlanCalculations, useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
import {useGeocode} from "src/composables/geocoding";
const props = defineProps({
flightPlan: {
type: Object,
required: true
},
airspaceData: {
type: Array,
required: true
},
airports: {
type: Array,
required: true,
},
pointsOfInterest: {
type: Array,
required: true
},
insertPosition: {
type: Number,
required: false,
default: null
},
});
const emits = defineEmits(["markers-changed", "change-insert-position"]);
const {center} = useGeocode();
const {markerObject, googleMapsMarkerObject, googleMapsCustomMarkerObject, editableNameTypes} = useFlightPlanObjects();
const {getMarkersFrom} = useFlightPlanUtils();
const {getDuration} = useFlightPlanCalculations();
const mapWrap = ref(null);
const handleMarkerClick = (marker) => {
mapWrap.value.openInfoWindow({marker});
}
const markers = ref([]);
watch(() => props.flightPlan.track, (newVal) => {
markers.value = getMarkersFrom(props.flightPlan);
}, {immediate: true, deep: true});
const track = computed(() => markers.value.map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude})));
const trackAids = computed(() => props.flightPlan.track.map(p => ({
...p,
anchorPoint: 'CENTER',
bearing: p.bearing,
distance: p.distance,
position: {
lat: (p.from.gpsLatitude + p.to?.gpsLatitude) / 2,
lng: (p.from.gpsLongitude + p.to?.gpsLongitude) / 2
}
}
)));
const displayTrackAids = ref(true);
const addMarker = async (point) => {
if (!point) return;
const insertAt = Math.min(markers.value.length, props.insertPosition);
markers.value.splice(insertAt, 0, markerObject({...point}));
emits("markers-changed", markers.value);
emits("change-insert-position", insertAt + 1);
}
const removeMarker = (infoWindow) => {
const idx = markers.value.findIndex(i => i.gpsLatitude === infoWindow.position.lat && i.gpsLongitude === infoWindow.position.lng);
if (!idx) return;
markers.value.splice(idx, 1);
emits("markers-changed", markers.value);
}
const {searchPlaceName} = useGeocode();
const handleMarkerMove = async (idx, event) => {
markers.value[idx].name = await searchPlaceName(event.lat(), event.lng());
markers.value[idx].gpsLatitude = event.lat();
markers.value[idx].gpsLongitude = event.lng();
markers.value[idx].type = "mapPoint"
emits("markers-changed", markers.value);
}
</script>
<template>
<MapWrapper
ref="mapWrap"
style="width: 100%; height: 65vh; min-height: 500px"
:center="center"
:zoom="8"
:track="{path: track, geodesic: true, strokeColor: '#ff00ff', strokeOpacity: 1, strokeWeight: 3}"
:points-of-interest="pointsOfInterest"
:airspaces-data="airspaceData"
:airports="airports"
v-bind="$attrs"
:fullscreen-control="false"
:show-info-window="true"
:show-layer-controls="true"
:allow-add-marker="insertPosition !== null"
@add-to-route="addMarker"
@remove-from-route="removeMarker"
>
<template v-for="(marker, i) in markers" :key="marker.name">
<!-- TODO: ve verejne casti by nemel byt draggable!-->
<Marker
v-if="editableNameTypes.includes(marker.type)"
:options="googleMapsMarkerObject(marker)"
@click="handleMarkerClick(marker)"
@dragend="handleMarkerMove(i, $event.latLng)"
/>
</template>
<template v-if="displayTrackAids">
<template v-for="marker in trackAids" :key="marker">
<CustomMarker :options="marker" class="track-info-marker">
<div class="track-info" :style='`transform: rotate(${marker.bearing}deg)`'>
{{ marker.bearing }}&deg; <br>
{{ getDuration(marker.distance, flightPlan.plannedSpeed) }}'
</div>
</CustomMarker>
</template>
</template>
<template v-if="markers.length > 1">
<CustomMarker :options="googleMapsCustomMarkerObject(markers[0], 'CENTER')" class="significant-airport">
<q-icon name="flight_takeoff"/>
</CustomMarker>
<CustomMarker :options="googleMapsCustomMarkerObject(markers[markers.length - 1], 'CENTER')" class="significant-airport">
<q-icon name="flight_land"/>
</CustomMarker>
</template>
<template #layersControlsEnd>
<q-checkbox v-model="displayTrackAids" label="Zobrazovat navigaci po trase?"/>
</template>
</MapWrapper>
</template>
<style lang="scss">
.significant-airport {
position: relative;
z-index: 99999;
text-align: center;
margin-top: -40px;
.q-icon {
color: white;
background: #39b6e6;
border: solid 1px black;
width: 30px;
height: 30px;
font-size: 20px;
border-radius: 50%;
}
}
.track-info {
background: rgba(128, 128, 128, 0.5);
color: white;
border: solid 2px rgba(0, 0, 0, 0.87);
padding: 5px;
margin: 5px;
font-size: 12pt;
font-weight: bold;
transform-origin: 50% 50%;
}
.track-info-marker {
z-index: 999;
}
</style>
@@ -0,0 +1,91 @@
<script setup>
import {computed} from "vue";
import {useFlightPlanCalculations} from "src/composables/flightPlan";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {useQuasar} from "quasar";
import {textEllipsis} from "src/composables/utils";
const props = defineProps({
flightPlan: {
type: Object,
required: true
},
plannedSpeed: {
type: Number,
required: true,
}
});
const columns = [
{
label: 'Z',
align: "left",
field: 'from'
},
{
label: 'Do',
align: "left",
field: 'to'
},
{
align: 'right',
label: 'Kurz',
field: 'bearing',
},
{
align: 'right',
label: 'Vzdálenost',
field: 'distance',
},
{
align: 'right',
label: 'Čas',
field: 'duration',
},
];
const {getDuration, getTotalDuration, getTotalDistance} = useFlightPlanCalculations();
const data = computed(() => props.flightPlan.track.map((item) => ({
from: textEllipsis(item.from.name, 30),
to: textEllipsis(item.to.name, 30),
distance: `${item.distance} km`,
bearing: `${item.bearing} °`,
duration: minutesToHumanReadable(getDuration(item.distance, props.plannedSpeed)),
})));
const {screen} = useQuasar();
</script>
<template>
<q-table
:grid="screen.xs"
:dense="screen.lt.md"
flat bordered
hide-bottom
:title="flightPlan.name"
:rows="data"
:columns="columns"
:rows-per-page-options="[0]"
>
<template #bottom-row="{cols}">
<tr class="summary">
<td v-for="col in cols" :key="col.field" :class="{'text-right': col.align === 'right'}">
<template v-if="col.field === 'duration'">
{{ minutesToHumanReadable(getTotalDuration(flightPlan, plannedSpeed)) }}
</template>
<template v-else-if="col.field === 'distance'">
{{ getTotalDistance(flightPlan) }} km
</template>
</td>
</tr>
</template>
</q-table>
</template>
<style scoped>
.summary {
font-weight: bold;
}
</style>
@@ -0,0 +1,76 @@
<script setup>
import {ref, watch} from "vue";
import {commonProps} from "src/composables/inputs";
import {useGeocode} from "src/composables/geocoding";
const props = defineProps({
options: {
type: Array,
required: true
}
})
const emits = defineEmits(["select"])
const place = ref("");
const placesOptions = ref(null);
const {searchGeocode} = useGeocode()
watch(() => place.value, (newVal) => {
if (newVal)
emits("select", newVal);
place.value = null;
});
const placesFilter = (val, update, abort) => {
update(() => {
if (val && val !== '') {
placesOptions.value = props.options.filter(p => p.name.includes(val));
if (placesOptions.value.length === 0) {
searchGeocode(val).then(results => {
if (!results) return;
console.log(results)
placesOptions.value = results.map(result => {
console.log(result, result.geometry.location)
return {
name: result.formatted_address,
type: "geocode",
id: null,
gpsLatitude: result.geometry.location.lat(),
gpsLongitude: result.geometry.location.lng(),
}
})
});
}
} else {
placesOptions.value = props.options;
}
})
}
</script>
<template>
<q-select
v-bind="commonProps"
v-model="place"
label="Místo"
use-input
input-debounce="100"
map-options
emit-value
option-label="name"
option-disable="disabled"
:options="placesOptions"
@filter="placesFilter"
>
<template #no-option>
<q-item>
<q-item-section>
Toto místo tu nemám. Zkus psát dál, najdem něco v Google Places.
</q-item-section>
</q-item>
</template>
</q-select>
</template>
@@ -0,0 +1,122 @@
<script setup>
import {computed, ref, watch} from "vue";
import FlightPlanPlaceInput from "components/flightPlans/FlightPlanPlaceInput.vue";
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
const props = defineProps({
markers: {
type: Array,
required: true
},
pointsOfInterest: {
type: Array,
required: true
},
airports: {
type: Array,
required: true
}
});
const emits = defineEmits(["markers-changed"]);
const model = ref(props.markers);
const removeMarker = (idx) => {
model.value.splice(idx, 1);
}
const swapMarkers = (idx1, idx2) => {
const tmp = model.value[idx1];
model.value[idx1] = model.value[idx2];
model.value[idx2] = tmp;
};
const handlePlaceSelect = (modelVal) => {
model.value.push(modelVal);
// TODO: upravit bounds u mapy
}
watch(() => model.value, () => {
emits("markers-changed", model.value);
}, {deep: true});
const {getIcon} = useFlightPlanUtils();
const {editableNameTypes} = useFlightPlanObjects();
const allPlacesOptions = computed(() => {
const reportingPoints = props.airports.map(a => a.reportingPoints.map(r => ({...r, airport: a}))).flat()
return [
...props.pointsOfInterest.map(poi => ({...poi, pointOfInterestId: poi.id, type: "poi"})),
...reportingPoints.map(r => ({
...r,
name: `${r.airport.icaoCode}:${r.name}`,
type: "reportingPoint"
})),
...props.airports.map(airport => ({
...airport,
name: `${airport.icaoCode} - ${airport.name}`,
type: "airport",
airportId: airport.id,
}))
].map(i => ({...i, disabled: !i.gpsLatitude || !i.gpsLongitude}));
});
</script>
<template>
<q-scroll-area style="height: 60vh;">
<q-list bordered separator class="full-width">
<q-item v-for="(marker, i) in model" :key="marker">
<q-item-section side>
<q-icon :name="getIcon(marker.type)"></q-icon>
</q-item-section>
<q-item-section side>
<div>
<q-btn
v-if="i > 0"
text-color="primary"
size="16px"
flat dense round icon="arrow_upward"
@click="swapMarkers(i, i-1)"
/>
<q-btn
v-if="i < model.length - 1"
text-color="primary"
flat dense round icon="`arrow_downward`"
size="16px"
@click="swapMarkers(i, i+1)"
/>
</div>
</q-item-section>
<q-item-section>
<div class="cursor-pointer" v-if="editableNameTypes.includes(marker.type)">
{{ marker.name }}
<q-icon name="edit"/>
<q-popup-edit v-model="marker.name" v-slot="scope">
<q-input v-model="scope.value" autofocus @keyup.enter="scope.set">
<template #append>
<q-icon name="edit"/>
</template>
</q-input>
</q-popup-edit>
</div>
<template v-else>
{{ marker.name }}
</template>
</q-item-section>
<q-item-section side class="text-right">
<q-btn size="12px" @click="removeMarker(i)" flat dense round color="red" icon="delete"/>
</q-item-section>
</q-item>
</q-list>
</q-scroll-area>
<FlightPlanPlaceInput
:options="allPlacesOptions"
@select="handlePlaceSelect"
/>
</template>
@@ -0,0 +1,119 @@
<script setup>
import {computed, ref, watch} from "vue";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {useFlightPlanCalculations, useFlightPlanUtils} from "src/composables/flightPlan";
const props = defineProps({
flightPlan: {
type: Object,
required: true
},
isFullscreen: {
type: Boolean,
default: false,
},
insertPosition: {
type: Number,
required: true
}
});
const emits = defineEmits(["open", "change-insert-position", "fullscreen-toggle"])
const {getDuration, getTotalDistance, getTotalDuration} = useFlightPlanCalculations();
const totalDistance = computed(() => getTotalDistance(props.flightPlan));
</script>
<template>
<q-list bordered class="q-ml-md-md q-pt-md">
<q-item class="mobile-hide">
<q-item-section>
<div style="display: flex; justify-content: space-between">
<q-btn
@click="emits('change-insert-position', 0)"
icon="add"
:color="insertPosition === 0 ? 'primary' : 'grey'"
size="sm"
>
<template v-if="insertPosition === 0">Přidávám na začátek</template>
</q-btn>
<q-btn
@click='emits("fullscreen-toggle")'
size="sm"
round
:icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
/>
</div>
</q-item-section>
</q-item>
<q-scroll-area class="mobile-hide" style="height: 55vh">
<template v-for="(segment, i) in props.flightPlan.track" :key="segment">
<q-item dense>
<q-item-section>
<q-item-label>{{ segment.from.name }}</q-item-label>
<q-item-label caption class="q-pa-sm q-ml-sm" style="border-left: solid 1px" v-if="segment.distance">
{{ segment.bearing }}°,
{{ segment.distance }} km,
{{ minutesToHumanReadable(getDuration(segment.distance, props.flightPlan.plannedSpeed)) }}
<q-btn
@click="emits('change-insert-position', i+1)"
icon="add"
size="xs"
round
class="q-ml-sm"
:color="insertPosition === i+1 ? 'primary' : 'grey'"
/>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense v-if="segment.to && i === props.flightPlan.track.length - 1">
<q-item-section>
<q-item-label>
{{ segment.to.name }}
</q-item-label>
</q-item-section>
</q-item>
</template>
<q-item>
<q-item-section v-if="flightPlan.track.length">
<div>
<q-btn
@click="emits('change-insert-position', flightPlan.track.length + 1)"
icon="add"
size="sm"
:color="insertPosition === flightPlan.track.length + 1? 'primary' : 'grey'"
>
<template v-if="insertPosition === flightPlan.track.length + 1">Přidávám na konec</template>
</q-btn>
</div>
</q-item-section>
</q-item>
</q-scroll-area>
<template v-if="totalDistance">
<q-item class="mobile-hide">
<q-item-section>
<q-separator/>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
Celková vzdálenost: {{ totalDistance }} km
<br>
Plánovaná doba letu: {{ minutesToHumanReadable(getTotalDuration(props.flightPlan)) }}
</q-item-section>
</q-item>
</template>
<q-item>
<q-item-section>
<q-btn @click="emits('open')" color="secondary">
{{ totalDistance ? 'Upravit trasu' : 'Sestavit trasu' }}
</q-btn>
</q-item-section>
</q-item>
</q-list>
</template>
@@ -0,0 +1,113 @@
<script setup>
import {useFlightPlanUtils} from "src/composables/flightPlan";
import {useQuasar} from "quasar";
import {useUnitsConvertors} from "src/composables/units";
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
import {formatDatetime} from "src/composables/datetimeUtils";
const props = defineProps({
flightPlan: {
type: Object,
required: true
}
})
const columns = [
{
label: 'Místo',
align: "left",
name: 'place',
},
{
align: 'right',
label: 'Teplota',
name: 'temperature',
},
{
align: 'right',
label: 'Vítr',
name: 'wind',
},
{
align: 'right',
label: 'Oblačnost',
name: 'clouds',
},
];
const {screen} = useQuasar();
const {getMarkersFrom} = useFlightPlanUtils();
const {kmhToKnots, ftToM} = useUnitsConvertors();
const getCloudsBase = (weatherInfo) => {
const tempDiff = weatherInfo.temperatureSurface - weatherInfo.dewpointSurface;
const cloudLevelInFt = tempDiff / 2.5 * 1000;
return Math.round(ftToM(cloudLevelInFt));
}
const markers = getMarkersFrom(props.flightPlan);
const markersWithWeather = markers.filter(marker => !!marker.weatherInfo);
</script>
<template>
<q-table
:grid="screen.xs"
:dense="screen.lt.md"
flat bordered
hide-bottom
:title="flightPlan.name"
:rows="markersWithWeather"
:columns="columns"
:rows-per-page-options="[0]"
>
<template v-slot:body-cell-place="props">
<q-td :props="props">
<strong>
{{ props.row.name }}
</strong>
<br>
<small>
{{ formatDatetime(props.row.weatherInfo.datetime) }}
</small>
</q-td>
</template>
<template v-slot:body-cell-temperature="props">
<q-td :props="props">
{{ props.row.weatherInfo.temperatureSurface }}° C
<br>
<small>
Rosný bod {{ props.row.weatherInfo.dewpointSurface }}° C
</small>
</q-td>
</template>
<template v-slot:body-cell-wind="props">
<q-td :props="props">
<q-icon name="navigation" :style="`transform: rotate(${props.row.weatherInfo.windDirectionSurface}deg)`"/>
{{ props.row.weatherInfo.windDirectionSurface }}°
<br>
{{ kmhToKnots(props.row.weatherInfo.windSpeedSurface) }} kt
</q-td>
</template>
<template v-slot:body-cell-clouds="props">
<q-td :props="props">
<CloudCoverIcon :cloudcover="props.row.weatherInfo.cloudcoverTotal"/>
<br>
celkové pokrytí oblačností {{ props.row.weatherInfo.cloudcoverTotal }} %
<br>
nízké mraky {{ props.row.weatherInfo.cloudcoverLow }} %
<template v-if="props.row.weatherInfo.cloudcoverLow > 5">
<br>
odhadovaná výška základen: {{ getCloudsBase(props.row.weatherInfo) }} m
</template>
</q-td>
</template>
</q-table>
</template>
@@ -0,0 +1,48 @@
<script setup>
import {formatDatetime} from "src/composables/datetimeUtils";
import {useInfiniteScroll} from "src/composables/pagination";
const props = defineProps({
items: {
type: Array,
required: true
},
...useInfiniteScroll().propsDef
});
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script>
<template>
<q-infinite-scroll
:disable="!hasMoreData"
@load="emitLoadMore"
:offset="250"
>
<q-list bordered separator>
<q-item
clickable
:to="{name: 'editFlightPlan', params: {id: flightPlan.id }}"
v-ripple
v-for="flightPlan of items" :key="flightPlan">
<q-item-section avatar>
<q-avatar icon="flight" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
<q-item-label>
{{ flightPlan.name || "Nepojmenovaný plán" }}
</q-item-label>
<q-item-label caption v-if="flightPlan.plannedTakeoffDatetime">
{{ formatDatetime(flightPlan.plannedTakeoffDatetime) }}
</q-item-label>
<q-item-label caption v-if="flightPlan.aircraft">
{{ flightPlan.aircraft.callSign }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-infinite-scroll>
</template>
@@ -0,0 +1,104 @@
import {computed} from "vue";
const markersCenter = computed(() => {
if (markers.value.length === 0) return null;
return {
gpsLatitude: markers.value.map(m => m.gpsLatitude).reduce((a, b) => a + b) / markers.value.length,
gpsLongitude: markers.value.map(m => m.gpsLongitude).reduce((a, b) => a + b) / markers.value.length,
}
});
const calcAngleFromCenter = (pointA, pointB) => {
if (!markersCenter.value) return 0;
const u = vector(markersCenter.value, pointA);
const v = vector(markersCenter.value, pointB);
return Math.acos(
(u[0] * v[0] + u[1] * v[1])
/
(vectorSize(u) * vectorSize(v))
)
}
let targetPosition = null;
let minAngleDiff = Infinity;
let mapPoints = 1;
const handleMapClickNefunkcni = (position) => {
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng};
const clickAngle = calcAngleFromCenter(clickPoint, markers.value[0]);
for (let i = 1; i < markers.value.length - 1; i++) {
const marker = markers.value[i];
const angle = calcAngleFromCenter(marker, markers.value[0]);
const angleDiff = clickAngle - angle;
console.log("click", i, marker.name, angleDiff);
if (angleDiff < minAngleDiff) {
console.log(" update => ", minAngleDiff, " > ", Math.abs(angleDiff), marker.name, " -> ", markers.value[i + 1].name);
minAngleDiff = angleDiff
targetPosition = Math.max(1, angleDiff < 0 ? i - 1 : i + 1);
}
// console.log(marker.name, angle);
}
console.log("click", clickAngle);
console.log("insert at position", targetPosition);
console.log("---------------")
markers.value.splice(targetPosition, 0, {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
mapPoints++;
}
const handleMapClickNefunkcni2 = (position) => {
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng}
const markersToSort = markers.value.slice(1, -1);
markersToSort.push({name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
markersToSort.sort(
(a, b) => calcAngleFromCenter(a, markers.value[0]) - calcAngleFromCenter(b, markers.value[0])
);
for (const marker of markersToSort) {
console.log(marker.name, calcAngleFromCenter(marker, markers.value[0]));
}
markers.value = [markers.value[0], ...markersToSort, markers.value[markers.value.length - 1]];
mapPoints++;
}
const calcTotalDistance = (points) => {
let distance = 0;
for (let i = 1; i < points.length; i++) {
distance += vectorDistance(points[i-1], points[i])
}
return distance
}
let minDistance = Infinity;
const handleMapClick = (position) => {
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng};
const newMarker = {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint}
markers.value.push(newMarker)
for (let i = 1; i < markers.value.length - 1; i++) {
console.log(markers.value[i], clickPoint);
const dist = vectorDistance(markers.value[i], clickPoint);
if (dist < minDistance) {
minDistance = dist;
targetPosition = i;
}
}
targetPosition = Math.min(markers.value.length - 1, Math.max(1, targetPosition));
markers.value.splice(targetPosition, 0, {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
mapPoints++;
}
+48 -7
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils"; import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
import WeatherInfo from "components/weather/WeatherInfo.vue"; import WeatherInfo from "components/weather/WeatherInfo.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -21,6 +22,16 @@ const props = defineProps({
type: Object, type: Object,
required: false, required: false,
default: null default: null
},
eventDetailRoute: {
type: Object,
required: false,
default: null
},
loggedUser: {
type: Object,
required: false,
default: null
} }
}) })
</script> </script>
@@ -71,7 +82,7 @@ const props = defineProps({
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item v-for="poi in flight.track" :key="poi.id"> <q-item v-for="poi in flight.turnPoints" :key="poi.id">
<q-item-section avatar> <q-item-section avatar>
<q-icon name="place" v-if="poi.pointOfInterest"/> <q-icon name="place" v-if="poi.pointOfInterest"/>
<q-icon name="local_airport" v-if="poi.airport"/> <q-icon name="local_airport" v-if="poi.airport"/>
@@ -122,6 +133,18 @@ const props = defineProps({
{{ minutesToHumanReadable(flight.durationMinCalculated) }} {{ minutesToHumanReadable(flight.durationMinCalculated) }}
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item v-if="flight.event && (loggedUser || flight.event.isPublic)">
<q-item-section avatar>
<q-icon name="event"/>
</q-item-section>
<q-item-section>
<router-link :to="eventDetailRoute">
{{ flight.event.name }}
</router-link>
</q-item-section>
</q-item>
<q-item v-if="flight.copilots?.length > 0"> <q-item v-if="flight.copilots?.length > 0">
<q-list> <q-list>
<q-item v-for="copilot in flight.copilots" :key="copilot.id"> <q-item v-for="copilot in flight.copilots" :key="copilot.id">
@@ -129,14 +152,32 @@ const props = defineProps({
<q-icon name="people"/> <q-icon name="people"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<router-link <template v-if="copilot.isPublic">
v-if="copilot.isPublic" <div>
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}" <router-link
> :to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
{{ copilot.name }} >
</router-link> {{ copilot.name }}
</router-link>
</div>
</template>
<template v-else> {{ copilot.name }}</template> <template v-else> {{ copilot.name }}</template>
</q-item-section> </q-item-section>
<q-item-section>
<CopyLinkButton
v-if="loggedUser && copilot.token && copilot.isPublic"
:route='{
name: "publicCopilotUploadPhoto",
params: {flightSlug: flight.urlSlug, username: loggedUser.publicUsername},
query: {token: copilot.token}
}'
icon="cloud_upload"
title="Získat odkaz na nahrání fotek od kopilota"
label=""
round
class="q-mx-3"
/>
</q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</q-item> </q-item>
@@ -0,0 +1,59 @@
<script setup>
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
const props = defineProps({
flight: {type: Object, required: true},
pilot: {type: Object, required: true},
routeDetail: {type: Object, required: true},
})
</script>
<template>
<div class="flight-header-info">
<h2 class="text-h4">
<router-link :to="routeDetail" v-if="routeDetail">
{{ flight.name }}
</router-link>
<span v-else>{{ flight.name }}</span>
</h2>
<div class="q-my-lg">
{{ formatDatetime(flight.takeoffDatetime, FORMAT_DATE) }}
</div>
<div>
<span v-for="(turnPoint, i) in flight.turnPoints">
{{ turnPoint.pointOfInterest.name }}
<span v-if="i < Object.keys(flight.turnPoints).length - 1" class="q-mx-md"> &bull;</span>
</span>
</div>
<div class="q-my-lg">
<router-link :to="{name: 'publicAircraftDetail', params: {callSign: flight.aircraft.callSign}}">
{{ flight.aircraft.callSign }}
</router-link>
</div>
<div class="q-my-lg" v-if="flight.description !== ''">
<div v-html="flight.description"></div>
</div>
<slot class="q-my-lg" name="default"></slot>
</div>
</template>
<style lang="scss">
.flight-header-info a {
&, &:visited {
color: #e6f5ff;
text-decoration: none;
}
&:hover {
color: #c2d2dd;
}
}
</style>
+51 -20
View File
@@ -2,12 +2,17 @@
import MapWithMarkers from "components/map/MapWithMarkers.vue"; import MapWithMarkers from "components/map/MapWithMarkers.vue";
import {computed, reactive, ref} from "vue"; import {computed, reactive, ref} from "vue";
import {Line} from "vue-chartjs" import {Line} from "vue-chartjs"
import {useFlightPlanUtils} from "src/composables/flightPlan";
const props = defineProps({ const props = defineProps({
flight: { flight: {
type: Object, type: Object,
required: true required: true
}, },
flightPlan: {
type: Object,
required: false,
},
photos: { photos: {
type: Array, type: Array,
required: false, required: false,
@@ -16,7 +21,7 @@ const props = defineProps({
const mapPhotos = computed(() => props.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude)); const mapPhotos = computed(() => props.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
const poisWithGPS = computed(() => props.flight.track.map(poi => poi.pointOfInterest || poi.airport)); const poisWithGPS = computed(() => props.flight.turnPoints.map(poi => poi.pointOfInterest || poi.airport));
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
@@ -25,10 +30,12 @@ const pointsOfInterests = computed(() => mapMarkers
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}}) marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
)); ));
const gpxTrack = props.flight.gpxTrack ? reactive(props.flight.gpxTrack) : null; // Tuu bylo reactive()
const flightTrack = props.flight.track ? props.flight.track.points : null;
const displayMap = computed(() => { const displayMap = computed(() => {
return ( return (
!!gpxTrack || !!flightTrack ||
mapPhotos.value.length > 0 || mapPhotos.value.length > 0 ||
poisWithGPS.value.length > 0 || poisWithGPS.value.length > 0 ||
props.flight.landingAirport.id !== props.flight.takeoffAirport.id props.flight.landingAirport.id !== props.flight.takeoffAirport.id
@@ -36,8 +43,14 @@ const displayMap = computed(() => {
}); });
let chartSpeedData, chartAltitudeData = null; let chartSpeedData, chartAltitudeData = null;
if (gpxTrack) { if (flightTrack) {
const chartLabels = gpxTrack.time.map(t => {
const timestamp = flightTrack.map(i => i.timestamp);
const speed = flightTrack.map(i => i.speed);
const altitude = flightTrack.map(i => i.altitude);
const terrainElevation = flightTrack.map(i => i.terrainElevation || 0);
const chartLabels = timestamp.map(t => {
const datetime = new Date(t); const datetime = new Date(t);
return datetime.getHours() + ":" + datetime.getMinutes(); return datetime.getHours() + ":" + datetime.getMinutes();
}); });
@@ -47,7 +60,7 @@ if (gpxTrack) {
backgroundColor: "#38bd3e", backgroundColor: "#38bd3e",
borderColor: "#38bd3e", borderColor: "#38bd3e",
label: "Rychlost (km/h)", label: "Rychlost (km/h)",
data: gpxTrack.speed, data: speed,
pointRadius: 1, pointRadius: 1,
}] }]
}; };
@@ -58,18 +71,18 @@ if (gpxTrack) {
backgroundColor: "#9ae3ff", backgroundColor: "#9ae3ff",
borderColor: "#9ae3ff", borderColor: "#9ae3ff",
label: "Nadmořská výška letu (m)", label: "Nadmořská výška letu (m)",
data: gpxTrack.altitude, data: altitude,
pointRadius: 1, pointRadius: 1,
}] }]
}; };
if (gpxTrack.terrainElevation.length > 0) { if (terrainElevation.length > 0) {
chartAltitudeData.datasets.push( chartAltitudeData.datasets.push(
{ {
backgroundColor: "rgba(148,94,40,0.7)", backgroundColor: "rgba(148,94,40,0.7)",
borderColor: "#945e28", borderColor: "#945e28",
label: "Nadmořská výška země (m)", label: "Nadmořská výška země (m)",
data: gpxTrack.terrainElevation, data: terrainElevation,
fill: true, fill: true,
pointRadius: 1, pointRadius: 1,
}); });
@@ -85,7 +98,7 @@ const drawCurrentPositionMarker = (e) => {
return; return;
} }
const idx = res[0].index; const idx = res[0].index;
currentPositionMarker.value = props.flight.gpxTrack.coordinates[idx]; currentPositionMarker.value = props.flight.track.points[idx].coordinates;
} }
const chartOptions = { const chartOptions = {
@@ -98,20 +111,38 @@ const chartOptions = {
onClick: drawCurrentPositionMarker, onClick: drawCurrentPositionMarker,
onHover: drawCurrentPositionMarker, onHover: drawCurrentPositionMarker,
} }
const {getMarkersFrom} = useFlightPlanUtils();
const showPlanTrack = ref(true);
const tracks = computed(() => {
const items = [];
if (props.flight.track) {
items.push(props.flight.track.points.map(m => m.coordinates))
}
if (showPlanTrack.value && props.flightPlan) {
items.push(getMarkersFrom(props.flightPlan).map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude})));
}
return items;
})
</script> </script>
<template> <template>
<q-no-ssr> <q-no-ssr>
<MapWithMarkers <q-intersection>
v-if="displayMap" <q-checkbox v-model="showPlanTrack" label="Zobrazovat plán?" v-if="flightPlan" />
style="width: 100%; height: 500px;"
:gpx-track="flight.gpxTrack?.coordinates || []"
:markers="pointsOfInterests"
:highlight-position="currentPositionMarker"
:photos="mapPhotos"
/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/> <MapWithMarkers
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/> v-if="displayMap"
style="width: 100%; height: 500px;"
:tracks="tracks"
:markers="pointsOfInterests"
:highlight-position="currentPositionMarker"
:photos="mapPhotos"
v-bind="$attrs"
/>
<Line v-if="flightTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="flightTrack" :data="chartAltitudeData" :options="chartOptions"/>
</q-intersection>
</q-no-ssr> </q-no-ssr>
</template> </template>
+51 -65
View File
@@ -2,9 +2,7 @@
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 {useInfiniteScroll} from "src/composables/pagination";
import {computed} from "vue";
import LoadMoreList from "components/LoadMoreList.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -15,62 +13,46 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true required: true
}, },
totalItemsCount: { ...useInfiniteScroll().propsDef
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
}); });
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
const routeDetail = (flight) => { const routeDetail = (flight) => {
if (props.public) if (props.public)
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}} 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>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length"> <q-infinite-scroll
<template #items> :disable="!hasMoreData"
<q-list bordered separator> @load="emitLoadMore"
<q-item :offset="250"
v-for="flight of items" :key="flight" >
ripple <q-list bordered separator>
clickable <q-item
:to="routeDetail(flight)" v-for="flight of items" :key="flight"
> ripple
<q-item-section avatar> clickable
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/> :to="routeDetail(flight)"
<q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar> >
</q-item-section> <q-item-section avatar class="gt-xs">
<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>
<q-img v-if="flight.titlePhoto" class="lt-sm" :src="flight.titlePhoto.thumbnailUrl"/>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<div class="flex justify-between justify-sm-start list-secondary-info">
<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.copilots"> <div class="text-secondary" v-if="flight.copilots">
@@ -92,27 +74,31 @@ const loadMoreCount = computed(() => {
<q-icon name="event"/> <q-icon name="event"/>
{{ flight.event.name }} {{ flight.event.name }}
</div> </div>
</q-item-section> </div>
</q-item-section>
<q-item-section side top v-if="!props.public"> <q-item-section side top v-if="!props.public">
<!-- Zobraz jen v adminu --> <!-- Zobraz jen v adminu -->
<q-icon name="visibility" v-if="flight.isPublic"/> <q-icon name="visibility" v-if="flight.isPublic"/>
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''"> <q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
<q-tooltip> <q-tooltip>
Let je sice publikovaný, nemá ale nastavenou URL adresu! Let je sice publikovaný, nemá ale nastavenou URL adresu!
</q-tooltip> </q-tooltip>
</q-icon> </q-icon>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
<template v-slot:loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px"/>
</div>
</template> </template>
<template #skeleton> </q-infinite-scroll>
<FlightsListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Načíst {{ pluralize(loadMoreCount, 'jeden let', `další ${loadMoreCount} lety`, `dalších ${loadMoreCount} letů`) }}
</template>
</LoadMoreList>
</template> </template>
<style>
.list-secondary-info div {
padding: 0 1em;
}
</style>
@@ -1,7 +1,7 @@
<script setup> <script setup>
import {ref} from "vue"; import {ref} from "vue";
import {flightName} from "src/composables/flight"; import FlightHeaderInfo from "components/flights/FlightHeaderInfo.vue";
import LatestFlightSliderInfo from "components/flights/slider/LatestFlightSliderInfo.vue"; import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -37,11 +37,25 @@ const slide = ref(0);
height="400px" height="400px"
v-if="flight.titlePhoto" v-if="flight.titlePhoto"
> >
<div class="absolute-bottom"> <div class="absolute-full">
<LatestFlightSliderInfo :flight="flight"/> <FlightHeaderInfo
:route-detail="{name: 'publicFlightDetailNew', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
:pilot="flight.pilot"
:flight="flight"
>
<div class="absolute-bottom-right">
<router-link
class="q-pa-lg"
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
>
{{ flight.pilot.publicUsername }}
</router-link>
</div>
</FlightHeaderInfo>
</div> </div>
</q-img> </q-img>
<LatestFlightSliderInfo v-else :flight="flight" text-color="primary"/>
</q-carousel-slide> </q-carousel-slide>
</q-carousel> </q-carousel>
</div> </div>
@@ -19,43 +19,11 @@ const formData = reactive({
copilots: [], copilots: [],
}) })
const solo = ref(false);
watch(() => solo.value, (newVal) => {
if (!newVal) {
formData.copilots = [];
}
});
watch(() => props.flight, (newVal) => {
if (newVal) {
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
solo.value = formData.copilots.length === 0;
}
}, {deep: true, immediate: true});
</script> </script>
<template> <template>
<FormWrapper v-model="formData"> <FormWrapper v-model="formData">
<q-btn-toggle
spread
no-caps
v-model="solo"
toggle-color="primary"
:options="[{label: 'Solo', value: true}, {label: 'S někým', value: false}]"
/>
<div v-if="!solo">
<ComboboxInput
v-for="i in flight.aircraft.seats - 1"
:key="i"
v-bind="commonProps"
:options="copilots"
v-model="formData.copilots[i-1]"
:clearable="true"
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
/>
</div>
</FormWrapper> </FormWrapper>
</template> </template>
@@ -8,6 +8,7 @@ import ComboboxInput from "components/inputs/ComboboxInput.vue";
import SlugInput from "components/inputs/SlugInput.vue"; import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser"; import {useLoggedUserStore} from "stores/LoggedUser";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -18,6 +19,10 @@ const props = defineProps({
type: Array, type: Array,
required: false, required: false,
}, },
copilots: {
type: Array,
required: false,
},
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -31,7 +36,8 @@ const formData = reactive({
description: "", description: "",
debriefingNotes: "", debriefingNotes: "",
urlSlug: "", urlSlug: "",
isPublic: null isPublic: null,
copilots: []
}) })
@@ -47,6 +53,20 @@ watch(() => props.flight, (newVal) => {
} }
}, {immediate: true}); }, {immediate: true});
const solo = ref(false);
watch(() => solo.value, (newVal) => {
if (!newVal) {
formData.copilots = [];
}
});
watch(() => props.flight, (newVal) => {
if (newVal) {
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
solo.value = formData.copilots.length === 0;
}
}, {deep: true, immediate: true});
const descriptionTab = ref("description"); const descriptionTab = ref("description");
</script> </script>
@@ -66,18 +86,52 @@ const descriptionTab = ref("description");
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/> <q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template> </template>
<div>
Letel jsi
<q-radio
v-model="solo"
:val="true"
label="Sólo"
/>
<q-radio
v-model="solo"
:val="false"
label="S někým"
/>
</div>
<div v-if="!solo">
<ComboboxInput
v-for="i in flight.aircraft.seats - 1"
:key="i"
v-bind="commonProps"
:options="copilots"
v-model="formData.copilots[i-1]"
:clearable="true"
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
/>
</div>
<q-tabs v-model="descriptionTab" active-color="primary"> <q-tabs v-model="descriptionTab" active-color="primary">
<q-tab name="description">Popis</q-tab> <q-tab name="description">Popis</q-tab>
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab> <q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
</q-tabs> </q-tabs>
<q-tab-panels v-model="descriptionTab"> <q-tab-panels v-model="descriptionTab">
<q-tab-panel name="description"> <q-tab-panel name="description">
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/> <RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="debriefing"> <q-tab-panel name="debriefing">
<BasicInfoBanner>
Debriefing by měl obsahovat minimálně následující body:
<ul>
<li><strong>Shrnutí letu:</strong> Trasa, čas a splnění cílů.</li>
<li><strong>Výkon:</strong> Navigace, reakce na podmínky, zvládnutí letu.</li>
<li><strong>Stav letadla:</strong> Funkčnost přístrojů, případné problémy.</li>
<li><strong>Bezpečnostní postupy:</strong> Dodržení bezpečnostních pravidel, řešení neočekávaných situací.</li>
<li><strong>Zpětná vazba:</strong> Pocity z letu, spokojenost pasažéra, návrhy na zlepšení.</li>
</ul>
</BasicInfoBanner>
<RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/> <RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/>
</q-tab-panel> </q-tab-panel>
@@ -1,8 +1,6 @@
<script setup> <script setup>
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {reactive} from "vue"; import {reactive} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {roundWithPrecision} from "../../../composables/utils";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -25,11 +25,11 @@ onMounted(() => {
}) })
const steps = [ const steps = [
{name: "basic", icon: "flight_takeoff", label: "Základní informace"}, {name: "editFlightBasic", icon: "flight_takeoff", label: "Základní informace"},
{name: "copilot", icon: "person_add", label: "Spolucestující"}, {name: "editFlightTrack", icon: "route", label: "Trasa"},
{name: "track", icon: "route", label: "Trasa"}, {name: "editFlightPhotos", icon: "add_a_photo", label: "Fotky"},
{name: "photos", icon: "add_a_photo", label: "Fotky"}, {name: "editFlightNotes", icon: "edit_note", label: "Poznámky"},
{name: "notes", icon: "edit_note", label: "Poznámky"}, {name: "editFlightSetPlan", icon: "pin_drop", label: "Plán"},
]; ];
const emit = defineEmits(["update:modelValue"]) const emit = defineEmits(["update:modelValue"])
@@ -37,14 +37,20 @@ watch(currentStep, (val) => emit("update:modelValue", val));
</script> </script>
<template> <template>
<q-tabs v-if="routerEditMode" active-color="primary" class="q-mb-lg"> <q-tabs
v-if="routerEditMode"
active-color="primary"
class="q-mb-lg"
left-icon="chevron_left"
right-icon="chevron_right"
>
<q-route-tab <q-route-tab
v-for="step in steps" v-for="step in steps"
:key="step.name" :key="step.name"
:icon="step.icon" :icon="step.icon"
:label="step.label" :label="step.label"
replace replace
:to="{name: 'flightEdit', params: {id: flightId, step: step.name}}" :to="{name: step.name, params: {id: flightId}}"
:disable="disabled.includes(step.name)" :disable="disabled.includes(step.name)"
exact exact
/> />
@@ -60,7 +66,6 @@ watch(currentStep, (val) => emit("update:modelValue", val));
active-color="primary" active-color="primary"
class="q-mb-lg" class="q-mb-lg"
> >
<q-tab v-for="step in steps" :key="step.name" v-bind="step" :disable="disabled.includes(step.name)"/> <q-tab v-for="step in steps" :key="step.name" v-bind="step" :disable="disabled.includes(step.name)"/>
</q-tabs> </q-tabs>
</template> </template>
@@ -51,7 +51,7 @@ watch(poi, (newVal) => {
} }
}); });
watch(() => props.flight.track, (newVal) => { watch(() => props.flight.turnPoints, (newVal) => {
formData.track = []; formData.track = [];
for (const i in newVal) { for (const i in newVal) {
const trackInfo = newVal[i]; const trackInfo = newVal[i];
@@ -76,7 +76,7 @@ watch(() => props.flight.track, (newVal) => {
<template> <template>
<FormWrapper v-model="formData"> <FormWrapper v-model="formData">
<q-banner class="bg-green text-white" v-if="flight.gpxTrack"> <q-banner class="bg-green text-white" v-if="flight.track">
<q-icon name="check"/> <q-icon name="check"/>
Trasa nahrána Trasa nahrána
</q-banner> </q-banner>
@@ -0,0 +1,23 @@
<script setup></script>
<template>
<div class="row">
<div class="col">
<q-card class="q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
</div>
</div>
<div class="row">
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
</div>
</div>
</template>
@@ -0,0 +1,6 @@
<script setup></script>
<template>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
<q-skeleton type="rect" width="100%" height="500px"/>
</template>
@@ -1,77 +0,0 @@
<script setup>
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import {randomInt} from "src/composables/random";
const props = defineProps({
step: {
type: String,
required: true
},
})
</script>
<template>
<template v-if="step === 'basic'">
<div class="row">
<div class="col">
<q-card class="q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
</div>
</div>
<div class="row">
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
</div>
</div>
</template>
<q-card v-else-if="step === 'copilot'">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm"/>
</q-card-section>
</q-card>
</q-card>
<template v-else-if="step === 'track'">
<q-card>
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
</template>
<template v-else-if="step === 'photos'">
<q-card class="q-my-lg">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm"/>
</q-card-section>
</q-card>
<div class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="i in randomInt(3, 10)" :key="i">
<q-card class="q-ma-md">
<q-skeleton height="300px" type="rect"/>
</q-card>
</div>
</div>
</template>
<div v-else-if="step === 'notes'">
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
<q-skeleton type="rect" width="100%" height="500px"/>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
</script>
<template>
<q-card>
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
</template>
@@ -1,33 +0,0 @@
<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>
@@ -3,13 +3,16 @@
<template> <template>
<q-timeline color="primary" class="q-my-lg"> <q-timeline color="primary" class="q-my-lg">
<q-timeline-entry title="Naplánuj si let">
<div>
Pomocí jednoduchého plánovače si sestav trasu, označ si zajímavé body, připrav si navigační štítek a zkontroluj počasí.
</div>
</q-timeline-entry>
<q-timeline-entry title="Zaznamenej si let"> <q-timeline-entry title="Zaznamenej si let">
<div> <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í. 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> </div>
</q-timeline-entry>
<q-timeline-entry title="Ulož trasu">
<div> <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ě. 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> </div>
+1 -1
View File
@@ -100,7 +100,7 @@ const filterFn = (val, update, abort) => {
clear-icon="clear" clear-icon="clear"
:v-bind="$attrs" :v-bind="$attrs"
> >
<template v-slot:no-option> <template #no-option>
<q-item v-if="inputValue"> <q-item v-if="inputValue">
<q-item-section avatar> <q-item-section avatar>
<q-avatar text-color="green" size="32px" icon="add"/> <q-avatar text-color="green" size="32px" icon="add"/>
+14 -3
View File
@@ -7,6 +7,16 @@ const props = defineProps({
route: { route: {
type: Object, type: Object,
required: true required: true
},
icon: {
type: String,
required: false,
default: "link"
},
label: {
type: String,
required: false,
default: "Získat odkaz"
} }
}) })
@@ -24,9 +34,10 @@ const copyLinkToClipboard = () => {
<template> <template>
<q-btn <q-btn
icon="link" :icon="icon"
@click="copyLinkToClipboard()" :label="label"
v-bind="$attrs"
flat flat
label="Získat odkaz" @click="copyLinkToClipboard()"
/> />
</template> </template>
+42 -30
View File
@@ -1,14 +1,9 @@
<script setup> <script setup>
import {Marker} from "vue3-google-map"; import {Marker} from "vue3-google-map";
import {onMounted, ref} from "vue"; import {onMounted, ref, watch} from "vue";
import axios from "axios";
import MapWrapper from "components/map/MapWrapper.vue"; import MapWrapper from "components/map/MapWrapper.vue";
import {useGeocode} from "src/composables/geocoding";
const center = ref({ import axios from "axios";
lat: 50.1314,
lng: 14.5256
});
const zoom = ref(10);
const props = defineProps({ const props = defineProps({
gpsLatitude: { gpsLatitude: {
@@ -24,44 +19,61 @@ const props = defineProps({
required: false required: false
} }
}); });
const emits = defineEmits(["update:gpsLatitude", "update:gpsLongitude", "notFound"]); const emits = defineEmits(["update:gpsLatitude", "update:gpsLongitude", "notFound"]);
const emitModels = (lat, lng) => { const emitModels = (lat, lng) => {
emits("update:gpsLatitude", lat); emits("update:gpsLatitude", lat);
emits("update:gpsLongitude", lng); emits("update:gpsLongitude", lng);
} }
const {searchGeocode, center} = useGeocode(emits);
const markerDropped = (position) => { const markerDropped = (position) => {
emitModels(position.latLng.lat(), position.latLng.lng()); emitModels(position.latLng.lat(), position.latLng.lng());
} }
const setCoordinatesFromGeocode = (address) => { const zoom = ref(8);
axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURI(address)}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => {
if (response.data.status !== 'OK') {
emits("notFound", props.label);
return;
}
center.value = response.data.results[0].geometry.location;
emitModels(center.value.lat, center.value.lng);
zoom.value = 13;
});
}
onMounted(() => { watch(() => [props.label, props.gpsLongitude, props.gpsLatitude], (val, oldValue) => {
if (props.gpsLatitude && props.gpsLongitude) { const [newAddress, newLon, newLat] = val
center.value.lng = props.gpsLongitude; const [previousAddress, oldLon, oldLat] = oldValue
center.value.lat = props.gpsLatitude if (newAddress) {
} else if (props.label) { if (newAddress === previousAddress) return
setCoordinatesFromGeocode(props.label);
searchGeocode(newAddress).then(results => {
if (!results) return;
const lat = results[0].geometry.location.lat()
const lng = results[0].geometry.location.lng()
center.value = {lng, lat}
emitModels(center.value.lat, center.value.lng);
zoom.value = 13;
});
} else if (newLat && newLon && (props.gpsLatitude !== newLat || props.gpsLongitude !== newLon)) {
center.value.lng = newLon;
center.value.lat = newLat
} else { } else {
console.log(newAddress, newLon, newLat, props.label, props.gpsLongitude, props.gpsLatitude)
console.error("Set either label or gps coordinates."); console.error("Set either label or gps coordinates.");
} }
}) },
// {immediate: true}
)
</script> </script>
<template> <template>
<MapWrapper style="width: 100%; height: 500px" :center="center" :zoom="zoom"> <MapWrapper
<Marker :options="{ position: center, title: label, draggable: true }" @dragend="markerDropped"/> :show-info-window="false"
:show-layer-controls="false"
style="width: 100%; height: 500px"
:center="center"
:zoom="zoom"
v-bind="$attrs"
>
<Marker
:options="{ position: center, title: label, draggable: true }"
@dragend="markerDropped"
/>
</MapWrapper> </MapWrapper>
</template> </template>
@@ -0,0 +1,19 @@
<script setup>
import {useAirportsStore} from "stores/Airports";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {ref} from "vue";
const props = defineProps({
modelValue: {
type: [Object, Array]
},
})
const model = ref("");
const options = useAirportsStore().fetch();
</script>
<template>
<ComboboxInput :options="options" v-model="model"
</template>
+229
View File
@@ -0,0 +1,229 @@
<script setup>
import {CustomMarker, InfoWindow} from "vue3-google-map";
import {useFlightPlanUtils} from "src/composables/flightPlan";
import {useGeocode} from "src/composables/geocoding";
import {computed, ref, watch} from "vue";
import {useAirspaceColors} from "src/composables/airspaces";
import AltitudeLimit from "components/AltitudeLimit.vue";
const props = defineProps({
infoWindow: {
type: Object,
required: true
},
highlightedAirspaces: {
type: Array,
required: false,
default: () => []
},
isInTrack: {
type: Boolean,
required: false,
default: false,
},
showActions: {
type: Boolean,
required: false,
default: false
}
});
const emits = defineEmits(["add-to-route", "remove-from-route", "highlight-airspace", "close"])
const {getIcon} = useFlightPlanUtils();
const {searchPlaceName} = useGeocode()
const title = ref(null);
const isOpened = ref(props.infoWindow.active);
watch(() => props.infoWindow.position, async () => {
if (props.infoWindow.title) {
title.value = props.infoWindow.title
} else {
searchPlaceName(props.infoWindow.position.lat, props.infoWindow.position.lng).then(data => title.value = data);
}
}, {immediate: true, deep: true});
const addPoint = () => {
let type = "mapPoint";
if (props.infoWindow.poi) {
type = "poi";
} else if (props.infoWindow.airport) {
type = "airport";
}
const point = props.infoWindow.marker || {
name: title.value,
airportId: props.infoWindow.airport?.id,
pointOfInterestId: props.infoWindow.poi?.id,
type,
gpsLatitude: props.infoWindow.position.lat,
gpsLongitude: props.infoWindow.position.lng
}
emits("add-to-route", point)
closeInfoWindow();
}
const closeInfoWindow = () => {
emits('close');
isOpened.value = false;
}
const removePoint = () => {
emits("remove-from-route", props.infoWindow);
emits("close");
}
const {getColorByType} = useAirspaceColors();
const highlightAirspace = (airspace) => {
emits('highlight-airspace', airspace.name)
}
const runways = computed(() => {
const runwayPairs = {};
for (const runway of props.infoWindow.airport.runways) {
runwayPairs[runway.designator] = runway;
}
return runwayPairs;
});
const runwaySurfaceName = (surface) => {
const surfaceMap = {
grass: "tráva",
concrete: "beton",
asphalt: "asfalt"
}
return surfaceMap[surface] || 'jiné'
}
const openedTab = ref("info");
</script>
<template>
<InfoWindow
@close="closeInfoWindow"
v-model="isOpened"
:options="{position: infoWindow.position}"
>
<q-tabs v-model="openedTab">
<q-tab name="info">{{ title }}</q-tab>
<q-tab name="airspaces" v-if="infoWindow.airspaces?.length">Prostory</q-tab>
</q-tabs>
<q-tab-panels v-model="openedTab">
<q-tab-panel name="info">
<div v-if="infoWindow.airport">
Elevace: {{ infoWindow.airport.elevation }} m
<div class="row">
<div class="col">
<q-list dense>
<q-item dense v-for="runway in runways" :key="runway">
<q-item-section>
<q-item-label>
<strong>{{ runway.designator }}</strong> ({{ runwaySurfaceName(runway.surface) }})
</q-item-label>
<q-item-label caption>
{{ runway.heading }}&deg;, {{ runway.width }} &times; {{ runway.length }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
<div class="col">
<q-list>
<q-item dense v-for="frequency in infoWindow.airport.frequencies" :key="frequency">
<q-item-section>
<q-item-label>
<strong>{{ frequency.name }}</strong>
</q-item-label>
<q-item-label caption>
{{ frequency.frequency }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</div>
<div v-if="infoWindow.marker">
<span class="text-h6">
<q-icon :name="getIcon(infoWindow.marker.type)"/>
{{ infoWindow.marker.name }}
</span>
</div>
<div v-if="infoWindow.poi">
<q-img :src="infoWindow.poi.titlePhoto.thumbnailUrl" width="300px" v-if="infoWindow.poi.titlePhoto"/>
</div>
</q-tab-panel>
<q-tab-panel name="airspaces" v-if="infoWindow.airspaces?.length">
<q-list>
<q-item-label v-for="airspace in infoWindow.airspaces" :key="airspace.name">
<q-avatar size="16px" class="q-mx-sm" :style="`border: solid 1px; background: ${getColorByType(airspace.type)}`"/>
<strong>
{{ airspace.name }}
</strong>
<AltitudeLimit
class="q-mx-md"
:upper-limit="airspace.upperLimit"
:upper-limit-unit="airspace.upperAltUnit"
:lower-limit="airspace.lowerLimit"
:lower-limit-unit="airspace.lowerAltUnit"
/>
<q-btn
v-if="airspace.type !== 'FIR'"
color="white"
:text-color="props.highlightedAirspaces.includes(airspace.name) ? 'primary' : 'grey-6'"
size="x-small"
round
text
icon="mdi-eye"
@click="highlightAirspace(airspace)"
/>
</q-item-label>
</q-list>
</q-tab-panel>
</q-tab-panels>
<template v-if="showActions">
<q-btn
v-if="isInTrack"
class="q-mr-sm q-mt-lg"
color="warning"
@click="removePoint()"
>
Odebrat z trasy
</q-btn>
<q-btn
:class="{'q-mt-lg': true, 'full-width': !isInTrack}"
color="secondary"
@click="addPoint()"
>
Přidat bod do trasy
</q-btn>
</template>
<CustomMarker :options="{position: infoWindow.position, anchorPoint: 'CENTER'}" v-if="isOpened">
<div class="info-window-position"></div>
</CustomMarker>
</InfoWindow>
</template>
<style scoped>
.info-window-position {
border: solid 4px orange;
outline: solid 1px black;
border-radius: 50%;
padding: 4px;
position: relative;
z-index: 9999;
}
</style>
+52 -10
View File
@@ -1,14 +1,14 @@
<script setup> <script setup>
import MapWrapper from "components/map/MapWrapper.vue"; import MapWrapper from "components/map/MapWrapper.vue";
import {Marker, Polyline, InfoWindow} from "vue3-google-map"; import {Marker, Polyline, InfoWindow} from "vue3-google-map";
import {computed, onMounted, ref} from "vue"; import {computed} from "vue";
import {axios} from "boot/axios";
const props = defineProps({ const props = defineProps({
gpxTrack: { tracks: {
type: Object, type: Array,
required: false, required: false,
default: () => []
}, },
markers: { markers: {
type: Array, type: Array,
@@ -23,15 +23,56 @@ const props = defineProps({
type: Array, type: Array,
required: false, required: false,
default: () => [] default: () => []
},
bounds: {
type: Array,
required: false,
default: () => []
} }
}) })
const track = ref([]);
const bounds = computed(() => { const bounds = computed(() => {
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}})); const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
const allData = [...props.markers, ...track.value, ...photosTransformed]; const coordinates = [
return allData.filter(i => !!i.position).map(i => i.position); ...[...props.markers, ...photosTransformed].filter(i => !!i.position).map(i => i.position),
...(props.tracks || []).flat()
];
const latitudes = coordinates.filter(i => i && i.lat).map(i => i.lat);
const longitudes = coordinates.filter(i => i && i.lng).map(i => i.lng);
const minLat = Math.min(...latitudes);
const maxLat = Math.max(...latitudes);
const minLng = Math.min(...longitudes);
const maxLng = Math.max(...longitudes);
return [
{lat: minLat, lng: minLng},
{lat: minLat, lng: maxLng},
{lat: maxLat, lng: minLng},
{lat: maxLat, lng: maxLng},
];
}); });
const getRandomColor = () => {
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
return `rgb(${r}, ${g}, ${b})`;
}
const getColor = (i) => {
const colors = [
'#FF0000',
'#5733FF', '#4bec41', '#8d3f25',
'#33A1FF', '#6d7e59', '#FF3333', '#0e1c48',
'#A133FF', '#FFFF33', '#c9decc'
]
return colors[i] || getRandomColor()
}
</script> </script>
<template> <template>
@@ -43,11 +84,12 @@ const bounds = computed(() => {
</InfoWindow> </InfoWindow>
</Marker> </Marker>
<template v-if="gpxTrack && gpxTrack.length > 0"> <template v-if="tracks && tracks.length > 0">
<Polyline :options="{path: gpxTrack, geodesic: true, strokeColor: '#ff0000', strokeOpacity: 0.3, strokeWeight: 5}"/> <template v-for="(track, i) in tracks" :key="track">
<Polyline :options="{path: track, geodesic: true, strokeColor: getColor(i), strokeOpacity: 0.5, strokeWeight: 5}"/>
</template>
</template> </template>
<Marker v-if="highlightPosition" :options="{position: highlightPosition, icon: '/mapIcons/currentPosition32x32.png'}"/> <Marker v-if="highlightPosition" :options="{position: highlightPosition, icon: '/mapIcons/currentPosition32x32.png'}"/>
</MapWrapper> </MapWrapper>
</template> </template>
+365 -11
View File
@@ -1,37 +1,391 @@
<script setup> <script setup>
import {GoogleMap} from "vue3-google-map"; import {CustomControl, CustomMarker, GoogleMap, Polygon, Polyline} from "vue3-google-map";
import {ref, watch} from "vue"; import {computed, ref, watch} from "vue";
import {useVectorUtilities} from "src/graphql/fragments/vectorUtilities";
import {useAirspaceColors, useAltitudeUtils} from "src/composables/airspaces";
import AirspaceInfoWindow from "components/map/AirspaceInfoWindow.vue";
import {useFlightPlanObjects} from "src/composables/flightPlan";
import {debounce} from "quasar";
const props = defineProps({ const props = defineProps({
bounds: { bounds: {
type: Array, type: Array,
required: false required: false
},
airspacesData: {
type: Array,
required: false,
default: () => []
},
airports: {
type: Array,
required: false,
default: () => []
},
pointsOfInterest: {
type: Array,
required: false,
default: () => []
},
track: {
type: Object,
required: false,
},
showInfoWindow: {
type: Boolean,
required: false,
default: false,
},
allowAddMarker: {
type: Boolean,
required: false,
default: false
},
showLayerControls: {
type: Boolean,
required: false,
default: true
} }
}); });
const emits = defineEmits(["map-click", "add-to-route", "remove-from-route"]);
const key = process.env.GOOGLE_MAPS_API_KEY; const key = process.env.GOOGLE_MAPS_API_KEY;
const mapRef = ref(null); const mapRef = ref(null);
const zoomLevel = ref(null);
const visibleBoundsObject = ref(null);
watch( watch(
() => mapRef.value?.ready, () => mapRef.value?.ready,
(ready) => { (ready) => {
if (!props.bounds || !ready) return; if (!ready) return;
const map = mapRef.value.map;
const api = mapRef.value.api;
const bounds = new api.LatLngBounds(); if (props.bounds) {
for (let i = 0; i < props.bounds.length; i++) { const bounds = new mapRef.value.api.LatLngBounds();
bounds.extend(props.bounds[i]); for (let i = 0; i < props.bounds.length; i++) {
bounds.extend(props.bounds[i]);
}
mapRef.value.map.fitBounds(bounds);
} }
map.fitBounds(bounds); zoomLevel.value = mapRef.value.map.getZoom();
mapRef.value.map.addListener("zoom_changed", debounce(() => {
zoomLevel.value = mapRef.value.map.getZoom();
}, 100));
mapRef.value.map.addListener("bounds_changed", debounce(() => {
visibleBoundsObject.value = mapRef.value.map.getBounds();
}, 100));
} }
); );
const airspaceLayers = ref({
CTR: {displayed: true, color: "", minZoom: 7},
TMA: {displayed: true, color: "", minZoom: 7},
RMZ: {displayed: false, color: "", minZoom: 7},
DANGER: {label: "Nebezpečné prostory", displayed: false, color: "", minZoom: 7},
PROHIBIT: {label: "Zakázané prostory", displayed: true, color: "", minZoom: 7},
RESTRICT: {label: "TRA", displayed: true, color: "", minZoom: 7},
TRAGA: {displayed: true, color: "", minZoom: 8},
ATZ: {displayed: true, color: "", minZoom: 8},
AIRPORTS: {label: "Letiště", displayed: true, color: "", minZoom: 10},
FIELDSTRIP: {label: "SLZ plochy", displayed: false, color: "", minZoom: 11},
})
const isAirspaceVisible = (airspace, bounds) => {
if (!bounds) return false;
if (airspace.mapBounds) {
return airspace.mapBounds.some(b => bounds.contains(b));
}
if (airspace.gpsLatitude && airspace.gpsLongitude) {
return bounds.contains({lat: airspace.gpsLatitude, lng: airspace.gpsLongitude});
}
return true;
}
const maxAltitude = ref(5000);
const {altitudeLimitFormatted, altitudeToFt} = useAltitudeUtils();
const airspaces = computed(() => {
const colorsMap = useAirspaceColors().colors;
return props.airspacesData
.filter(airspace => airspaceLayers.value[airspace.type] && airspaceLayers.value[airspace.type].displayed)
.filter(airspace => airspaceLayers.value[airspace.type].minZoom <= zoomLevel.value)
.filter(airspace => isAirspaceVisible(airspace, visibleBoundsObject.value))
.filter(airspace => altitudeToFt(airspace.lowerLimit, airspace.lowerAltUnit) < maxAltitude.value)
.map(airspace => {
let fillOpacity = 0;
let strokeWeight = 1;
if (["PROHIBIT", "RESTRICT"].includes(airspace.type))
fillOpacity = 0.1;
if (highlightedAirspaces.value.includes(airspace.name)) {
strokeWeight = 2;
fillOpacity = 0.3;
}
const data = {
name: airspace.name,
strokeColor: colorsMap[airspace.type],
fillColor: colorsMap[airspace.type],
strokeOpacity: 0.5,
strokeWeight,
fillOpacity,
};
if (airspace.mapBounds.length > 0) {
data['path'] = airspace.mapBounds;
}
return data
});
});
const airportsOnMap = computed(() => {
const displayAirport = airspaceLayers.value.AIRPORTS.displayed;
const displayFieldstrip = airspaceLayers.value.FIELDSTRIP.displayed;
if (!displayAirport && !displayFieldstrip) return [];
return props.airports
.filter(a => a.gpsLatitude && a.gpsLongitude)
.filter(a => isAirspaceVisible(a, visibleBoundsObject.value))
.filter(a => zoomLevel.value >= (a.icaoCode.length === 6 ? airspaceLayers.value.FIELDSTRIP.minZoom : airspaceLayers.value.AIRPORTS.minZoom))
.filter(a => a.icaoCode.length === 6 ? displayFieldstrip : displayAirport)
});
const {isPointInPolygon} = useVectorUtilities();
const getAirspacesAtCoordinates = (clickCoordinates) => {
const airspacesUnderClick = []
for (const airspace of props.airspacesData) {
if (airspace.mapBounds && isPointInPolygon(clickCoordinates, airspace.mapBounds)) {
airspacesUnderClick.push(airspace);
}
}
return airspacesUnderClick;
}
const {infoWindowObject} = useFlightPlanObjects();
const infoWindow = ref({...infoWindowObject({})});
const openInfoWindow = (data) => {
infoWindow.value = infoWindowObject({active: true, ...data});
}
defineExpose({
openInfoWindow
});
const emitMapClick = (data) => {
isControlsOpened.value = false;
openInfoWindow(data);
emits("map-click", data);
}
const highlightedAirspaces = ref([]);
const highlightAirspace = (airspaceName) => {
const airspace = props.airspacesData.find(a => a.name === airspaceName);
if (airspace) {
highlightedAirspaces.value = [airspaceName];
airspaceLayers.value[airspace.type].displayed = true;
}
}
const handleMapClick = (e) => {
const position = {lat: e.latLng.lat(), lng: e.latLng.lng()};
const airspacesUnderClick = getAirspacesAtCoordinates(position);
highlightedAirspaces.value = airspacesUnderClick.map(a => a.name);
emitMapClick({position, airspaces: airspacesUnderClick});
}
const handleReportingPointClick = (airport, reportingPoint) => {
const position = {lat: reportingPoint.gpsLatitude, lng: reportingPoint.gpsLongitude};
const airspacesUnderClick = getAirspacesAtCoordinates(position);
highlightedAirspaces.value = [];
emitMapClick({position, airport, reportingPoint, airspaces: airspacesUnderClick})
}
const handleAirportClick = (airport) => {
const position = {lat: airport.gpsLatitude, lng: airport.gpsLongitude};
const airspacesUnderClick = getAirspacesAtCoordinates(position);
highlightedAirspaces.value = [];
emitMapClick({position, airport, airspaces: airspacesUnderClick})
}
const handlePoiClick = (poi) => {
const position = {lat: poi.gpsLatitude, lng: poi.gpsLongitude};
const airspacesUnderClick = getAirspacesAtCoordinates(position);
highlightedAirspaces.value = [];
emitMapClick({poi, position, airspaces: airspacesUnderClick});
}
const isDetailInTrack = computed(() => {
if (!props.track) return false;
return !!props.track.path.find(i => i.lat === infoWindow.value.position.lat && i.lng === infoWindow.value.position.lng);
});
const isControlsOpened = ref(false);
</script> </script>
<template> <template>
<GoogleMap :api-key="key" v-bind="$attrs" :max-zoom="13" ref="mapRef">
<!-- <q-banner>-->
<!-- https://dev.to/quasar/how-to-use-google-maps-in-a-quasar-project-1c13-->
<!-- </q-banner>-->
<GoogleMap
:api-key="key"
v-bind="$attrs"
:street-view-control="false"
@click="handleMapClick"
map-type-id="roadmap"
gesture-handling="greedy"
:max-zoom="13"
ref="mapRef"
>
<!-- TODO: pridavani a odebirani z trasy resit pres slot-->
<AirspaceInfoWindow
v-if="showInfoWindow && infoWindow.active"
:info-window="infoWindow"
:show-actions="allowAddMarker"
:highlighted-airspaces="highlightedAirspaces"
:is-in-track="isDetailInTrack"
@close="infoWindow.active=false"
@add-to-route="emits('add-to-route', $event)"
@remove-from-route="emits('remove-from-route', $event)"
@highlight-airspace="highlightAirspace"
/>
<template v-for="airspace in airspaces" :key="airspace.name">
<Polygon :options="airspace" @click="handleMapClick"/>
</template>
<slot/> <slot/>
<template v-for="airport in airportsOnMap" :key="airport.icaoCode">
<CustomMarker
style="position: relative; z-index: 100"
:options="{position: {lat: airport.gpsLatitude, lng: airport.gpsLongitude}, anchorPoint: 'CENTER'}"
@click.stop="handleAirportClick(airport)"
>
<img
:title="airport.icaoCode"
:src="airport.iconUrl"
:alt="airport.icaoCode"
/>
</CustomMarker>
<template v-if="airspaceLayers.CTR.displayed">
<CustomMarker
v-for="reportingPoint in airport.reportingPoints" :key="reportingPoint.id"
:options="{position: {lat: reportingPoint.gpsLatitude, lng: reportingPoint.gpsLongitude, anchorPoint: 'CENTER'}}"
@click.stop="handleReportingPointClick(airport, reportingPoint)"
:title="`${airport.icaoCode}: ${reportingPoint.name}`"
style="font-size: 20px"
>
&#9650;
</CustomMarker>
</template>
</template>
<template v-for="poi in pointsOfInterest" :key="poi.name">
<CustomMarker
v-if="poi.gpsLatitude && poi.gpsLongitude"
:options="{position: {lat: poi.gpsLatitude, lng: poi.gpsLongitude}, anchorPoint: 'BOTTOM_CENTER'}"
@click.stop="handlePoiClick(poi)"
>
<div class="poi-avatar" :title="poi.name" v-if="!poi.titlePhoto">
<img src="/mapIcons/photo-32x32.png" alt="">
</div>
<div
:title="poi.name"
v-else
class="poi-img"
:style="`background-image: url(${poi.titlePhoto.thumbnailUrl})`"
>
</div>
</CustomMarker>
</template>
<Polyline v-if="track" :options="track"/>
<CustomControl position="TOP_LEFT" v-if="showLayerControls" style="width: auto; max-width: 200px">
<q-btn
@click="isControlsOpened = !isControlsOpened"
:icon="isControlsOpened ? 'layers_clear' : 'layers'"
color="grey-2"
text-color="grey-9"
round
size="sm"
class="q-my-md"
>
</q-btn>
<q-card
v-if="isControlsOpened"
class="q-px-lg"
style="max-width: 30%; min-width: 200px;"
>
<template v-for="(airspaceLayer, key) in airspaceLayers" :key="airspaceLayer">
<q-checkbox
v-model="airspaceLayers[key].displayed"
v-if="airspaceLayers[key].minZoom <= zoomLevel"
:label="airspaceLayer.label || key"
/>
</template>
<br>
<div class="q-my-lg">
Zobrazit prostory od GND do výšky {{ altitudeLimitFormatted(maxAltitude, 'ft') }}
<q-slider
v-model.number="maxAltitude"
:min="1000"
:max="15000"
label
:label-value="altitudeLimitFormatted(maxAltitude, 'ft')"
:step="1000"
/>
</div>
<slot name="layersControlsEnd"></slot>
</q-card>
</CustomControl>
</GoogleMap> </GoogleMap>
</template> </template>
<style lang="scss">
.poi-img {
border: solid 3px #8bdfff;
outline: solid 1px black;
width: 40px;
height: 40px;
border-radius: 50%;
background-size: cover;
}
.poi-avatar {
padding: 5px;
border-radius: 15px;
background: #8bdfff;
img {
width: 20px;
}
}
</style>
+18 -23
View File
@@ -9,12 +9,12 @@
v-if="icon" v-if="icon"
avatar avatar
> >
<q-icon :name="icon" /> <q-icon :name="icon"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ title }}</q-item-label> <q-item-label>{{ title }}</q-item-label>
<q-item-label caption>{{ caption }}</q-item-label> <q-item-label caption v-if="caption">{{ caption }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</template> </template>
@@ -24,26 +24,21 @@ import {computed} from "vue";
import {useRoute, useRouter} from "vue-router"; import {useRoute, useRouter} from "vue-router";
const props = defineProps({ const props = defineProps({
title: { title: {
type: String, type: String,
required: true required: true
}, },
to: {
caption: { type: Object,
type: String, required: true
default: '' },
}, caption: {
type: String,
to: { },
type: Object, icon: {
required: true type: String,
}, }
});
icon: {
type: String,
default: ''
}
});
const $route = useRoute(); const $route = useRoute();
const $router = useRouter(); const $router = useRouter();
@@ -51,7 +46,7 @@ const isButtonActive = computed(() => {
const btnResolvedPath = $router.resolve(props.to).path; const btnResolvedPath = $router.resolve(props.to).path;
if (props.to.name === 'myFlights') { if (props.to.name === 'myFlights') {
return $route.name.toLowerCase().includes("flight"); return ["myFlights", "addFlight", "flightDetail", "flightEdit"].includes($route.name);
} else { } else {
return $route.fullPath === btnResolvedPath || $route.fullPath.startsWith(btnResolvedPath); return $route.fullPath === btnResolvedPath || $route.fullPath.startsWith(btnResolvedPath);
} }
@@ -6,9 +6,15 @@ const linksList = [
{ {
title: 'Moje lety', title: 'Moje lety',
icon: "flight_takeoff", icon: "flight_takeoff",
to: {name: "myFlights"} to: {name: "myFlights"},
}, },
{separator: true}, {
title: 'Moje plány',
icon: "pin_drop",
to: {name: "flightPlans"}
},
{separator: true},
{ {
title: 'Letadla', title: 'Letadla',
@@ -19,6 +19,9 @@ const props = defineProps({
copilots: { copilots: {
type: Array type: Array
}, },
loading: {
type: Boolean
}
}); });
const formData = ref({ const formData = ref({
@@ -44,7 +47,12 @@ const setup = () => {
<q-card-section class="text-center"> <q-card-section class="text-center">
<q-img :src="photo.thumbnailUrl" width="80px" class="q-ma-sm" v-for="photo in photos" :key="photo.thumbnailUrl"/> <q-img :src="photo.thumbnailUrl" width="80px" class="q-ma-sm" v-for="photo in photos" :key="photo.thumbnailUrl"/>
</q-card-section> </q-card-section>
<q-card-section>
<q-card-section v-if="loading">
<q-spinner />
</q-card-section>
<q-card-section v-else>
<FormWrapper :save-button="false"> <FormWrapper :save-button="false">
<ComboboxInput <ComboboxInput
:options="locations" :options="locations"
+8 -2
View File
@@ -18,6 +18,9 @@ const props = defineProps({
}, },
copilots: { copilots: {
type: Array type: Array
},
loading: {
type: Boolean
} }
}); });
@@ -38,7 +41,10 @@ const setup = (() => {
<template> <template>
<DialogWrapper :formData="formData" :object-id="photoId" @show="setup"> <DialogWrapper :formData="formData" :object-id="photoId" @show="setup">
<q-img :src="photo.thumbnailUrl"/> <q-img :src="photo.thumbnailUrl"/>
<q-card-section> <q-card-section v-if="loading">
<q-spinner />
</q-card-section>
<q-card-section v-else>
<FormWrapper :save-button="false"> <FormWrapper :save-button="false">
<ComboboxInput <ComboboxInput
:options="locations" :options="locations"
@@ -67,7 +73,7 @@ const setup = (() => {
option-value="id" option-value="id"
emit-value emit-value
map-options map-options
/> />
<q-input v-model="formData.name" label="Název" v-bind="commonProps"/> <q-input v-model="formData.name" label="Název" v-bind="commonProps"/>
<q-input type="textarea" v-model="formData.description" label="Popis" v-bind="commonProps"/> <q-input type="textarea" v-model="formData.description" label="Popis" v-bind="commonProps"/>
+68 -38
View File
@@ -6,8 +6,9 @@ import PhotoSwipeDynamicCaption from "../../../node_modules/photoswipe-dynamic-c
import PhotoCaption from "components/photos/PhotoCaption.vue"; import PhotoCaption from "components/photos/PhotoCaption.vue";
import PhotoSwipeLightbox from "../../../node_modules/photoswipe/dist/photoswipe-lightbox.esm" import PhotoSwipeLightbox from "../../../node_modules/photoswipe/dist/photoswipe-lightbox.esm"
import PhotoSwipe from "../../../node_modules/photoswipe/dist/photoswipe.esm" import PhotoSwipe from "../../../node_modules/photoswipe/dist/photoswipe.esm"
import {useRoute, useRouter} from "vue-router"; import {useRouter} from "vue-router";
import {usePhotosStore} from "stores/Photos"; import {usePhotosStore} from "stores/Photos";
import {useQuasar} from "quasar";
const props = defineProps({ const props = defineProps({
galleryId: { galleryId: {
@@ -21,9 +22,8 @@ const props = defineProps({
} }
}) })
const route = useRoute();
const $router = useRouter(); const $router = useRouter();
const $q = useQuasar();
let $lightbox = ref(null); let $lightbox = ref(null);
@@ -46,20 +46,6 @@ const initLightbox = () => {
$lightbox.value.init(); $lightbox.value.init();
} }
onMounted(() => {
initLightbox();
// todo: tohle nefunguje, sice se galerie zavre, ale i tak se user presmeruje na predchozi stranku
window.onpopstate = function (e) {
if (!$lightbox.value || !$lightbox.value.pswp) {
initLightbox();
}
if ($lightbox.value && $lightbox.value.pswp)
$lightbox.value.pswp.close();
};
});
onUnmounted(() => { onUnmounted(() => {
if ($lightbox.value) { if ($lightbox.value) {
$lightbox.value.destroy(); $lightbox.value.destroy();
@@ -69,38 +55,82 @@ onUnmounted(() => {
const openedPhoto = computed(() => usePhotosStore().openedPhoto); const openedPhoto = computed(() => usePhotosStore().openedPhoto);
watch(() => [openedPhoto.value, $lightbox.value], () => { watch(() => [openedPhoto.value, $lightbox.value], () => {
if (!$lightbox.value || !openedPhoto.value) return; if (!openedPhoto.value) return;
if (!$lightbox.value) {
initLightbox();
}
const idx = props.photos.findIndex(p => p.id === openedPhoto.value.id); const idx = props.photos.findIndex(p => p.id === openedPhoto.value.id);
if (idx >= 0) { if (idx >= 0) {
$lightbox.value.loadAndOpen(idx); $lightbox.value.loadAndOpen(idx);
usePhotosStore().setOpenedPhoto(null); usePhotosStore().setOpenedPhoto(null);
} }
}, {immediate: true}) }, {immediate: true});
</script> </script>
<template> <template>
<div :id="galleryId" class="row"> <div :class="{'photogallery--wrapper': true}" :id="`${galleryId}-wrapper`">
<div class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" v-for="photo in photos" :key="photo.id"> <div :id="galleryId" class="row photogallery--content">
<a <div
:href="photo.url" v-for="photo in photos" :key="photo.id"
:data-pswp-width="photo.width" style="min-height: 100px"
:data-pswp-height="photo.height" class="col-6 col-sm-4 col-md-4 col-lg-2 q-pa-sm"
target="_blank"
rel="noreferrer"
> >
<q-img <a
spinner-color="primary" :data-pswp-src="photo.url"
class="rounded-borders" :data-pswp-width="photo.width"
:src="photo.thumbnailUrl" :data-pswp-height="photo.height"
alt="" target="_blank"
@click="initLightbox"
rel="noreferrer"
> >
<div class="absolute-bottom" v-if="photo.name !== ''"> <q-img
{{ photo.name }} spinner-color="primary"
</div> class="rounded-borders"
</q-img> :src="photo.thumbnailUrl"
<PhotoCaption :photo="photo"/> alt=""
</a> >
<div class="absolute-bottom" v-if="photo.name !== ''">
{{ photo.name }}
</div>
</q-img>
<PhotoCaption :photo="photo"/>
</a>
</div>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss">
.photogallery--wrapper {
margin: 50px 0;
a:hover {
cursor: pointer
}
&.collapsed {
position: relative;
.photogallery--content {
max-height: 250px;
overflow: hidden
}
.photogallery--collapse {
display: block;
position: absolute;
width: 100%;
bottom: 0;
left: 0;
right: 0;
text-align: center;
background: rgb(255, 255, 255);
background: linear-gradient(0deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 1) 35%, rgba(255, 255, 255, 0) 100%);
}
}
}
</style>
+14 -90
View File
@@ -2,6 +2,7 @@
import {computed, onMounted, reactive, ref, watch} from "vue"; import {computed, onMounted, reactive, ref, watch} from "vue";
import {useQuasar} from "quasar"; import {useQuasar} from "quasar";
import {confirmObject} from "src/composables/dialog"; import {confirmObject} from "src/composables/dialog";
import {usePhotoBatchManagement} from "src/composables/photos/batch";
const props = defineProps({ const props = defineProps({
photos: { photos: {
@@ -19,100 +20,23 @@ const props = defineProps({
const $q = useQuasar(); const $q = useQuasar();
const batchEdit = ref({});
for (const photo of props.photos) {
batchEdit.value[photo.id] = false;
}
const emits = defineEmits(["edit", "delete", "set-title-photo", "batch-edit", "batch-rotate"]) const emits = defineEmits(["edit", "delete", "set-title-photo", "batch-edit", "batch-rotate"])
const openEdit = (photo) => { const openEdit = (photo) => {
emits("edit", photo); emits("edit", photo);
}; };
const confirmPhotoDelete = (id) => { const confirmPhotoDelete = (id) => {
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?")) $q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
.onOk(() => { .onOk(() => {
emits("delete", id); emits("delete", id);
}); });
} }
const confirmBatchPhotoDelete = () => { const {
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?")) openBatchEdit, batchEdit, handleImageClick, confirmBatchPhotoDelete, batchOptionsProps,
.onOk(() => { isBatchEditAllowed, batchRotate, checkAll
for (const id in batchEdit.value) { } = usePhotoBatchManagement({photos: props.photos, emits});
if (!batchEdit.value[id]) continue;
emits("delete", parseInt(id));
batchEdit.value[id] = false;
}
});
}
const handleImageClick = (photo, idx) => {
batchEdit.value[photo.id] = !batchEdit.value[photo.id];
}
const selectedPhotos = computed(() => {
const photos = [];
for (const i in batchEdit.value) {
const photo = props.photos.find(p => p.id === parseInt(i));
if (photo && batchEdit.value[i]) photos.push(photo);
}
return photos;
});
const openBatchEdit = () => {
const photos = selectedPhotos.value;
for (const i in batchEdit.value) {
batchEdit.value[i] = false;
}
emits('batch-edit', photos);
}
const batchRotate = (direction) => {
const photos = selectedPhotos.value;
emits("batch-rotate", {photos, direction});
}
const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0);
const checkAll = ref(false);
watch(() => checkAll.value, (newVal) => {
if (newVal === null) return;
for (const photo of props.photos) {
batchEdit.value[photo.id] = newVal;
}
});
watch(() => batchEdit.value, () => {
let checkedAll = true;
let uncheckedAll = true;
for (const i of Object.keys(batchEdit.value)) {
if (typeof batchEdit.value[i] === 'undefined') continue;
if (batchEdit.value[i]) uncheckedAll = false;
if (!batchEdit.value[i]) checkedAll = false;
}
if (checkedAll) {
checkAll.value = true;
} else if (uncheckedAll) {
checkAll.value = false;
} else {
checkAll.value = null;
}
}, {deep: true});
const batchOptionsProps = computed(() => ({
round: true,
flat: true,
class: "q-ml-lg",
disable: !isBatchEditAllowed.value
}));
const setFlightCover = (titlePhoto) => { const setFlightCover = (titlePhoto) => {
emits("set-title-photo", titlePhoto); emits("set-title-photo", titlePhoto);
@@ -136,10 +60,10 @@ const showLarge = (displayedPhoto) => {
<q-btn icon="fullscreen" color="grey-6" round @click.stop.prevent="showLarge(photo)"/> <q-btn icon="fullscreen" color="grey-6" round @click.stop.prevent="showLarge(photo)"/>
</div> </div>
<q-btn <q-btn
icon="star" icon="star"
:color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'" :color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'"
round round
@click.stop.prevent="setFlightCover(photo)" @click.stop.prevent="setFlightCover(photo)"
/> />
</div> </div>
@@ -162,7 +86,7 @@ const showLarge = (displayedPhoto) => {
</div> </div>
<q-btn flat icon="edit" color="blue" @click="openEdit(photo)"/> <q-btn flat icon="edit" color="blue" @click="openEdit(photo)"/>
<q-btn flat icon="tune" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/> <q-btn flat icon="tune" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/>
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"/> <q-btn flat icon="delete" color="red" :disabled="titlePhoto?.id === photo.id" @click="confirmPhotoDelete(photo.id)"/>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</div> </div>
@@ -183,7 +107,7 @@ const showLarge = (displayedPhoto) => {
<q-dialog v-model="openPhoto" full-width> <q-dialog v-model="openPhoto" full-width>
<q-img :src="photo.url"> <q-img :src="photo.url">
<div class="absolute-top bg-transparent text-right"> <div class="absolute-top bg-transparent text-right">
<q-btn icon="close" round color="primary" @click="openPhoto = false" /> <q-btn icon="close" round color="primary" @click="openPhoto = false"/>
</div> </div>
</q-img> </q-img>
</q-dialog> </q-dialog>
@@ -0,0 +1,63 @@
<script setup>
import {useQuasar} from "quasar";
import {confirmObject} from "src/composables/dialog";
import {usePhotoBatchManagement} from "src/composables/photos/batch";
const props = defineProps({
photos: {
type: Array
},
titlePhoto: {
type: Object,
},
showActions: {
type: Boolean,
required: false,
default: true
}
});
const $q = useQuasar();
const emits = defineEmits(["delete", "batch-edit", "batch-rotate"])
const confirmPhotoDelete = (id) => {
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
.onOk(() => {
emits("delete", id);
});
}
const {
batchEdit, handleImageClick, confirmBatchPhotoDelete, batchOptionsProps, isBatchEditAllowed, batchRotate, checkAll
} = usePhotoBatchManagement({photos: props.photos, emits});
</script>
<template>
<div class="row" v-bind="$attrs">
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id">
<q-card :class='{"q-ma-sm": true, "shadow-1": !batchEdit[photo.id], "shadow-10": batchEdit[photo.id]}'>
<q-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)" style="cursor: pointer"/>
<q-card-actions align="right" :class="{'bg-primary': batchEdit[photo.id], 'text-white': batchEdit[photo.id]}" v-if="showActions">
<div class="col-grow">
<q-checkbox v-model="batchEdit[photo.id]"/>
</div>
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"/>
</q-card-actions>
</q-card>
</div>
</div>
<q-page-sticky :offset="[50, 50]" position="bottom" v-if="photos.length > 0 && showActions">
<q-card>
<q-card-actions>
<q-checkbox v-model="checkAll" label="Vybrat vše"/>
<q-btn v-bind="batchOptionsProps" icon="rotate_90_degrees_ccw" :color="isBatchEditAllowed ? 'secondary' : 'grey'" @click="batchRotate('counterClockwise')"/>
<q-btn v-bind="batchOptionsProps" icon="rotate_90_degrees_cw" :color="isBatchEditAllowed ? 'secondary' : 'grey'" @click="batchRotate('clockwise')"/>
<q-btn v-bind="batchOptionsProps" icon="delete" :color="isBatchEditAllowed ? 'red' : 'grey'" @click="confirmBatchPhotoDelete"/>
</q-card-actions>
</q-card>
</q-page-sticky>
</template>
+11 -11
View File
@@ -74,8 +74,18 @@ const handlePositionNotFound = () => {
<template #append v-if="showResetMapButton"> <template #append v-if="showResetMapButton">
<q-btn @click="resetMap" icon="gps_fixed" round flat text-color="green" title="Nastav pozici na mapě"/> <q-btn @click="resetMap" icon="gps_fixed" round flat text-color="green" title="Nastav pozici na mapě"/>
</template> </template>
<template #after>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
</q-input> </q-input>
<MapGPSInput
:label="poiNameForMap"
:zoom="zoom"
v-model:gpsLatitude="formData.gpsLatitude"
v-model:gpsLongitude="formData.gpsLongitude"
@not-found="handlePositionNotFound"
/>
<SlugInput <SlugInput
v-if="formData.isPublic" v-if="formData.isPublic"
v-bind="commonProps" v-bind="commonProps"
@@ -85,21 +95,11 @@ const handlePositionNotFound = () => {
:slug-source="formData.name" :slug-source="formData.name"
:rules="rules.urlSlug" :rules="rules.urlSlug"
/> />
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
<MapGPSInput
v-if="poiNameForMap"
:key="poiNameForMap"
:label="poiNameForMap"
:zoom="zoom"
v-model:gpsLatitude="formData.gpsLatitude"
v-model:gpsLongitude="formData.gpsLongitude"
@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"/>
</FormWrapper> </FormWrapper>
</template> </template>
+31 -46
View File
@@ -1,7 +1,5 @@
<script setup> <script setup>
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue"; import {useInfiniteScroll} from "src/composables/pagination";
import LoadMoreList from "components/LoadMoreList.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -12,20 +10,7 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true required: true
}, },
totalItemsCount: { ...useInfiniteScroll().propsDef,
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
}); });
const getRoute = (item) => { const getRoute = (item) => {
@@ -34,35 +19,35 @@ const getRoute = (item) => {
else else
return {name: "poiDetail", params: {id: item.id}} return {name: "poiDetail", params: {id: item.id}}
}; };
const emits = defineEmits(useInfiniteScroll().emitsDef)
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script> </script>
<template> <template>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length"> <q-infinite-scroll
<template #items> :disable="!hasMoreData"
<q-list bordered separator> @load="emitLoadMore"
<q-item clickable v-ripple v-for="poi of items" :key="poi" :to="getRoute(poi)"> :offset="250"
<q-item-section avatar> >
<q-avatar v-if="poi.titlePhoto"> <q-list bordered separator>
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/> <q-item clickable v-for="poi of items" :key="poi" :to="getRoute(poi)">
</q-avatar> <q-item-section avatar>
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/> <q-avatar v-if="poi.titlePhoto">
</q-item-section> <img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
<q-item-section> </q-avatar>
{{ poi.name }} <q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
<div class="text-secondary" v-if="poi.type"> </q-item-section>
{{ poi.type.name }} <q-item-section>
</div> {{ poi.name }}
</q-item-section> <div class="text-secondary" v-if="poi.type">
<q-item-section side top v-if="!props.public && poi.isPublic"> {{ poi.type.name }}
<q-icon name="visibility"></q-icon> </div>
</q-item-section> </q-item-section>
</q-item> <q-item-section side top v-if="!props.public && poi.isPublic">
</q-list> <q-icon name="visibility"></q-icon>
</template> </q-item-section>
<template #skeleton> </q-item>
<BasicListSkeleton :items="itemsPerPage"/> </q-list>
</template> </q-infinite-scroll>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
+2 -3
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import {ref} from "vue"; import {ref} from "vue";
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue"; import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
import {useUnitsConvertors} from "src/composables/units";
const props = defineProps({ const props = defineProps({
weather: { weather: {
@@ -9,9 +10,7 @@ const props = defineProps({
} }
}) })
const kmhToKnots = (speedKmh) => { const {kmhToKnots} = useUnitsConvertors();
return Math.round(speedKmh * 0.53996);
}
const openDetail = ref(false); const openDetail = ref(false);
</script> </script>
+38
View File
@@ -0,0 +1,38 @@
export const useAirspaceColors = () => {
const colors = {
TMA: "#800080",
CTR: "#366e36",
PROHIBIT: "#FF0000",
RMZ: "#FFdd00",
RESTRICT: "#ff6a00",
DANGER: "#FFA500",
TRAGA: "#008000",
ATZ: "#4e7cfc",
FIR: '#ffffff',
PARA: "#000000"
}
const getColorByType = (airspaceType) => {
return colors[airspaceType] || '#ff00ff';
}
return {colors, getColorByType}
}
export const useAltitudeUtils = () => {
const altitudeToFt = (limit, unit) => {
if (unit === 'FL') return parseInt(limit) * 100;
return parseInt(limit);
}
const altitudeLimitFormatted = (limit, unit) => {
if (parseInt(limit) === 0) return 'GND';
else if (unit === 'FL') return `FL${limit}`;
else {
if (parseInt(limit) >= 5000) return `FL${limit / 100}`;
return `${limit} ft`
}
}
return {altitudeLimitFormatted, altitudeToFt}
}
+73
View File
@@ -0,0 +1,73 @@
import {flightName} from "src/composables/flight";
export const homepageBreadcrumbs = () => {
return [{to: {name: 'homepage'}, label: "Polétání.cz"}];
}
export const publicProfileBreadcrumbs = (pilot, link = true) => {
const profile = {label: `@${pilot.publicUsername}`}
if (link) {
profile.to = {name: "publicProfileDetail", params: {username: pilot.publicUsername}};
}
return [
...homepageBreadcrumbs(),
profile,
]
}
export const publicEventBreadcrumbs = (pilot, event, link = true) => {
const eventDetail = {label: event.name}
if (link) {
eventDetail.to = {
name: 'publicEventDetail',
params: {eventSlug: event.urlSlug, username: pilot.publicUsername}
};
}
return [
...publicProfileBreadcrumbs(pilot),
{
to: {name: 'publicEventsList', username: pilot.publicUsername},
label: "Události"
},
eventDetail
]
}
export const publicFlightBreadcrumbs = (pilot, flight, event, link = true) => {
let breadcrumbs;
if (event && event.isPublic) {
breadcrumbs = publicEventBreadcrumbs(pilot, event)
} else {
breadcrumbs = publicProfileBreadcrumbs(pilot)
}
const flightEntry = {label: flightName(flight)}
if (link) {
flightEntry.to = {name: "publicFlightDetail", params: {username: pilot.publicUsername, flightSlug: flight.urlSlug}}
}
breadcrumbs.push(flightEntry)
return breadcrumbs;
}
export const publicCopilotBreadcrumbs = (pilot, flight, event, copilot) => {
return [
...publicProfileBreadcrumbs(pilot),
{label: "Kopiloti"},
{label: copilot.name}
]
}
export const publicPOIBaseBreadcrumbs = () => {
return [
...homepageBreadcrumbs(),
{
to: {name: 'publicPOIsList'},
label: "Místa"
},
]
}
-28
View File
@@ -14,34 +14,6 @@ export const minutesToHumanReadable = (totalMinutes) => {
return `${hoursPart}${minutes}min` return `${hoursPart}${minutes}min`
} }
export const formatDatetimeNative = (date, format = "DD. MM. YYYY") => {
if (typeof date === 'string') {
date = parseDate(date);
}
return format
.replace("DD", date.getDate().toString())
.replace("MM", (date.getMonth() + 1).toString())
.replace("YYYY", date.getFullYear().toString())
// todo: doplnit pocatecni nuly
.replace("hh", date.getHours().toString())
.replace("mm", date.getMinutes().toString())
.replace("ss", date.getSeconds().toString());
}
export const parseDateNative = (datetime) => {
// const [date, time] = datetime.split("T");
// const [year, month, day] = date.split("-");
// const [hour, minute] = time.split(":");
// const dateObject = new Date(year, month, day, hour, minute, 0);
// ^ tohle funguje ok
const timestamp = Date.parse(datetime);
return new Date(timestamp);
}
export const parseDate = (datetimeStr, format = FORMAT_ISO) => { export const parseDate = (datetimeStr, format = FORMAT_ISO) => {
return date.extractDate(datetimeStr, format) return date.extractDate(datetimeStr, format)
} }
+1 -1
View File
@@ -5,7 +5,7 @@ export const flightName = (flight) => {
if (flight.name !== '') { if (flight.name !== '') {
return `${flight.name} (${date})` return `${flight.name} (${date})`
} else { } else {
const track = flight.track ? flight.track.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : []; const track = flight.turnPoints ? flight.turnPoints.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})`;
+109
View File
@@ -0,0 +1,109 @@
export const useFlightPlanUtils = () => {
const getIcon = (type) => {
const icons = {
"poi": "pin_drop",
"geocode": "travel_explore",
"airport": "local_airport"
};
return icons[type] || "place"
}
const getMarkersFrom = (flightPlan) => {
const segments = flightPlan.track.length;
const markers = flightPlan.track.map(m => m.from);
if (segments > 0 && flightPlan.track[segments - 1].to) {
markers.push(flightPlan.track[segments - 1].to);
}
return markers;
}
return {getIcon, getMarkersFrom}
}
export const useFlightPlanCalculations = () => {
const getDuration = (distance, speed) => Math.round(distance / speed * 60)
const getTotalDistance = (flightPlan) => {
if (!flightPlan.track.length) return null;
return Math.round(flightPlan.track.map(t => t.distance).reduce((a, b) => a + b));
}
const getTotalDuration = (flightPlan, plannedSpeed = undefined) => {
if (!plannedSpeed) plannedSpeed = flightPlan.plannedSpeed;
if (!flightPlan.track.length) return null;
return flightPlan.track.map(t => getDuration(t.distance, plannedSpeed)).reduce((a, b) => a + b)
}
return {getDuration, getTotalDistance, getTotalDuration}
}
export const useFlightPlanObjects = () => {
const editableNameTypes = ["geocode", "mapPoint"];
const markerObject = ({name, type, airportId, pointOfInterestId, gpsLatitude, gpsLongitude}) => ({
name, type, gpsLatitude, gpsLongitude, pointOfInterestId, airportId
});
const googleMapsCustomMarkerObject = (marker, overrideAnchorPoint = undefined) => {
const anchorPoints = {
airport: "CENTER",
geocode: "BOTTOM_CENTER",
poi: "BOTTOM_CENTER",
mapPoint: "BOTTOM_CENTER",
}
return {
anchorPoint: overrideAnchorPoint || anchorPoints[marker.type],
position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}
}
}
const googleMapsMarkerObject = (marker) => {
return {
label: marker.name,
draggable: editableNameTypes.includes(marker.type),
position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}
}
}
const infoWindowObject = ({active, position, marker, poi, airport, reportingPoint, airspaces, isInTrack}) => {
if (!position) position = {};
if (!position.lat) position.lat = 0;
if (!position.lng) position.lng = 0;
let title = null;
if (airport) {
if (!reportingPoint) {
title = `${airport.icaoCode} (${airport.name})`;
position.lat = airport.gpsLatitude;
position.lng = airport.gpsLongitude;
} else {
title = `${airport.icaoCode}:${reportingPoint.name}`
position.lat = reportingPoint.gpsLatitude;
position.lng = reportingPoint.gpsLongitude;
}
}
if (marker) {
title = marker.name;
position.lat = marker.gpsLatitude;
position.lng = marker.gpsLongitude;
}
if (poi) {
title = poi.name;
position.lat = poi.gpsLatitude;
position.lng = poi.gpsLongitude;
}
return {title, active, position, airspaces, poi, marker, airport, reportingPoint, isInTrack};
};
return {markerObject, googleMapsMarkerObject, googleMapsCustomMarkerObject, infoWindowObject, editableNameTypes}
}
+74
View File
@@ -0,0 +1,74 @@
import {ref} from "vue";
export const useGeocode = () => {
const center = ref({lat: 50.1314, lng: 14.5256});
const getGeocoder = (limitMs = 5000) => {
let interval = null
const poolingWindowMs = 100
const tries = limitMs / poolingWindowMs
let currentTry = 0
return new Promise((resolve, reject) => {
let instance = null
interval = setInterval(() => {
try {
instance = new google.maps.Geocoder()
clearInterval(interval)
return resolve(instance)
} catch (e) {
if (++currentTry >= tries) {
clearInterval(interval)
return reject(instance)
}
}
}, poolingWindowMs)
})
}
const searchGeocode = (address) => new Promise((resolve, reject) => {
getGeocoder().then(geocoder => geocoder
.geocode({address})
.then(({results}) => resolve(results))
.catch((e) => reject(e))).catch(() => {
console.log("Cannot get geocoder")
})
})
const searchPlaceName = (lat, lng) => {
const latlng = {
lat: parseFloat(lat), lng: parseFloat(lng),
};
return new Promise((resolve, reject) => {
getGeocoder().then((geocoder) => geocoder
.geocode({location: latlng})
.then((response) => {
const results = response.results;
const components = {};
for (const addressComponent of results[0].address_components) {
for (const namePartType of ["locality", "postal_town", 'administrative_area_level_2']) {
if (addressComponent.types.includes(namePartType)) {
components[namePartType] = addressComponent.long_name
}
}
}
if (!Object.keys(components).length) {
return resolve("Bod na mapě");
}
return resolve(Object.values(components).join(", "));
})
.catch((e) => reject(e))
)
})
}
return {center, searchGeocode, searchPlaceName}
}
+10 -1
View File
@@ -13,9 +13,18 @@ export const commonKeywords = [
export const aircraftKeywords = (aircraft) => { export const aircraftKeywords = (aircraft) => {
return [aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model] return ["letadlo", "letoun", aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
}; };
export const flightEventKeywords = (event) => {
if (!event) return [];
return [event.name];
}
export const flightKeywords = (flight) => {
return ["let", "výlet", "letadlo"]
}
export const metaData = ({title, description, keywords, image, index = true}) => { export const metaData = ({title, description, keywords, image, index = true}) => {
const filteredKeywords = (keywords || []).filter(kw => !!kw); const filteredKeywords = (keywords || []).filter(kw => !!kw);
const filteredDescription = stripHtml(description); const filteredDescription = stripHtml(description);
+75 -13
View File
@@ -1,18 +1,80 @@
import {ref} from "vue"; import {computed, ref} from "vue";
export const paginationLimit = ref(10); export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
export const paginationOffset = ref(0); if (typeof fetchFunction === "undefined") {
console.error("Set fetchFunction to usePagination!");
}
export const filters = ref({}); const paginationLimit = ref(itemsPerPage);
const paginationOffset = ref(0);
const filters = ref({});
const hasMoreData = ref(true)
const isQueryRunning = ref(false)
export const loadPage = ({fetchFunc, appendItems}) => {
return fetchFunc({ const loadFirstPage = () => {
...filters.value, paginationOffset.value = 0
limit: paginationLimit.value, isQueryRunning.value = true
offset: paginationOffset.value, return fetchFunction({
appendItems ...filters.value, limit: paginationLimit.value, offset: 0, appendItems: false
}).then(() => { }).then((data) => {
paginationOffset.value += paginationLimit.value; paginationOffset.value += paginationLimit.value;
}) hasMoreData.value = data.length === paginationLimit.value;
})
.finally(() => {
isQueryRunning.value = false
})
}
const loadPage = (index, done) => {
if (isQueryRunning.value) return
isQueryRunning.value = true
return fetchFunction({
...filters.value, appendItems: true, limit: paginationLimit.value, offset: paginationOffset.value,
}).then((data) => {
paginationOffset.value += paginationLimit.value;
hasMoreData.value = data.length === paginationLimit.value;
if (done) done()
})
.finally(() => {
isQueryRunning.value = false
})
}
const handleFiltersChanged = (newFilters) => {
filters.value = newFilters;
loadFirstPage()
}
return {
paginationOffset, paginationLimit, handleFiltersChanged, filters, loadPage, loadFirstPage, hasMoreData
}
} }
export const useLoading = (promiseGetter) => {
const loading = ref(false)
const load = () => {
loading.value = true
return promiseGetter().finally(() => loading.value = false)
}
return {loading, load}
}
export const useInfiniteScroll = (emits, props) => {
const emitsDef = ["load-more"]
const propsDef = {
hasMoreData: {
type: Boolean, required: false, default: true
}
}
const hasMoreData = computed(() => props.hasMoreData)
const emitLoadMore = (index, done) => emits("load-more", {index, done});
return {emitsDef, propsDef, hasMoreData, emitLoadMore}
}
+89
View File
@@ -0,0 +1,89 @@
import {ref} from "vue";
import {usePhotosStore} from "stores/Photos";
import {errorObject, successObject, warningObject} from "src/composables/dialog";
import {useQuasar} from "quasar";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {storeToRefs} from "pinia";
import {useFlightsStore} from "stores/Flights";
export const useBasicPhotoOperations = ({copilotUploadToken}) => {
const $q = useQuasar();
const {flight} = storeToRefs(useFlightsStore());
const {photos} = storeToRefs(usePhotosStore());
// TODO: V PRIVATNI CASTI JE TO ZATIM ROZBITE!!!
const uploadInProgress = ref(false);
const handlePhotosUpload = (data) => {
const promises = [];
uploadInProgress.value = true;
for (const photo of data.photos) {
promises.push(usePhotosStore().upload(flight.value.id, {photo, copilotUploadToken}).then((data) => {
photos.value.push(data);
photo._uploaded = true;
}));
}
Promise.allSettled(promises).then((values) => {
const success = values.filter(v => v.status === 'fulfilled');
const failed = values.filter(v => v.status === 'rejected'); // TODO: nebo vse - success?
if (success.length > 0)
$q.notify(successObject(`Úspěšně nahráno ${success.length} fotek`));
if (failed.length > 0)
$q.notify(warningObject(`Nepovedlo se nahrát ${failed.length} fotek. Zde jsou důvody: ${failed.map(f => f.reason)}`));
data.photos = [];
}).finally(() => uploadInProgress.value = false);
}
const _findPhotoIndex = (photoId) => photos.value.findIndex((object) => object.id === photoId);
const deletePhoto = (id) => {
usePhotosStore().destroy(id, copilotUploadToken).then((data) => {
photos.value.splice(_findPhotoIndex(data.id), 1);
});
}
const batchRotatePhotos = (data) => {
const selectedPhotos = data.photos;
const direction = data.direction;
const promises = [];
for (const photo of selectedPhotos) {
promises.push(usePhotosStore().changeOrientation(photo.id, direction, copilotUploadToken).then(data => {
const idx = _findPhotoIndex(data.id);
data.thumbnailUrl += "?t=" + (new Date()).getTime();
photos.value[idx] = data;
}));
}
let success = 0;
Promise.allSettled(promises).then(result => {
for (const item of result) {
if (item.status === 'fulfilled') success++;
else console.error(item.reason)
}
const error = result.length - success;
if (success > 0) $q.notify(successObject("Otočeno " + success + " fotografií"));
if (error > 0) $q.notify(errorObject(`Nepovedlo se otočit ${error} fotografi`))
});
}
const updateSinglePhoto = (id, formData) => {
formData.copilots = formData.copilots.map(c => ({id: c.id, name: c.name}));
return usePhotosStore().update(id, formData).then((data) => {
const idx = _findPhotoIndex(data.id);
photos.value[idx] = data;
usePointsOfInterestStore().fetch({});
});
}
return {
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
}
}
+96
View File
@@ -0,0 +1,96 @@
import {computed, ref, watch} from "vue";
import {confirmObject} from "src/composables/dialog";
import {useQuasar} from "quasar";
export const usePhotoBatchManagement = ({photos, emits}) => {
const batchEdit = ref({});
const $q = useQuasar();
// console.log(photos);
for (const photo of photos) {
batchEdit.value[photo.id] = false;
}
const handleImageClick = (photo, idx) => {
batchEdit.value[photo.id] = !batchEdit.value[photo.id];
}
const selectedPhotos = computed(() => {
const selected = [];
for (const i in batchEdit.value) {
const photo = photos.find(p => p.id === parseInt(i));
if (photo && batchEdit.value[i]) selected.push(photo);
}
return selected;
});
const confirmBatchPhotoDelete = () => {
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?"))
.onOk(() => {
for (const id in batchEdit.value) {
if (!batchEdit.value[id]) continue;
emits("delete", parseInt(id));
batchEdit.value[id] = false;
}
});
}
const openBatchEdit = () => {
const photos = selectedPhotos.value;
for (const i in batchEdit.value) {
batchEdit.value[i] = false;
}
emits('batch-edit', photos);
}
const batchRotate = (direction) => {
const photos = selectedPhotos.value;
emits("batch-rotate", {photos, direction});
}
const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0);
const checkAll = ref(false);
watch(() => checkAll.value, (newVal) => {
if (newVal === null) return;
for (const photo of photos) {
batchEdit.value[photo.id] = newVal;
}
});
const batchOptionsProps = computed(() => ({
round: true,
flat: true,
class: "q-ml-lg",
disable: !isBatchEditAllowed.value
}));
watch(() => batchEdit.value, () => {
let checkedAll = true;
let uncheckedAll = true;
for (const i of Object.keys(batchEdit.value)) {
if (typeof batchEdit.value[i] === 'undefined') continue;
if (batchEdit.value[i]) uncheckedAll = false;
if (!batchEdit.value[i]) checkedAll = false;
}
if (checkedAll) {
checkAll.value = true;
} else if (uncheckedAll) {
checkAll.value = false;
} else {
checkAll.value = null;
}
}, {deep: true});
return {
batchEdit, handleImageClick, selectedPhotos, confirmBatchPhotoDelete,
openBatchEdit, batchRotate, checkAll, isBatchEditAllowed, batchOptionsProps
}
};
+12 -2
View File
@@ -1,3 +1,13 @@
export const ktToKph = (speedInKt) => { export const useUnitsConvertors = () => {
return speedInKt;
const kmhToKnots = (speedKmh) => {
return Math.round(speedKmh * 0.53996);
}
const ftToM = (distanceInFt) => {
return Math.round(distanceInFt * 0.3048 * 10) / 10;
}
return {kmhToKnots, ftToM}
} }
+21
View File
@@ -2,3 +2,24 @@ export const roundWithPrecision = (number, precision) => {
const factor = Math.pow(10, precision); const factor = Math.pow(10, precision);
return Math.round(number * factor) / factor; return Math.round(number * factor) / factor;
} }
export const pluralize = (count, singular, plural, pluralMore) => {
if (count === 1) return singular;
else if (count < 5) return plural;
else return pluralMore;
}
export const textEllipsis = (text, maxLength) => {
if (text.length <= maxLength) {
return text;
}
let words = text.split(' ');
const maxWords = 2;
if (words.length <= maxWords + 1) {
return text;
}
return words[0] + ' ... ' + words[words.length - 1];
}
+1
View File
@@ -9,6 +9,7 @@ fragment aircraftListInfo on Aircraft {
model model
manufacturer manufacturer
isPublic isPublic
seats
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
+8 -8
View File
@@ -1,14 +1,14 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
export const FRAGMENT_AIRCRAFT_BASIC_INFO = gql` export const FRAGMENT_AIRCRAFT_BASIC_INFO = gql`
fragment aircraftBasicInfo on Aircraft { fragment aircraftBasicInfo on Aircraft {
id
callSign
manufacturer
organization {
id id
name callSign
manufacturer
organization {
id
name
}
} }
}
`; `;
+31
View File
@@ -1,5 +1,17 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
export const FRAGMENT_AIRPORT_LIST_INFO = gql`
fragment airportListInfo on Airport {
id
name
icaoCode
gpsLatitude
gpsLongitude
elevation
iconUrl
}
`;
export const FRAGMENT_AIRPORT_INFO = gql` export const FRAGMENT_AIRPORT_INFO = gql`
fragment airportInfo on Airport { fragment airportInfo on Airport {
id id
@@ -7,5 +19,24 @@ export const FRAGMENT_AIRPORT_INFO = gql`
icaoCode icaoCode
gpsLatitude gpsLatitude
gpsLongitude gpsLongitude
elevation
iconUrl
reportingPoints {
id
gpsLatitude
gpsLongitude
name
}
frequencies {
frequency
name
}
runways {
designator
heading
surface
length
width
}
} }
`; `;
+10 -2
View File
@@ -11,18 +11,26 @@ fragment copilotListInfo on Copilot {
} }
`; `;
export const FRAGMENT_COPILOT_FULL_INFO = gql` export const FRAGMENT_COPILOT_PUBLIC_INFO = gql`
fragment copilotFullInfo on Copilot { fragment copilotPublicInfo on Copilot {
id id
name name
isPublic isPublic
urlSlug urlSlug
}
`
export const FRAGMENT_COPILOT_FULL_INFO = gql`
fragment copilotFullInfo on Copilot {
...copilotPublicInfo
titlePhoto { titlePhoto {
id id
url url
} }
} }
${FRAGMENT_COPILOT_PUBLIC_INFO}
`; `;
export const FRAGMENT_COPILOT_FULL_PRIVATE_INFO = gql` export const FRAGMENT_COPILOT_FULL_PRIVATE_INFO = gql`
+28 -17
View File
@@ -3,10 +3,11 @@ import {FRAGMENT_AIRCRAFT_FULL_INFO} from "src/graphql/fragments/aircraft";
import {FRAGMENT_AIRPORT_INFO} from "src/graphql/fragments/airport"; import {FRAGMENT_AIRPORT_INFO} from "src/graphql/fragments/airport";
import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo"; import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
import {FRAGMENT_POI_FULL_INFO} from "src/graphql/fragments/poi"; import {FRAGMENT_POI_FULL_INFO} from "src/graphql/fragments/poi";
import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
export const FRAGMENT_WEATHER_FULL_INFO = gql` export const FRAGMENT_WEATHER_FULL_INFO = gql`
fragment weatherFullInfo on WeatherInfo { fragment weatherFullInfo on WeatherInfo {
datetime dateTime
qnh qnh
temperatureSurface temperatureSurface
dewpointSurface dewpointSurface
@@ -28,7 +29,8 @@ fragment flightFullInfo on Flight {
landings landings
durationMinCalculated durationMinCalculated
isPublic isPublic
flightPlanId
socialImageUrl
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
url url
@@ -51,35 +53,37 @@ fragment flightFullInfo on Flight {
...weatherFullInfo ...weatherFullInfo
} }
copilots {
id
name
isPublic
urlSlug
}
event { event {
id id
isPublic
urlSlug
name name
} }
gpxTrack { track {
coordinates { points {
coordinates {
lat
lng
}
timestamp
magneticVariation
speed
altitude
terrainElevation
}
mapBounds {
lat lat
lng lng
} }
time
magneticVariation
speed
maxSpeed maxSpeed
avgSpeed avgSpeed
altitude
terrainElevation
maxAltitude maxAltitude
avgAltitude avgAltitude
} }
track { turnPoints {
pointOfInterest { pointOfInterest {
...pointOfInterestFullInfo ...pointOfInterestFullInfo
} }
@@ -103,8 +107,14 @@ ${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
flightPlanId
debriefingNotes debriefingNotes
copilots {
...copilotPublicInfo
token
}
photos { photos {
...photoFullInfo ...photoFullInfo
@@ -122,4 +132,5 @@ fragment flightFullPrivateInfo on Flight {
${FRAGMENT_FLIGHT_FULL_INFO} ${FRAGMENT_FLIGHT_FULL_INFO}
${FRAGMENT_PHOTO_FULL_INFO} ${FRAGMENT_PHOTO_FULL_INFO}
${FRAGMENT_COPILOT_PUBLIC_INFO}
`; `;
+66
View File
@@ -0,0 +1,66 @@
import gql from "graphql-tag";
import {FRAGMENT_WEATHER_FULL_INFO} from "src/graphql/fragments/flight";
export const FRAGMENT_FLIGHT_PLAN_MARKER = gql`
fragment flightPlanMarker on FlightPlanMarker {
name
position
type
gpsLatitude
gpsLongitude
pointOfInterestId
airportId
weatherInfo {
...weatherFullInfo
}
}
${FRAGMENT_WEATHER_FULL_INFO}
`
export const FRAGMENT_FLIGHT_PLAN_FULL_INFO = gql`
fragment flightPlanFullInfo on FlightPlan {
id
name
plannedSpeed
plannedTakeoffDatetime
isDefaultName
isPublic
isEditableByCopilot
description
aircraft {
id
callSign
seats
isPublic
titlePhoto {
thumbnailUrl
}
}
track {
bearing
distance
from {
...flightPlanMarker
}
to {
...flightPlanMarker
}
}
}
${FRAGMENT_FLIGHT_PLAN_MARKER}
`;
export const FRAGMENT_FLIGHT_PLAN_LIST_INFO = gql`
fragment flightPlanListInfo on FlightPlan {
id
name
plannedTakeoffDatetime
aircraft {
callSign
}
}
`;
+5 -5
View File
@@ -1,5 +1,5 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
import {FRAGMENT_AIRPORT_INFO} from "src/graphql/fragments/airport"; import {FRAGMENT_AIRPORT_LIST_INFO} from "src/graphql/fragments/airport";
import {FRAGMENT_AIRCRAFT_BASIC_INFO} from "src/graphql/fragments/aircraft_basic"; import {FRAGMENT_AIRCRAFT_BASIC_INFO} from "src/graphql/fragments/aircraft_basic";
export const FRAGMENT_FLIGHT_LIST_INFO = gql` export const FRAGMENT_FLIGHT_LIST_INFO = gql`
@@ -17,7 +17,7 @@ fragment flightListInfo on Flight {
url url
} }
track { turnPoints {
pointOfInterest { pointOfInterest {
id id
name name
@@ -33,11 +33,11 @@ fragment flightListInfo on Flight {
} }
takeoffAirport { takeoffAirport {
...airportInfo ...airportListInfo
} }
landingAirport { landingAirport {
...airportInfo ...airportListInfo
} }
aircraft { aircraft {
@@ -45,7 +45,7 @@ fragment flightListInfo on Flight {
} }
} }
${FRAGMENT_AIRPORT_INFO} ${FRAGMENT_AIRPORT_LIST_INFO}
${FRAGMENT_AIRCRAFT_BASIC_INFO} ${FRAGMENT_AIRCRAFT_BASIC_INFO}
`; `;
+2
View File
@@ -8,6 +8,8 @@ fragment pointOfInterestListInfo on PointOfInterest {
name name
urlSlug urlSlug
isPublic isPublic
gpsLatitude
gpsLongitude
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
} }
+1
View File
@@ -7,6 +7,7 @@ export const FRAGMENT_USER_FULL_INFO = gql`
email email
avatarImageUrl avatarImageUrl
titleImageUrl titleImageUrl
socialImageUrl
publicUsername publicUsername
description description
createdAt createdAt
+46
View File
@@ -0,0 +1,46 @@
export const useBasicVectorOperations = () => {
const vectorSize = (a) => {
return Math.sqrt(Math.pow(a[0], 2) + Math.pow(a[1], 2));
}
const vector = (pointA, pointB) => {
return [pointA.gpsLatitude - pointB.gpsLatitude, pointA.gpsLongitude - pointB.gpsLongitude];
}
const vectorDistance = (pointA, pointB) => {
const u = [pointA.gpsLatitude || pointA.lat, pointA.gpsLongitude || pointA.lng];
const v = [pointB.gpsLatitude || pointB.lat, pointB.gpsLongitude || pointB.lng];
// const v = vector(pointB);
return Math.sqrt(
Math.pow(u[0] - v[0], 2) + Math.pow(u[1] - v[1], 2)
);
}
return {vector, vectorSize, vectorDistance}
}
export const useVectorUtilities = () => {
function isPointInPolygon(clickPoint, polygonCoords) {
let count = 0;
const x = clickPoint.lng, y = clickPoint.lat;
for (let i = 0; i < polygonCoords.length; i++) {
const iNext = (i + 1) % polygonCoords.length;
const xi = polygonCoords[i].lng, yi = polygonCoords[i].lat;
const xiNext = polygonCoords[iNext].lng, yiNext = polygonCoords[iNext].lat;
const test = ((yi > y) !== (yiNext > y)) && (x < (xiNext - xi) * (y - yi) / (yiNext - yi) + xi);
if (test) count++;
}
return count % 2 === 1; // Lichý počet průsečíků = bod uvnitř
}
const vectorDistanceToKm = (vectorDistance) => {
return vectorDistance * (2*Math.PI * 6371 / 360);
}
return {isPointInPolygon, vectorDistanceToKm}
}
+28
View File
@@ -0,0 +1,28 @@
import gql from "graphql-tag";
import {FRAGMENT_FLIGHT_PLAN_FULL_INFO} from "src/graphql/fragments/flightPlan";
export const CREATE_FLIGHT_PLAN = gql`
mutation createFlightPlan($input: CreateFlightPlanInput!) {
createFlightPlan(input: $input) {
...flightPlanFullInfo
}
}
${FRAGMENT_FLIGHT_PLAN_FULL_INFO}
`;
export const EDIT_FLIGHT_PLAN = gql`
mutation editFlightPlan($id: Int!, $input: EditFlightPlanInput!) {
editFlightPlan(id: $id, input: $input) {
...flightPlanFullInfo
}
}
${FRAGMENT_FLIGHT_PLAN_FULL_INFO}
`;
export const DELETE_FLIGHT_PLAN = gql`
mutation deleteFlightPlan($id: Int!) {
deleteFlightPlan(id: $id) {
id
}
}
`;
+4 -4
View File
@@ -23,8 +23,8 @@ mutation editPhoto($id: Int!, $input: EditPhotoInput!) {
`; `;
export const CHANGE_PHOTO_ORIENTATION = gql` export const CHANGE_PHOTO_ORIENTATION = gql`
mutation changeOrientation($id: Int!, $direction: String!) { mutation changeOrientation($id: Int!, $direction: String!, $copilotUploadToken: String) {
changeOrientation(id: $id, direction: $direction) { changeOrientation(id: $id, direction: $direction, copilotUploadToken: $copilotUploadToken) {
...photoFullInfo ...photoFullInfo
flight { flight {
@@ -67,8 +67,8 @@ mutation adjustPhoto($id: Int!, $adjustment: AdjustmentInput!) {
// TODO: ^^ udelat z toho asi jeden fragment a pouzit i v query GET_PHOTO // TODO: ^^ udelat z toho asi jeden fragment a pouzit i v query GET_PHOTO
export const DELETE_PHOTO = gql` export const DELETE_PHOTO = gql`
mutation deletePhoto($id: Int!) { mutation deletePhoto($id: Int!, $copilotUploadToken: String) {
deletePhoto(id: $id) { deletePhoto(id: $id, copilotUploadToken: $copilotUploadToken) {
id id
} }
} }

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