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
*.sln
.vscode
quasar.config.js.temporary.*
+6 -3
View File
@@ -1,5 +1,5 @@
# develop stage
FROM node:20-alpine as develop-stage
FROM node:22-alpine AS develop-stage
ENV TZ=Europe/Prague
@@ -12,12 +12,15 @@ RUN npx update-browserslist-db@latest
COPY . .
# build stage
FROM develop-stage as build-stage
FROM develop-stage AS build-stage
ENV NODE_ENV production
ENV API_URL "https://api.poletani.cz"
ENV GOOGLE_MAPS_API_KEY "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
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 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;
keepalive_timeout 65;
gzip on;
#gzip on;
include /etc/nginx/conf.d/*.conf;
map $sent_http_content_type $expires {
@@ -32,10 +34,6 @@ http {
text/html 0;
}
map $host $api_url {
default "$API_URL";
}
server {
listen 80 default;
server_name poletani.cz;
@@ -48,9 +46,5 @@ http {
expires $expires;
try_files $uri /index.html;
}
location /sitemap.xml {
try_files $uri $api_url;
}
}
}
-7
View File
@@ -2,13 +2,6 @@ version: "3.4"
services:
poletani-app:
image: "docker.kvacek.cz/poletani/app:latest"
# build:
# context: .
# dockerfile: Dockerfile
# target: "production-stage"
restart: always
ports:
# - 9000:80
- 9000:3000
environment:
API_URL: "https://api.poletani.cz"
+2 -35
View File
@@ -1,30 +1,5 @@
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:
network_mode: host
build:
context: .
target: "develop-stage"
@@ -32,19 +7,11 @@ services:
- "9000:9000"
- "9001:9100"
volumes:
- ./src:/app/src
- ./src-ssr:/app/src-ssr
- ./public:/app/public
# - ./node_modules:/app/node_modules:delegated
- ./:/app
command: quasar dev # -m ssr
environment:
API_URL: "http://127.0.0.1:8000"
# nginx:
# image:
#
#
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
volumes:
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="16x16" href="icons/favicon-16x16.png">
<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>
<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 -->
</body>
</html>
+37 -22
View File
@@ -10,34 +10,49 @@
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore"
},
"dependencies": {
"@apollo/client": "^3.7.14",
"@quasar/extras": "^1.15.11",
"@vue/apollo-composable": "4.0.0-beta.2",
"@vue/apollo-option": "^4.0.0-beta.5",
"apollo-upload-client": "^17.0.0",
"axios": "^1.6.2",
"chart.js": "^4.4.1",
"graphql": "16.6.0",
"@apollo/client": "^4.0.5",
"@quasar/extras": "^1.16.16",
"@sentry/browser": "^10.14.0",
"@sentry/integrations": "^7.114.0",
"@sentry/vue": "^10.14.0",
"@vue/apollo-composable": "4.2.2",
"@vue/apollo-option": "^4.2.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",
"photoswipe": "^5.4.2",
"photoswipe": "^5.4.4",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"pinia": "^2.0.33",
"quasar": "^2.14.1",
"vue": "^3.3.11",
"vue-chartjs": "^5.2.0",
"pinia": "^3.0.3",
"quasar": "2.18.2",
"rxjs": "^7.8.2",
"vue": "3.5.21",
"vue-chartjs": "^5.3.2",
"vue-picture-cropper": "^0.7.0",
"vue-router": "^4.2.5",
"vue3-google-map": "^0.15.0"
"vue-router": "^4.5.0",
"vue3-google-map": "^0.24.1"
},
"devDependencies": {
"@quasar/app-vite": "^1.7.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-vue": "^9.19.2",
"postcss": "^8.4.14",
"prettier": "^2.8.8"
"@quasar/app-vite": "2.4.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.5.0",
"postcss": "^8.5.6",
"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": {
"npm": ">= 6.13.4",
"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
exec_mode: cluster
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
// 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) {
return {
@@ -29,6 +28,12 @@ module.exports = configure(function (ctx) {
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files
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",
"axios",
"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
build: {
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20'
},
env: {
API_URL: ctx.dev ? 'http://localhost:8000' : 'https://api.poletani.cz',
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
API_URL: process.env.API_URL,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY
},
vueRouterMode: 'history', // available values: 'hash', 'history'
@@ -80,8 +85,7 @@ module.exports = configure(function (ctx) {
minify: true,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// extendViteConf(viteConf, {isServer}) {},
// viteVuePluginOptions: {},
// vitePlugins: []
@@ -140,7 +144,8 @@ module.exports = configure(function (ctx) {
ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
extendSSRWebserverConf (esbuildConf) {},
extendSSRWebserverConf(esbuildConf) {
},
// extendPackageJson (json) {},
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
// since it captures everything and tries to
// 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
// over to Vue and Vue Router to render our page
app.get(resolve.urlPath('*'), (req, res) => {
@@ -28,12 +28,9 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
} else if (err.code === 404) {
// hmm, Vue Router could not find the requested route
console.log("404 v renderu")
// Should reach here only if no "catch-all" route
// is defined in /src/routes
// res.status(404).send('404 | Page Not Found')
res.redirect(resolve.urlPath('404'))
res.status(404).send('404 | Page Not Found')
} else if (process.env.DEV) {
// well, we treat any other code as error;
// if we're in dev mode, then we can use Quasar CLI
@@ -42,7 +39,6 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
// serve.error is available on dev only
serve.error({ err, req, res })
} else {
// we're in production, so we should have another method
// to display something to the client when we encounter an error
@@ -51,7 +47,11 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
// Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it
res.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 compression from 'compression'
import {
ssrClose,
ssrCreate,
ssrListen,
ssrRenderPreloadTag,
ssrServeStaticContent
} from 'quasar/wrappers'
defineSsrCreate,
defineSsrInjectDevMiddleware,
defineSsrListen,
defineSsrClose,
defineSsrServeStaticContent,
defineSsrRenderPreloadTag
} from '#q-app/wrappers'
/**
* Create your webserver and return its instance.
* If needed, prepare your webserver to receive
* connect-like middlewares.
*
* Should NOT be async!
* Can be async: defineSsrCreate(async ({ ... }) => { ... })
*/
export const create = ssrCreate((/* { ... } */) => {
export const create = defineSsrCreate((/* { ... } */) => {
const app = express()
// attackers can use this header to detect apps running Express
@@ -42,6 +43,19 @@ export const create = ssrCreate((/* { ... } */) => {
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
* 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
* 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 }) => {
await isReady()
return app.listen(port, () => {
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
const server = devHttpsApp || app
return server.listen(port, () => {
if (process.env.PROD) {
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,
* 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()
})
@@ -81,14 +97,19 @@ const maxAge = process.env.DEV
: 1000 * 60 * 60 * 24 * 30
/**
* Should return middleware that serves the indicated path
* with static content.
* Should return a function that will be used to configure the webserver
* 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) => {
return express.static(path, {
maxAge,
...opts
})
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
app.use(resolve.urlPath(urlPath), serveFn)
}
})
const jsRE = /\.js$/
@@ -103,13 +124,13 @@ const pngRE = /\.png$/
* Should return a String with HTML output
* (if any) for preloading indicated file
*/
export const renderPreloadTag = ssrRenderPreloadTag((file) => {
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
if (jsRE.test(file) === true) {
return `<link rel="modulepreload" href="${file}" crossorigin>`
}
if (cssRE.test(file) === true) {
return `<link rel="stylesheet" href="${file}">`
return `<link rel="stylesheet" href="${file}" crossorigin>`
}
if (woffRE.test(file) === true) {
@@ -121,15 +142,15 @@ export const renderPreloadTag = ssrRenderPreloadTag((file) => {
}
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) {
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) {
return `<link rel="preload" href="${file}" as="image" type="image/png">`
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
}
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 {storeToRefs} from "pinia";
import {useAppStore} from "stores/App";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
const route = useRoute();
@@ -13,6 +15,10 @@ const layoutComponent = computed(() => {
});
const appStatus = storeToRefs(useAppStore()).appStatus;
useMeta(() => metaData({
title: useAppStore().pageTitle,
}));
</script>
<template>
View File
+126 -82
View File
@@ -1,105 +1,149 @@
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core'
import {onError} from "@apollo/client/link/error";
import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core'
import {ErrorLink} from "@apollo/client/link/error";
import {defineStore} from "pinia";
import {useAppStore} from "stores/App";
import {Notify} from "quasar";
import {errorObject, warningObject} from "src/composables/dialog";
import {createUploadLink} from "apollo-upload-client";
import {useAuthStore} from "stores/Auth";
import {useRouter} from "vue-router";
// import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
import UploadHttpLink from "apollo-upload-client/UploadHttpLink.mjs";
const handleRefreshToken = (store, router, forward, operation) => {
if (!operation.query?._refreshUnauthorizedUser) return;
console.log("refreshing token, ", operation.query);
return fromPromise(useAuthStore(store).refreshAccessToken()
return useAuthStore(store).refreshAccessToken()
.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"})
}))
.filter((value) => Boolean(value))
.flatMap((accessToken) => {
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${accessToken}`,
},
});
})
.then(value => value.flatMap((accessToken) => {
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${accessToken}`,
},
});
// retry the request, returning the new observable
return forward(operation);
});
// retry the request, returning the new observable
return forward(operation);
}))
}
export const apolloWrapper = {};
export default (({store, router}) => {
const uploadLink = createUploadLink({
uri: process.env.API_URL + '/graphql',
credentials: 'include'
})
// SSR: boot() runs again for every request. The client/query/mutate below
// used to live on a shared module-level `apolloWrapper` object that every
// request's boot() reassigned in place - under concurrent SSR requests, one
// request could end up calling into another request's (still-being-set-up
// 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 accessToken = useAuthStore(store).getAccessToken();
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
});
}
return forward(operation);
});
const init = ({store, router}) => {
if (client) return // boot() already initialized this request's store
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation);
}
const uploadLink = new UploadHttpLink({
uri: process.env.API_URL + '/graphql',
credentials: 'include'
})
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 authLink = new ApolloLink((operation, forward) => {
const accessToken = useAuthStore(store).getAccessToken();
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
});
}
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 => {
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);
})
};
apolloWrapper.mutate = (mutation, variables) => {
return apolloClient
.mutate({
mutation: mutation,
client = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, uploadLink]),
cache: new InMemoryCache(),
connectToDevTools: true,
ssrMode: true,
});
queryImpl = (query, variables = {}) => {
return client.query({
query: query,
fetchPolicy: 'no-cache',
variables: variables,
variables: variables
})
.then((response) => response)
.catch(err => {
console.error("Error during mutation: ", err);
})
};
.then((response) => response)
.catch(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}) => {
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();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
@@ -28,7 +44,7 @@ export default boot(({store}) => {
})
// Response interceptor for API calls
api.interceptors.response.use(
responseInterceptorId = api.interceptors.response.use(
(response) => response,
async function (error) {
const originalRequest = error.config;
+28 -5
View File
@@ -1,9 +1,32 @@
import {Cookies} from 'quasar'
import {boot} from "quasar/wrappers";
import {defineStore} from 'pinia'
let cookies = Cookies;
export default boot(({ssrContext}) => {
cookies = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
// SSR: boot() runs again for every request. `cookies` used to be a single
// shared module-level variable reassigned in place by every request's
// 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(() => {
if (props.mode === "add") {
return {icon: "add", label: "Přidat"};
} else if (props.mode === "edit") {
@@ -26,7 +25,11 @@ const config = computed(() => {
</script>
<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-page-sticky>
</template>
+1 -1
View File
@@ -30,6 +30,6 @@ const saveForm = () => {
<template>
<q-form @submit.prevent="saveForm" ref="form">
<slot name="default"/>
<FormSubmitButton v-if="saveButton" :save-button="saveButton"/>
<FormSubmitButton v-if="saveButton" :save-button="saveButton" :is-request-pending="isRequestPending" />
</q-form>
</template>
+14 -5
View File
@@ -38,14 +38,23 @@ onMounted(() => {
if (search) {
filters.value.search = search;
} else {
filters.value = {};
}
})
</script>
<template>
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
<template #prepend>
<q-icon name="search"/>
</template>
</q-input>
<div class="flex">
<div class="col-grow">
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
<template #prepend>
<q-icon name="search"/>
</template>
</q-input>
</div>
<div class="flex items-center">
<slot name="append" class="full-height"/>
</div>
</div>
</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 ComboboxInput from "components/inputs/ComboboxInput.vue";
const formData = reactive({
id: null,
callSign: "",
model: "",
seats: null,
manufacturer: "",
description: "",
organization: null,
isPublic: false
});
const props = defineProps({
defaultValues: {
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 = {
callSign: [isRequired, minLength(4), maxLength(8)],
seats: [isRequired, isNumber]
@@ -54,48 +54,48 @@ onMounted(() => {
<q-img :src="props.defaultValues.photoUrl" v-if="props.defaultValues.photoUrl"/>
<q-input
type="text"
:rules="rules.callSign"
v-model="formData.callSign"
label="Volací značka"
v-bind="commonProps"
type="text"
:rules="rules.callSign"
v-model="formData.callSign"
label="Volací značka"
v-bind="commonProps"
/>
<q-input
type="number"
v-model.number="formData.seats"
label="Počet míst (včetně pilota)"
min="1"
:rules="rules.seats"
v-bind="commonProps"
type="number"
v-model.number="formData.seats"
label="Počet míst (včetně pilota)"
min="1"
:rules="rules.seats"
v-bind="commonProps"
/>
<ComboboxInput
:options="organizations"
label="Organizace"
v-model="formData.organization"
v-bind="commonProps"
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
:options="organizations"
label="Organizace"
v-model="formData.organization"
v-bind="commonProps"
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
/>
<q-input
type="text"
v-model="formData.manufacturer"
label="Výrobce"
v-bind="commonProps"
type="text"
v-model="formData.manufacturer"
label="Výrobce"
v-bind="commonProps"
/>
<q-input
type="text"
v-model="formData.model"
label="Model"
v-bind="commonProps"
type="text"
v-model="formData.model"
label="Model"
v-bind="commonProps"
/>
<q-input
type="textarea"
v-model="formData.description"
label="Popis"
v-bind="commonProps"
type="textarea"
v-model="formData.description"
label="Popis"
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>
import LoadMoreList from "components/LoadMoreList.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import {useInfiniteScroll} from "src/composables/pagination";
const props = defineProps({
items: {
type: Array,
required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
...useInfiniteScroll().propsDef
});
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script>
<template>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<template #items>
<q-list bordered separator>
<q-item
clickable
v-ripple
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
v-for="aircraft of props.items" :key="aircraft"
>
<q-item-section avatar>
<q-avatar v-if="aircraft.titlePhoto">
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
</q-item-section>
<q-item-section>
<q-item-label label>
{{ aircraft.callSign }}
</q-item-label>
<q-item-label caption>
{{ aircraft.model }}
<template v-if="aircraft.manufacturer">
({{ aircraft.manufacturer }})
</template>
</q-item-label>
<q-item-label caption v-if="aircraft.organization">
Provozovatel: {{ aircraft.organization.name }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
<q-infinite-scroll
:disable="!hasMoreData"
@load="emitLoadMore"
:offset="250"
>
<q-list bordered separator>
<q-item
clickable
v-ripple
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
v-for="aircraft of props.items" :key="aircraft"
>
<q-item-section avatar>
<q-avatar v-if="aircraft.titlePhoto">
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
</q-item-section>
<q-item-section>
<q-item-label label>
{{ aircraft.callSign }}
</q-item-label>
<q-item-label caption>
{{ aircraft.model }}
<template v-if="aircraft.manufacturer">
({{ aircraft.manufacturer }})
</template>
</q-item-label>
<q-item-label caption v-if="aircraft.organization">
Provozovatel: {{ aircraft.organization.name }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-infinite-scroll>
</template>
+33 -3
View File
@@ -1,4 +1,6 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
routeAdd: {
type: Object,
@@ -8,16 +10,44 @@ const props = defineProps({
type: String,
required: false,
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>
<template>
<div class="q-pa-md q-gutter-sm">
<q-banner class="bg-light-blue text-white">
<template v-slot:avatar>
<q-icon name="info"/>
<q-banner :class=bannerClass>
<template v-slot:avatar v-if="icon">
<q-icon :name="icon"/>
</template>
<slot/>
<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({
event: {
type: Object
type: Object,
required: false,
},
isRequestPending: {
type: Boolean,
@@ -72,9 +73,6 @@ const loggedUser = storeToRefs(useLoggedUserStore()).user;
</div>
</div>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
</FormWrapper>
</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>
import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
import WeatherInfo from "components/weather/WeatherInfo.vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
const props = defineProps({
flight: {
@@ -21,6 +22,16 @@ const props = defineProps({
type: Object,
required: false,
default: null
},
eventDetailRoute: {
type: Object,
required: false,
default: null
},
loggedUser: {
type: Object,
required: false,
default: null
}
})
</script>
@@ -71,7 +82,7 @@ const props = defineProps({
</q-item-section>
</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-icon name="place" v-if="poi.pointOfInterest"/>
<q-icon name="local_airport" v-if="poi.airport"/>
@@ -122,6 +133,18 @@ const props = defineProps({
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
</q-item-section>
</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-list>
<q-item v-for="copilot in flight.copilots" :key="copilot.id">
@@ -129,14 +152,32 @@ const props = defineProps({
<q-icon name="people"/>
</q-item-section>
<q-item-section>
<router-link
v-if="copilot.isPublic"
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
>
{{ copilot.name }}
</router-link>
<template v-if="copilot.isPublic">
<div>
<router-link
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
>
{{ copilot.name }}
</router-link>
</div>
</template>
<template v-else> {{ copilot.name }}</template>
</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-list>
</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 {computed, reactive, ref} from "vue";
import {Line} from "vue-chartjs"
import {useFlightPlanUtils} from "src/composables/flightPlan";
const props = defineProps({
flight: {
type: Object,
required: true
},
flightPlan: {
type: Object,
required: false,
},
photos: {
type: Array,
required: false,
@@ -16,7 +21,7 @@ const props = defineProps({
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 pointsOfInterests = computed(() => mapMarkers
@@ -25,10 +30,12 @@ const pointsOfInterests = computed(() => mapMarkers
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(() => {
return (
!!gpxTrack ||
!!flightTrack ||
mapPhotos.value.length > 0 ||
poisWithGPS.value.length > 0 ||
props.flight.landingAirport.id !== props.flight.takeoffAirport.id
@@ -36,8 +43,14 @@ const displayMap = computed(() => {
});
let chartSpeedData, chartAltitudeData = null;
if (gpxTrack) {
const chartLabels = gpxTrack.time.map(t => {
if (flightTrack) {
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);
return datetime.getHours() + ":" + datetime.getMinutes();
});
@@ -47,7 +60,7 @@ if (gpxTrack) {
backgroundColor: "#38bd3e",
borderColor: "#38bd3e",
label: "Rychlost (km/h)",
data: gpxTrack.speed,
data: speed,
pointRadius: 1,
}]
};
@@ -58,18 +71,18 @@ if (gpxTrack) {
backgroundColor: "#9ae3ff",
borderColor: "#9ae3ff",
label: "Nadmořská výška letu (m)",
data: gpxTrack.altitude,
data: altitude,
pointRadius: 1,
}]
};
if (gpxTrack.terrainElevation.length > 0) {
if (terrainElevation.length > 0) {
chartAltitudeData.datasets.push(
{
backgroundColor: "rgba(148,94,40,0.7)",
borderColor: "#945e28",
label: "Nadmořská výška země (m)",
data: gpxTrack.terrainElevation,
data: terrainElevation,
fill: true,
pointRadius: 1,
});
@@ -85,7 +98,7 @@ const drawCurrentPositionMarker = (e) => {
return;
}
const idx = res[0].index;
currentPositionMarker.value = props.flight.gpxTrack.coordinates[idx];
currentPositionMarker.value = props.flight.track.points[idx].coordinates;
}
const chartOptions = {
@@ -98,20 +111,38 @@ const chartOptions = {
onClick: 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>
<template>
<q-no-ssr>
<MapWithMarkers
v-if="displayMap"
style="width: 100%; height: 500px;"
:gpx-track="flight.gpxTrack?.coordinates || []"
:markers="pointsOfInterests"
:highlight-position="currentPositionMarker"
:photos="mapPhotos"
/>
<q-intersection>
<q-checkbox v-model="showPlanTrack" label="Zobrazovat plán?" v-if="flightPlan" />
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
<MapWithMarkers
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>
</template>
+51 -65
View File
@@ -2,9 +2,7 @@
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {flightName} from "src/composables/flight";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {computed} from "vue";
import LoadMoreList from "components/LoadMoreList.vue";
import {useInfiniteScroll} from "src/composables/pagination";
const props = defineProps({
items: {
@@ -15,62 +13,46 @@ const props = defineProps({
type: Boolean,
required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
...useInfiniteScroll().propsDef
});
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
const routeDetail = (flight) => {
if (props.public)
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
else
return {name: "flightDetail", params: {id: flight.id}}
};
const pluralize = (count, singular, plural, pluralMore) => {
if (count === 1) return singular;
else if (count < 5) return plural;
else return pluralMore;
}
const loadMoreCount = computed(() => {
const remaining = props.totalItemsCount - props.items.length;
return Math.min(props.itemsPerPage, remaining);
});
</script>
<template>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<template #items>
<q-list bordered separator>
<q-item
v-for="flight of items" :key="flight"
ripple
clickable
:to="routeDetail(flight)"
>
<q-item-section avatar>
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
<q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
</q-item-section>
<q-item-section>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<q-infinite-scroll
:disable="!hasMoreData"
@load="emitLoadMore"
:offset="250"
>
<q-list bordered separator>
<q-item
v-for="flight of items" :key="flight"
ripple
clickable
:to="routeDetail(flight)"
>
<q-item-section avatar class="gt-xs">
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
<q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
</q-item-section>
<q-item-section>
<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">
<q-icon name="support_agent"/>
@{{ flight.pilot.publicUsername }}
{{ flight.pilot.publicUsername }}
</div>
<div class="text-secondary" v-if="flight.copilots">
@@ -92,27 +74,31 @@ const loadMoreCount = computed(() => {
<q-icon name="event"/>
{{ flight.event.name }}
</div>
</q-item-section>
</div>
</q-item-section>
<q-item-section side top v-if="!props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility" v-if="flight.isPublic"/>
<q-item-section side top v-if="!props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility" v-if="flight.isPublic"/>
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
<q-tooltip>
Let je sice publikovaný, nemá ale nastavenou URL adresu!
</q-tooltip>
</q-icon>
</q-item-section>
</q-item>
</q-list>
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
<q-tooltip>
Let je sice publikovaný, nemá ale nastavenou URL adresu!
</q-tooltip>
</q-icon>
</q-item-section>
</q-item>
</q-list>
<template v-slot:loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px"/>
</div>
</template>
<template #skeleton>
<FlightsListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Načíst {{ pluralize(loadMoreCount, 'jeden let', `další ${loadMoreCount} lety`, `dalších ${loadMoreCount} letů`) }}
</template>
</LoadMoreList>
</q-infinite-scroll>
</template>
<style>
.list-secondary-info div {
padding: 0 1em;
}
</style>
@@ -1,7 +1,7 @@
<script setup>
import {ref} from "vue";
import {flightName} from "src/composables/flight";
import LatestFlightSliderInfo from "components/flights/slider/LatestFlightSliderInfo.vue";
import FlightHeaderInfo from "components/flights/FlightHeaderInfo.vue";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
const props = defineProps({
items: {
@@ -37,11 +37,25 @@ const slide = ref(0);
height="400px"
v-if="flight.titlePhoto"
>
<div class="absolute-bottom">
<LatestFlightSliderInfo :flight="flight"/>
<div class="absolute-full">
<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>
</q-img>
<LatestFlightSliderInfo v-else :flight="flight" text-color="primary"/>
</q-carousel-slide>
</q-carousel>
</div>
@@ -19,43 +19,11 @@ const formData = reactive({
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>
<template>
<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>
</template>
@@ -8,6 +8,7 @@ import ComboboxInput from "components/inputs/ComboboxInput.vue";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const props = defineProps({
flight: {
@@ -18,6 +19,10 @@ const props = defineProps({
type: Array,
required: false,
},
copilots: {
type: Array,
required: false,
},
isRequestPending: {
type: Boolean,
required: false,
@@ -31,7 +36,8 @@ const formData = reactive({
description: "",
debriefingNotes: "",
urlSlug: "",
isPublic: null
isPublic: null,
copilots: []
})
@@ -47,6 +53,20 @@ watch(() => props.flight, (newVal) => {
}
}, {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");
</script>
@@ -66,18 +86,52 @@ const descriptionTab = ref("description");
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</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-tab name="description">Popis</q-tab>
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
</q-tabs>
<q-tab-panels v-model="descriptionTab">
<q-tab-panels v-model="descriptionTab">
<q-tab-panel name="description">
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
</q-tab-panel>
<q-tab-panel name="debriefing">
<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"/>
</q-tab-panel>
@@ -1,8 +1,6 @@
<script setup>
import FormWrapper from "components/FormWrapper.vue";
import {reactive} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {roundWithPrecision} from "../../../composables/utils";
const props = defineProps({
flight: {
@@ -25,11 +25,11 @@ onMounted(() => {
})
const steps = [
{name: "basic", icon: "flight_takeoff", label: "Základní informace"},
{name: "copilot", icon: "person_add", label: "Spolucestující"},
{name: "track", icon: "route", label: "Trasa"},
{name: "photos", icon: "add_a_photo", label: "Fotky"},
{name: "notes", icon: "edit_note", label: "Poznámky"},
{name: "editFlightBasic", icon: "flight_takeoff", label: "Základní informace"},
{name: "editFlightTrack", icon: "route", label: "Trasa"},
{name: "editFlightPhotos", icon: "add_a_photo", label: "Fotky"},
{name: "editFlightNotes", icon: "edit_note", label: "Poznámky"},
{name: "editFlightSetPlan", icon: "pin_drop", label: "Plán"},
];
const emit = defineEmits(["update:modelValue"])
@@ -37,14 +37,20 @@ watch(currentStep, (val) => emit("update:modelValue", val));
</script>
<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
v-for="step in steps"
:key="step.name"
:icon="step.icon"
:label="step.label"
replace
:to="{name: 'flightEdit', params: {id: flightId, step: step.name}}"
:to="{name: step.name, params: {id: flightId}}"
:disable="disabled.includes(step.name)"
exact
/>
@@ -60,7 +66,6 @@ watch(currentStep, (val) => emit("update:modelValue", val));
active-color="primary"
class="q-mb-lg"
>
<q-tab v-for="step in steps" :key="step.name" v-bind="step" :disable="disabled.includes(step.name)"/>
</q-tabs>
</template>
@@ -51,7 +51,7 @@ watch(poi, (newVal) => {
}
});
watch(() => props.flight.track, (newVal) => {
watch(() => props.flight.turnPoints, (newVal) => {
formData.track = [];
for (const i in newVal) {
const trackInfo = newVal[i];
@@ -76,7 +76,7 @@ watch(() => props.flight.track, (newVal) => {
<template>
<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"/>
Trasa nahrána
</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>
<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">
<div>
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
</div>
</q-timeline-entry>
<q-timeline-entry title="Ulož trasu">
<div>
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
</div>
+1 -1
View File
@@ -100,7 +100,7 @@ const filterFn = (val, update, abort) => {
clear-icon="clear"
:v-bind="$attrs"
>
<template v-slot:no-option>
<template #no-option>
<q-item v-if="inputValue">
<q-item-section avatar>
<q-avatar text-color="green" size="32px" icon="add"/>
+14 -3
View File
@@ -7,6 +7,16 @@ const props = defineProps({
route: {
type: Object,
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>
<q-btn
icon="link"
@click="copyLinkToClipboard()"
:icon="icon"
:label="label"
v-bind="$attrs"
flat
label="Získat odkaz"
@click="copyLinkToClipboard()"
/>
</template>
+42 -30
View File
@@ -1,14 +1,9 @@
<script setup>
import {Marker} from "vue3-google-map";
import {onMounted, ref} from "vue";
import axios from "axios";
import {onMounted, ref, watch} from "vue";
import MapWrapper from "components/map/MapWrapper.vue";
const center = ref({
lat: 50.1314,
lng: 14.5256
});
const zoom = ref(10);
import {useGeocode} from "src/composables/geocoding";
import axios from "axios";
const props = defineProps({
gpsLatitude: {
@@ -24,44 +19,61 @@ const props = defineProps({
required: false
}
});
const emits = defineEmits(["update:gpsLatitude", "update:gpsLongitude", "notFound"]);
const emitModels = (lat, lng) => {
emits("update:gpsLatitude", lat);
emits("update:gpsLongitude", lng);
}
const {searchGeocode, center} = useGeocode(emits);
const markerDropped = (position) => {
emitModels(position.latLng.lat(), position.latLng.lng());
}
const setCoordinatesFromGeocode = (address) => {
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;
});
}
const zoom = ref(8);
onMounted(() => {
if (props.gpsLatitude && props.gpsLongitude) {
center.value.lng = props.gpsLongitude;
center.value.lat = props.gpsLatitude
} else if (props.label) {
setCoordinatesFromGeocode(props.label);
watch(() => [props.label, props.gpsLongitude, props.gpsLatitude], (val, oldValue) => {
const [newAddress, newLon, newLat] = val
const [previousAddress, oldLon, oldLat] = oldValue
if (newAddress) {
if (newAddress === previousAddress) return
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 {
console.log(newAddress, newLon, newLat, props.label, props.gpsLongitude, props.gpsLatitude)
console.error("Set either label or gps coordinates.");
}
})
},
// {immediate: true}
)
</script>
<template>
<MapWrapper style="width: 100%; height: 500px" :center="center" :zoom="zoom">
<Marker :options="{ position: center, title: label, draggable: true }" @dragend="markerDropped"/>
<MapWrapper
: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>
</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>
import MapWrapper from "components/map/MapWrapper.vue";
import {Marker, Polyline, InfoWindow} from "vue3-google-map";
import {computed, onMounted, ref} from "vue";
import {axios} from "boot/axios";
import {computed} from "vue";
const props = defineProps({
gpxTrack: {
type: Object,
tracks: {
type: Array,
required: false,
default: () => []
},
markers: {
type: Array,
@@ -23,15 +23,56 @@ const props = defineProps({
type: Array,
required: false,
default: () => []
},
bounds: {
type: Array,
required: false,
default: () => []
}
})
const track = ref([]);
const bounds = computed(() => {
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
const allData = [...props.markers, ...track.value, ...photosTransformed];
return allData.filter(i => !!i.position).map(i => i.position);
const coordinates = [
...[...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>
<template>
@@ -43,11 +84,12 @@ const bounds = computed(() => {
</InfoWindow>
</Marker>
<template v-if="gpxTrack && gpxTrack.length > 0">
<Polyline :options="{path: gpxTrack, geodesic: true, strokeColor: '#ff0000', strokeOpacity: 0.3, strokeWeight: 5}"/>
<template v-if="tracks && tracks.length > 0">
<template v-for="(track, i) in tracks" :key="track">
<Polyline :options="{path: track, geodesic: true, strokeColor: getColor(i), strokeOpacity: 0.5, strokeWeight: 5}"/>
</template>
</template>
<Marker v-if="highlightPosition" :options="{position: highlightPosition, icon: '/mapIcons/currentPosition32x32.png'}"/>
</MapWrapper>
</template>
+365 -11
View File
@@ -1,37 +1,391 @@
<script setup>
import {GoogleMap} from "vue3-google-map";
import {ref, watch} from "vue";
import {CustomControl, CustomMarker, GoogleMap, Polygon, Polyline} from "vue3-google-map";
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({
bounds: {
type: Array,
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 mapRef = ref(null);
const zoomLevel = ref(null);
const visibleBoundsObject = ref(null);
watch(
() => mapRef.value?.ready,
(ready) => {
if (!props.bounds || !ready) return;
const map = mapRef.value.map;
const api = mapRef.value.api;
if (!ready) return;
const bounds = new api.LatLngBounds();
for (let i = 0; i < props.bounds.length; i++) {
bounds.extend(props.bounds[i]);
if (props.bounds) {
const bounds = new mapRef.value.api.LatLngBounds();
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>
<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/>
<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>
</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"
avatar
>
<q-icon :name="icon" />
<q-icon :name="icon"/>
</q-item-section>
<q-item-section>
<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>
</template>
@@ -24,26 +24,21 @@ import {computed} from "vue";
import {useRoute, useRouter} from "vue-router";
const props = defineProps({
title: {
type: String,
required: true
},
caption: {
type: String,
default: ''
},
to: {
type: Object,
required: true
},
icon: {
type: String,
default: ''
}
});
title: {
type: String,
required: true
},
to: {
type: Object,
required: true
},
caption: {
type: String,
},
icon: {
type: String,
}
});
const $route = useRoute();
const $router = useRouter();
@@ -51,7 +46,7 @@ const isButtonActive = computed(() => {
const btnResolvedPath = $router.resolve(props.to).path;
if (props.to.name === 'myFlights') {
return $route.name.toLowerCase().includes("flight");
return ["myFlights", "addFlight", "flightDetail", "flightEdit"].includes($route.name);
} else {
return $route.fullPath === btnResolvedPath || $route.fullPath.startsWith(btnResolvedPath);
}
@@ -6,9 +6,15 @@ const linksList = [
{
title: 'Moje lety',
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',
@@ -19,6 +19,9 @@ const props = defineProps({
copilots: {
type: Array
},
loading: {
type: Boolean
}
});
const formData = ref({
@@ -44,7 +47,12 @@ const setup = () => {
<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-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">
<ComboboxInput
:options="locations"
+8 -2
View File
@@ -18,6 +18,9 @@ const props = defineProps({
},
copilots: {
type: Array
},
loading: {
type: Boolean
}
});
@@ -38,7 +41,10 @@ const setup = (() => {
<template>
<DialogWrapper :formData="formData" :object-id="photoId" @show="setup">
<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">
<ComboboxInput
:options="locations"
@@ -67,7 +73,7 @@ const setup = (() => {
option-value="id"
emit-value
map-options
/>
/>
<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"/>
+68 -38
View File
@@ -6,8 +6,9 @@ import PhotoSwipeDynamicCaption from "../../../node_modules/photoswipe-dynamic-c
import PhotoCaption from "components/photos/PhotoCaption.vue";
import PhotoSwipeLightbox from "../../../node_modules/photoswipe/dist/photoswipe-lightbox.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 {useQuasar} from "quasar";
const props = defineProps({
galleryId: {
@@ -21,9 +22,8 @@ const props = defineProps({
}
})
const route = useRoute();
const $router = useRouter();
const $q = useQuasar();
let $lightbox = ref(null);
@@ -46,20 +46,6 @@ const initLightbox = () => {
$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(() => {
if ($lightbox.value) {
$lightbox.value.destroy();
@@ -69,38 +55,82 @@ onUnmounted(() => {
const openedPhoto = computed(() => usePhotosStore().openedPhoto);
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);
if (idx >= 0) {
$lightbox.value.loadAndOpen(idx);
usePhotosStore().setOpenedPhoto(null);
}
}, {immediate: true})
}, {immediate: true});
</script>
<template>
<div :id="galleryId" class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" v-for="photo in photos" :key="photo.id">
<a
:href="photo.url"
:data-pswp-width="photo.width"
:data-pswp-height="photo.height"
target="_blank"
rel="noreferrer"
<div :class="{'photogallery--wrapper': true}" :id="`${galleryId}-wrapper`">
<div :id="galleryId" class="row photogallery--content">
<div
v-for="photo in photos" :key="photo.id"
style="min-height: 100px"
class="col-6 col-sm-4 col-md-4 col-lg-2 q-pa-sm"
>
<q-img
spinner-color="primary"
class="rounded-borders"
:src="photo.thumbnailUrl"
alt=""
<a
:data-pswp-src="photo.url"
:data-pswp-width="photo.width"
:data-pswp-height="photo.height"
target="_blank"
@click="initLightbox"
rel="noreferrer"
>
<div class="absolute-bottom" v-if="photo.name !== ''">
{{ photo.name }}
</div>
</q-img>
<PhotoCaption :photo="photo"/>
</a>
<q-img
spinner-color="primary"
class="rounded-borders"
:src="photo.thumbnailUrl"
alt=""
>
<div class="absolute-bottom" v-if="photo.name !== ''">
{{ photo.name }}
</div>
</q-img>
<PhotoCaption :photo="photo"/>
</a>
</div>
</div>
</div>
</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 {useQuasar} from "quasar";
import {confirmObject} from "src/composables/dialog";
import {usePhotoBatchManagement} from "src/composables/photos/batch";
const props = defineProps({
photos: {
@@ -19,100 +20,23 @@ const props = defineProps({
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 openEdit = (photo) => {
emits("edit", photo);
};
const confirmPhotoDelete = (id) => {
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
.onOk(() => {
emits("delete", id);
});
.onOk(() => {
emits("delete", id);
});
}
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 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 {
openBatchEdit, batchEdit, handleImageClick, confirmBatchPhotoDelete, batchOptionsProps,
isBatchEditAllowed, batchRotate, checkAll
} = usePhotoBatchManagement({photos: props.photos, emits});
const setFlightCover = (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)"/>
</div>
<q-btn
icon="star"
:color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'"
round
@click.stop.prevent="setFlightCover(photo)"
icon="star"
:color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'"
round
@click.stop.prevent="setFlightCover(photo)"
/>
</div>
@@ -162,7 +86,7 @@ const showLarge = (displayedPhoto) => {
</div>
<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="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>
</div>
@@ -183,7 +107,7 @@ const showLarge = (displayedPhoto) => {
<q-dialog v-model="openPhoto" full-width>
<q-img :src="photo.url">
<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>
</q-img>
</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">
<q-btn @click="resetMap" icon="gps_fixed" round flat text-color="green" title="Nastav pozici na mapě"/>
</template>
<template #after>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
</q-input>
<MapGPSInput
:label="poiNameForMap"
:zoom="zoom"
v-model:gpsLatitude="formData.gpsLatitude"
v-model:gpsLongitude="formData.gpsLongitude"
@not-found="handlePositionNotFound"
/>
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
@@ -85,21 +95,11 @@ const handlePositionNotFound = () => {
:slug-source="formData.name"
: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"/>
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
</FormWrapper>
</template>
+31 -46
View File
@@ -1,7 +1,5 @@
<script setup>
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import LoadMoreList from "components/LoadMoreList.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import {useInfiniteScroll} from "src/composables/pagination";
const props = defineProps({
items: {
@@ -12,20 +10,7 @@ const props = defineProps({
type: Boolean,
required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
...useInfiniteScroll().propsDef,
});
const getRoute = (item) => {
@@ -34,35 +19,35 @@ const getRoute = (item) => {
else
return {name: "poiDetail", params: {id: item.id}}
};
const emits = defineEmits(useInfiniteScroll().emitsDef)
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script>
<template>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<template #items>
<q-list bordered separator>
<q-item clickable v-ripple v-for="poi of items" :key="poi" :to="getRoute(poi)">
<q-item-section avatar>
<q-avatar v-if="poi.titlePhoto">
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
{{ poi.name }}
<div class="text-secondary" v-if="poi.type">
{{ poi.type.name }}
</div>
</q-item-section>
<q-item-section side top v-if="!props.public && poi.isPublic">
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
<q-infinite-scroll
:disable="!hasMoreData"
@load="emitLoadMore"
:offset="250"
>
<q-list bordered separator>
<q-item clickable v-for="poi of items" :key="poi" :to="getRoute(poi)">
<q-item-section avatar>
<q-avatar v-if="poi.titlePhoto">
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
{{ poi.name }}
<div class="text-secondary" v-if="poi.type">
{{ poi.type.name }}
</div>
</q-item-section>
<q-item-section side top v-if="!props.public && poi.isPublic">
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
</q-list>
</q-infinite-scroll>
</template>
+2 -3
View File
@@ -1,6 +1,7 @@
<script setup>
import {ref} from "vue";
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
import {useUnitsConvertors} from "src/composables/units";
const props = defineProps({
weather: {
@@ -9,9 +10,7 @@ const props = defineProps({
}
})
const kmhToKnots = (speedKmh) => {
return Math.round(speedKmh * 0.53996);
}
const {kmhToKnots} = useUnitsConvertors();
const openDetail = ref(false);
</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`
}
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) => {
return date.extractDate(datetimeStr, format)
}
+1 -1
View File
@@ -5,7 +5,7 @@ export const flightName = (flight) => {
if (flight.name !== '') {
return `${flight.name} (${date})`
} 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(" - ")} - ` : ' - ';
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) => {
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}) => {
const filteredKeywords = (keywords || []).filter(kw => !!kw);
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 paginationOffset = ref(0);
export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
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({
...filters.value,
limit: paginationLimit.value,
offset: paginationOffset.value,
appendItems
}).then(() => {
paginationOffset.value += paginationLimit.value;
})
const loadFirstPage = () => {
paginationOffset.value = 0
isQueryRunning.value = true
return fetchFunction({
...filters.value, limit: paginationLimit.value, offset: 0, appendItems: false
}).then((data) => {
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) => {
return speedInKt;
export const useUnitsConvertors = () => {
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);
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
manufacturer
isPublic
seats
titlePhoto {
thumbnailUrl
+8 -8
View File
@@ -1,14 +1,14 @@
import gql from "graphql-tag";
export const FRAGMENT_AIRCRAFT_BASIC_INFO = gql`
fragment aircraftBasicInfo on Aircraft {
id
callSign
manufacturer
organization {
fragment aircraftBasicInfo on Aircraft {
id
name
callSign
manufacturer
organization {
id
name
}
}
}
`;
+31
View File
@@ -1,5 +1,17 @@
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`
fragment airportInfo on Airport {
id
@@ -7,5 +19,24 @@ export const FRAGMENT_AIRPORT_INFO = gql`
icaoCode
gpsLatitude
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`
fragment copilotFullInfo on Copilot {
export const FRAGMENT_COPILOT_PUBLIC_INFO = gql`
fragment copilotPublicInfo on Copilot {
id
name
isPublic
urlSlug
}
`
export const FRAGMENT_COPILOT_FULL_INFO = gql`
fragment copilotFullInfo on Copilot {
...copilotPublicInfo
titlePhoto {
id
url
}
}
${FRAGMENT_COPILOT_PUBLIC_INFO}
`;
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_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
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`
fragment weatherFullInfo on WeatherInfo {
datetime
dateTime
qnh
temperatureSurface
dewpointSurface
@@ -28,7 +29,8 @@ fragment flightFullInfo on Flight {
landings
durationMinCalculated
isPublic
flightPlanId
socialImageUrl
titlePhoto {
thumbnailUrl
url
@@ -51,35 +53,37 @@ fragment flightFullInfo on Flight {
...weatherFullInfo
}
copilots {
id
name
isPublic
urlSlug
}
event {
id
isPublic
urlSlug
name
}
gpxTrack {
coordinates {
track {
points {
coordinates {
lat
lng
}
timestamp
magneticVariation
speed
altitude
terrainElevation
}
mapBounds {
lat
lng
}
time
magneticVariation
speed
maxSpeed
avgSpeed
altitude
terrainElevation
maxAltitude
avgAltitude
}
track {
turnPoints {
pointOfInterest {
...pointOfInterestFullInfo
}
@@ -103,8 +107,14 @@ ${FRAGMENT_WEATHER_FULL_INFO}
export const FRAGMENT_FLIGHT_FULL_PRIVATE_INFO = gql`
fragment flightFullPrivateInfo on Flight {
...flightFullInfo
flightPlanId
debriefingNotes
copilots {
...copilotPublicInfo
token
}
photos {
...photoFullInfo
@@ -122,4 +132,5 @@ fragment flightFullPrivateInfo on Flight {
${FRAGMENT_FLIGHT_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 {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";
export const FRAGMENT_FLIGHT_LIST_INFO = gql`
@@ -17,7 +17,7 @@ fragment flightListInfo on Flight {
url
}
track {
turnPoints {
pointOfInterest {
id
name
@@ -33,11 +33,11 @@ fragment flightListInfo on Flight {
}
takeoffAirport {
...airportInfo
...airportListInfo
}
landingAirport {
...airportInfo
...airportListInfo
}
aircraft {
@@ -45,7 +45,7 @@ fragment flightListInfo on Flight {
}
}
${FRAGMENT_AIRPORT_INFO}
${FRAGMENT_AIRPORT_LIST_INFO}
${FRAGMENT_AIRCRAFT_BASIC_INFO}
`;
+2
View File
@@ -8,6 +8,8 @@ fragment pointOfInterestListInfo on PointOfInterest {
name
urlSlug
isPublic
gpsLatitude
gpsLongitude
titlePhoto {
thumbnailUrl
}
+1
View File
@@ -7,6 +7,7 @@ export const FRAGMENT_USER_FULL_INFO = gql`
email
avatarImageUrl
titleImageUrl
socialImageUrl
publicUsername
description
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`
mutation changeOrientation($id: Int!, $direction: String!) {
changeOrientation(id: $id, direction: $direction) {
mutation changeOrientation($id: Int!, $direction: String!, $copilotUploadToken: String) {
changeOrientation(id: $id, direction: $direction, copilotUploadToken: $copilotUploadToken) {
...photoFullInfo
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
export const DELETE_PHOTO = gql`
mutation deletePhoto($id: Int!) {
deletePhoto(id: $id) {
mutation deletePhoto($id: Int!, $copilotUploadToken: String) {
deletePhoto(id: $id, copilotUploadToken: $copilotUploadToken) {
id
}
}

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