112 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
Michal Kváček 1a5c8f09bf Napoveda, fix filtru pro nacitani letu u objektu 2024-02-13 23:00:37 +01:00
Michal Kváček 9c9bbc9baa Reset hesla 2024-02-08 16:25:41 +01:00
Michal Kváček fe47c1f7d6 Neindexovat 404 2024-02-02 09:34:03 +01:00
Michal Kváček b22773b2ae Snad fix 404ky 2024-02-01 17:32:12 +01:00
Michal Kváček d69ceac14b Vyhledavani i ve verejnych POI 2024-02-01 14:48:05 +01:00
Michal Kváček 418469d81c Vyhledavani v POI 2024-02-01 08:32:27 +01:00
Michal Kváček c28feade90 Drobna uprava refreshe loginu 2024-02-01 07:21:03 +01:00
Michal Kváček 5b94f2c4b3 Kontaktni form 2024-02-01 07:20:54 +01:00
Michal Kváček 08a3cbf3d9 Avatar do admina, pridani debrief informaci 2024-01-20 22:08:49 +01:00
Michal Kváček b0aa6b5628 Uprava setovani 404 (asi porad nefunguje), ruzne podminky, priprava na zapomenute heslo, sjednoceni nacitani letu v profilu 2024-01-20 15:40:31 +01:00
Michal Kváček 0c89884b23 Uprava publikovani objektu, bugfixing 2024-01-18 06:52:39 +01:00
Michal Kváček 5a5c7cb645 Slugy, webp, hromada refaktoringu 2024-01-13 23:29:51 +01:00
Michal Kváček e7cde2f425 Zmena favicon, uprava HP 2024-01-02 11:30:53 +01:00
Michal Kváček 966d582b63 Uprava error stranek, indexu 2023-12-19 20:34:06 +01:00
Michal Kváček 9aebc352cd Fix nacitani letu u eventu 2023-12-12 23:54:19 +01:00
Michal Kváček c8ea17c1ec Aktualizace balicku, strnakovani, uprava hlavni stranky 2023-12-12 23:42:16 +01:00
Michal Kváček 8c7e237053 Verejny seznam POI 2023-12-10 23:21:33 +01:00
Michal Kváček 3216de4cb7 Bugfixing 2023-12-10 23:11:11 +01:00
Michal Kváček 9b38abd0de Cookies v SSR 2023-12-10 22:10:26 +01:00
Michal Kváček 71389caecc Reorganizace skeletonu, aktualizace app-vite a quasaru 2023-12-10 10:19:03 +01:00
Michal Kváček 68659ca924 Bugfixing, dotazeni uprav s uvodni fotkou 2023-12-05 12:15:12 +01:00
Michal Kváček 3bd4d7ba52 Lepsi verejna cast, ssr, lepsi prace s fotkamaa 2023-12-04 22:46:23 +01:00
Michal Kváček 11cc6ad107 Vylepseni SSR, pridani meta informaci, zatim dost WIP 2023-11-28 11:35:53 +01:00
Michal Kváček 86605ad53e Merge branch 'master' of kvacek.cz:poletani.cz/app into ssr 2023-11-21 16:27:49 +01:00
Michal Kváček 9a14fdfcfb Fix chybejiciho importu props 2023-11-20 16:48:58 +01:00
Michal Kváček 00d4de08e4 Fix chybejiciho importu props 2023-11-20 16:48:35 +01:00
Michal Kváček 9181118e69 Fix loginu a spatneho importu 2023-11-20 16:43:19 +01:00
Michal Kváček 4aeef2e8a6 Merge branch 'master' of kvacek.cz:poletani.cz/app into ssr 2023-11-20 16:28:02 +01:00
Michal Kváček af74801693 Bugfixing 2023-11-20 10:24:08 +01:00
Michal Kváček f9d70a5d57 Ne uplne otestovany prepis storu 2023-11-19 00:44:01 +01:00
Michal Kváček 331ebbc4e4 Zaklad ssr 2023-11-14 16:20:01 +01:00
Michal Kváček e65491e00b Bugfix 2023-11-09 06:17:20 +01:00
Michal Kváček 8beb7bb9d6 Odstraneni vue-inner-image-zoom, oprava na dotazovani na verejny seznam letu u kopilota 2023-11-07 17:47:23 +01:00
Michal Kváček 284f8d2c53 Nahled pred uploadem 2023-11-06 21:35:43 +01:00
Michal Kváček 6b1e220d42 Pridani stranky pro verejny prehled letu s danym kopilotem 2023-11-02 09:03:12 +01:00
Michal Kváček 40f21d159e Pridani skeletonu 2023-11-01 06:19:26 +01:00
Michal Kváček 3e7ee9e686 Uprava editoru 2023-10-31 17:29:51 +01:00
Michal Kváček 1ed35552d5 Drobnosti 2023-10-30 09:13:54 +01:00
Michal Kváček e2ad6e39b8 Implementace photoswipe 2023-10-30 09:08:58 +01:00
Michal Kváček a63efc44d8 Prepracovane zobrazeni seznamu fotke 2023-10-27 23:01:11 +02:00
Michal Kváček 100e1fa33b Pridan fotoeditor 2023-10-27 22:30:47 +02:00
Michal Kváček 4c8952117b Refaktoring verejneho profilu, pridani zobrazeni udalosti 2023-10-14 22:43:16 +02:00
Michal Kváček 5b47c56c6f Bugfixing a refaktoring 2023-10-13 23:25:06 +02:00
Michal Kváček bf8df8bb34 Fix vykreslovani mapy 2023-10-09 14:57:15 +02:00
Michal Kváček e76d34fab4 Refaktoring a pridani letiste mezipristani do mapy 2023-10-07 22:11:56 +02:00
Michal Kváček 901c652641 Pridani letiste pro mezipristani 2023-10-07 21:37:16 +02:00
Michal Kváček 36b0178b97 Pridani od/do k eventum, fix datetime pickeru pro vyber pouze data, pridano URL k otevrene fotce 2023-09-25 06:25:53 +02:00
Michal Kváček b64c426179 Otaceni fotek 2023-09-21 09:39:22 +02:00
Michal Kváček d1baec342f Drobny bugfixing 2023-09-20 14:43:53 +02:00
Michal Kváček 8c3c5ee48d Sprava eventu a organizaci 2023-09-20 13:26:13 +02:00
Michal Kváček 6fbaef4a1e Sprava eventu 2023-09-19 06:33:17 +02:00
Michal Kváček 0c91ff7838 Fix vypisu spolecnych letu s kopilotem, uprava zobrazeni photo carouselu 2023-09-18 20:03:53 +02:00
Michal Kváček b6a6fc3558 Description projektu, drobna uprava skeletonu pro editaci letu 2023-09-18 09:00:03 +02:00
Michal Kváček e203a7d229 Refaktoring 2023-09-13 10:03:41 +02:00
303 changed files with 14739 additions and 10162 deletions
+2
View File
@@ -28,3 +28,5 @@ yarn-error.log*
*.njsproj
*.sln
.vscode
quasar.config.js.temporary.*
+27 -14
View File
@@ -1,24 +1,37 @@
# develop stage
FROM node:20-alpine as develop-stage
FROM node:22-alpine AS develop-stage
ENV TZ=Europe/Prague
WORKDIR /app
COPY package*.json ./
RUN yarn global add @quasar/cli && yarn --production=false
RUN yarn global add @quasar/cli
COPY package.json yarn.lock ./
RUN yarn --production=false
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"
#ARG API_URL
#ENV VUE_API_URL $API_URL
RUN quasar build -m ssr
RUN cp /app/dist/ssr/server/server-entry.mjs /app/dist/ssr/server/server-entry.js
RUN yarn --production=false
RUN quasar build
FROM node:22-alpine AS production-stage
# production stage
FROM nginx:1.25-alpine as production-stage
COPY conf/nginx.conf /etc/nginx/nginx.conf
COPY --from=build-stage /app/dist/spa /app/html
EXPOSE 80
ENV NODE_ENV production
ENV TZ=Europe/Prague
CMD ["nginx", "-g", "daemon off;"]
EXPOSE 3000
WORKDIR /app
COPY process.yml /app
COPY --from=build-stage /app/dist/ssr/package.json /app
RUN yarn global add pm2 && yarn && yarn cache clean
COPY --from=build-stage /app/dist/ssr /app
CMD ["pm2-runtime", "/app/process.yml"]
+2 -2
View File
@@ -1,7 +1,7 @@
version: "3.3"
version: "3.4"
services:
poletani-app:
image: "docker.kvacek.cz/poletani/app:latest"
restart: always
ports:
- 9000:80
- 9000:3000
+5 -35
View File
@@ -1,47 +1,17 @@
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:
build:
context: .
target: "develop-stage"
ports:
- "9000:9000"
- "9001:9100"
volumes:
- ./src:/app/src
- ./public:/app/public
- ./node_modules:/app/node_modules:delegated
command: quasar dev
- ./:/app
command: quasar dev # -m ssr
environment:
API_URL: "http://localhost:8000"
# nginx:
# image:
#
#
API_URL: "http://127.0.0.1:8000"
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
volumes:
db-data: { }
+16 -3
View File
@@ -1,10 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
@@ -14,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>
-5400
View File
File diff suppressed because it is too large Load Diff
+40 -24
View File
@@ -1,7 +1,7 @@
{
"name": "app",
"version": "0.0.1",
"description": "A Quasar Project",
"description": "Fotogalerie pro tvoje zážitky ze vzduchu",
"productName": "Polétání.cz",
"author": "Michal Kváček <michal@kvacek.cz>",
"private": true,
@@ -10,33 +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.4.0",
"chart.js": "^4.4.0",
"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",
"pinia": "^2.0.33",
"quasar": "^2.12.0",
"vue": "^3.2.47",
"vue-chartjs": "^5.2.0",
"vue-inner-image-zoom": "^2.0.0",
"vue-router": "^4.1.6",
"vue3-google-map": "^0.15.0"
"photoswipe": "^5.4.4",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"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.5.0",
"vue3-google-map": "^0.24.1"
},
"devDependencies": {
"@quasar/app-vite": "^1.3.0",
"autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-vue": "^9.0.0",
"postcss": "^8.4.14",
"prettier": "^2.5.1",
"vite-plugin-rewrite-all": "^1.0.1"
"@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')
]
}
+11
View File
@@ -0,0 +1,11 @@
apps:
- name: poletani
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.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Disallow: /login/
Disallow: /moje/
+26 -19
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 {
@@ -23,15 +22,22 @@ module.exports = configure(function (ctx) {
},
// https://v2.quasar.dev/quasar-cli/prefetch-feature
// preFetch: true,
preFetch: true,
// app boot file (/poletani/boot)
// --> 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",
"vueInnerImageZoom",
"chartJs",
"apollo",
"chartJs"
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
@@ -55,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'],
node: 'node18'
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'
@@ -76,17 +82,13 @@ module.exports = configure(function (ctx) {
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
minify: true,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// extendViteConf(viteConf, {isServer}) {},
// viteVuePluginOptions: {},
vitePlugins: [
[ require("vite-plugin-rewrite-all").default ] // slouzi k podpore tecky v params v routeru
]
// vitePlugins: []
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
@@ -98,6 +100,7 @@ module.exports = configure(function (ctx) {
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {},
cssAddon: true,
iconSet: 'mdi-v6', // Quasar icon set
// lang: 'cs-CZ', // Quasar language pack
@@ -111,6 +114,7 @@ module.exports = configure(function (ctx) {
// Quasar plugins
plugins: [
'Cookies',
'Dialog',
'Meta',
'Notify'
@@ -119,7 +123,9 @@ module.exports = configure(function (ctx) {
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
animations: [
"fadeIn", "fadeOut"
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
sourceFiles: {
@@ -135,10 +141,11 @@ module.exports = configure(function (ctx) {
// https://v2.quasar.dev/quasar-cli/developing-ssr/configuring-ssr
ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
// extendSSRWebserverConf (esbuildConf) {},
extendSSRWebserverConf(esbuildConf) {
},
// extendPackageJson (json) {},
pwa: false,
+58
View File
@@ -0,0 +1,58 @@
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 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) => {
res.setHeader('Content-Type', 'text/html')
render(/* the ssrContext: */ { req, res })
.then(html => {
// now let's send the rendered html to the client
res.send(html)
})
.catch(err => {
// oops, we had an error while rendering the page
// we were told to redirect to another URL
if (err.url) {
if (err.code) {
res.redirect(err.code, err.url)
} else {
res.redirect(err.url)
}
} else if (err.code === 404) {
// hmm, Vue Router could not find the requested route
// Should reach here only if no "catch-all" route
// is defined in /src/routes
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
// to display a nice error page that contains the stack
// and other useful information
// 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
// (for security reasons, it's not ok to display the same wealth
// of information as we do in development)
// Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it
res.status(500).send('500 | Internal Server Error')
if (process.env.DEBUGGING) {
console.error(err.stack)
}
}
})
})
})
+157
View File
@@ -0,0 +1,157 @@
/**
* More info about this file:
* https://v2.quasar.dev/quasar-cli-vite/developing-ssr/ssr-webserver
*
* Runs in Node context.
*/
/**
* Make sure to yarn add / npm install (in your project root)
* anything you import here (except for express and compression).
*/
import express from 'express'
import compression from 'compression'
import {
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.
*
* Can be async: defineSsrCreate(async ({ ... }) => { ... })
*/
export const create = defineSsrCreate((/* { ... } */) => {
const app = express()
// attackers can use this header to detect apps running Express
// and then launch specifically-targeted attacks
app.disable('x-powered-by')
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD) {
app.use(compression())
}
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
* close the server with.
*
* The "listenResult" param for the "close()" definition below
* is what you return here.
*
* 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 = defineSsrListen(({ app, devHttpsApp, port }) => {
const server = devHttpsApp || app
return server.listen(port, () => {
if (process.env.PROD) {
console.log('Server listening at port ' + port)
}
})
})
/**
* Should close the server and free up any resources.
* Will be used on development only when the server needs
* to be rebooted.
*
* Should you need the result of the "listen()" call above,
* you can use the "listenResult" param.
*
* Can be async: defineSsrClose(async ({ listenResult }) => { ... })
*/
export const close = defineSsrClose(({ listenResult }) => {
return listenResult.close()
})
const maxAge = process.env.DEV
? 0
: 1000 * 60 * 60 * 24 * 30
/**
* 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 = 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$/
const cssRE = /\.css$/
const woffRE = /\.woff$/
const woff2RE = /\.woff2$/
const gifRE = /\.gif$/
const jpgRE = /\.jpe?g$/
const pngRE = /\.png$/
/**
* Should return a String with HTML output
* (if any) for preloading indicated 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}" crossorigin>`
}
if (woffRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
}
if (woff2RE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
}
if (gifRE.test(file) === true) {
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" crossorigin>`
}
if (pngRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
}
return ''
})
+7 -2
View File
@@ -1,19 +1,24 @@
<script setup>
import {useRoute} from "vue-router";
import {computed} from "vue";
import ErrorNotFound from "pages/ErrorNotFound.vue";
import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
import {storeToRefs} from "pinia";
import {useAppStore} from "stores/App";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
const route = useRoute();
const layoutComponent = computed(() => {
if (!route.meta) return null;
return route.meta.layout;
});
const appStatus = storeToRefs(useAppStore()).appStatus;
useMeta(() => metaData({
title: useAppStore().pageTitle,
}));
</script>
<template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View File
+149
View File
@@ -0,0 +1,149 @@
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 {useAuthStore} from "stores/Auth";
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 useAuthStore(store).refreshAccessToken()
.catch((error) => {
Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu."));
router.push({name: "login"})
})
.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);
}))
}
// 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 init = ({store, router}) => {
if (client) return // boot() already initialized this request's store
const uploadLink = new UploadHttpLink({
uri: process.env.API_URL + '/graphql',
credentials: 'include'
})
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));
}
}
}
})
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
})
.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})
});
+48 -43
View File
@@ -1,57 +1,62 @@
import {boot} from 'quasar/wrappers'
import axios from 'axios'
import {useUserStore} from "stores/User";
import {useAuthStore} from "stores/Auth";
const api = axios.create({
baseURL: process.env.API_URL,
withCredentials: true,
xsrfHeaderName: "X-CSRF-TOKEN",
headers: {
Accept: "application/json",
"Content-Type": "application/json;charset=UTF-8",
},
baseURL: process.env.API_URL,
withCredentials: true,
xsrfHeaderName: "X-CSRF-TOKEN",
headers: {
Accept: "application/json",
"Content-Type": "application/json;charset=UTF-8",
},
})
api.interceptors.request.use((config) => {
const token = useUserStore().getAccessToken();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
// 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
if (['/refresh', '/login'].includes(config.url)) {
delete config.headers['Authorization'];
}
export default boot(({store}) => {
if (requestInterceptorId !== undefined) {
api.interceptors.request.eject(requestInterceptorId)
}
if (responseInterceptorId !== undefined) {
api.interceptors.response.eject(responseInterceptorId)
}
return config;
})
requestInterceptorId = api.interceptors.request.use((config) => {
const token = useAuthStore(store).getAccessToken();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
// Response interceptor for API calls
api.interceptors.response.use((response) => {
return response
}, async function (error) {
const originalRequest = error.config;
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
const newToken = await useUserStore().refreshAccessToken().catch(err => {
console.error(err);
return Promise.reject("Logged out");
if (['/refresh', '/login'].includes(config.url)) {
delete config.headers['Authorization'];
}
return config;
})
api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
return api(originalRequest);
}
return Promise.reject(error);
});
export default boot(({app}) => {
// for use inside Vue files (Options API) through this.$axios and this.$api
// app.config.globalProperties.$axios = axios
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
// so you won't necessarily have to import axios in each vue file
app.config.globalProperties.$api = api
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
// so you can easily perform requests against your app's API
// Response interceptor for API calls
responseInterceptorId = api.interceptors.response.use(
(response) => response,
async function (error) {
const originalRequest = error.config;
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
const newToken = await useAuthStore(store).refreshAccessToken().catch(err => {
return Promise.reject("Logged out");
})
api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
return api(originalRequest);
}
return Promise.reject(error);
});
})
export {axios, api}
+2 -2
View File
@@ -2,5 +2,5 @@ import {boot} from "quasar/wrappers";
import {BarElement, CategoryScale, Chart as ChartJS, Filler, Legend, LinearScale, LineElement, PointElement, Title, Tooltip} from "chart.js";
export default boot(({app}) => {
ChartJS.register(Title, Tooltip, Legend, PointElement, LineElement, Filler, BarElement, CategoryScale, LinearScale)
})
ChartJS.register(Title, Tooltip, Legend, PointElement, LineElement, Filler, BarElement, CategoryScale, LinearScale);
});
+32
View File
@@ -0,0 +1,32 @@
import {Cookies} from 'quasar'
import {boot} from "quasar/wrappers";
import {defineStore} from 'pinia'
// 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)
});
+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,
});
});
-6
View File
@@ -1,6 +0,0 @@
import {boot} from "quasar/wrappers";
import InnerImageZoom from 'vue-inner-image-zoom';
export default boot(({app}) => {
app.component("inner-image-zoom", InnerImageZoom);
})
+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>
+69
View File
@@ -0,0 +1,69 @@
<script setup>
import {ref} from "vue";
const date = new Date();
const year = ref(date.getFullYear());
</script>
<template>
<q-footer class="q-pa-lg q-mt-xl">
<div class="row justify-center">
<div class="col-xs-12 col-md-4 text-center q-py-lg" style="font-size: 130%">
<q-img fit="contain" width="32px" src="/icons/favicon-32x32.png"/>
<div class="text-h5">Polétání.cz</div>
Vytvořeno s <q-icon name="favorite" color="red"/> nejen pro létání.
</div>
<div class="col-xs-6 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'about'}">O projektu</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'contact'}">Kontakt</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'features'}">Funkce</router-link>
</q-item>
</q-list>
</div>
<div class="col-xs-6 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'login'}">Login</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'registration'}">Registrace</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'forgottenPassword'}">Zapomenuté heslo</router-link>
</q-item>
</q-list>
</div>
<div class="col-xs-12 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'termsAndConditions'}">Podmínky použití</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'personalInformation'}">Správa osobních údajů</router-link>
</q-item>
</q-list>
</div>
</div>
<div class="row q-mt-lg">
<div class="col text-center">
&copy; {{ year }} Polétání.cz
</div>
</div>
</q-footer>
</template>
<style scoped>
a {
color: white;
font-size: 110%;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<script setup>
import TitlePhotoWithGallery from "components/photos/TitlePhotoWithGallery.vue";
const props = defineProps({
titlePhoto: {
type: Object,
required: false
},
title: {
type: String,
required: false
}
})
</script>
<template>
<q-card class="flight-detail-card q-mb-lg">
<TitlePhotoWithGallery
v-if="titlePhoto"
:photo="titlePhoto"
:title="title"
/>
<q-card-section v-else-if="title && title !== ''">
<h3>{{ title }}</h3>
</q-card-section>
<q-card-actions>
<slot name="actions"/>
</q-card-actions>
<slot name="content"/>
</q-card>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
const props = defineProps({
to: {
type: Object,
required: true
},
label: {
type: String,
required: false,
default: null
}
});
</script>
<template>
<div class="text-right">
<q-btn flat class="q-my-sm" :to="props.to" icon="edit" color="primary">
{{ props.label }}
</q-btn>
</div>
</template>
+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>
+48
View File
@@ -0,0 +1,48 @@
<script setup>
import {computed} from "vue";
import {useQuasar} from "quasar";
const props = defineProps({
isRequestPending: {
type: Boolean,
required: false,
default: false,
},
saveButton: {
type: [Object, Boolean],
required: false,
default: () => ({
label: "Uložit",
icon: "save",
align: "left"
})
}
})
const $q = useQuasar();
const fullwidthBtn = computed(() => $q.screen.width <= $q.screen.sizes.md);
</script>
<template>
<div class="text-right">
<q-btn
color="primary"
type="submit"
size="lg"
:class="{'q-my-lg': true, 'btn--fullwidth': fullwidthBtn}"
:icon="props.saveButton.icon"
:loading="props.isRequestPending"
>
{{ props.saveButton.label }}
<template v-slot:loading>
<q-spinner/>
</template>
</q-btn>
</div>
</template>
<style>
.btn--fullwidth {
width: 100%;
}
</style>
+7 -32
View File
@@ -1,13 +1,15 @@
<script setup>
import {isRequestPending} from "src/composables/form/props";
import {computed, useSlots} from "vue";
import {useQuasar} from "quasar";
import FormSubmitButton from "components/FormSubmitButton.vue";
const props = defineProps({
modelValue: {
type: Object,
},
isRequestPending,
isRequestPending: {
type: Boolean,
required: false,
default: false,
},
saveButton: {
type: [Object, Boolean],
required: false,
@@ -23,38 +25,11 @@ const emits = defineEmits(["save"]);
const saveForm = () => {
emits("save", props.modelValue);
}
const $q = useQuasar();
const fullwidthBtn = computed(() => $q.screen.width <= $q.screen.sizes.md);
</script>
<template>
<q-form @submit.prevent="saveForm" ref="form">
<slot name="default"/>
<div class="text-right">
<q-btn
color="primary"
type="submit"
size="lg"
:class="{'q-my-lg': true, 'btn--fullwidth': fullwidthBtn}"
v-if="saveButton"
:icon="props.saveButton.icon"
:loading="props.isRequestPending"
>
{{ props.saveButton.label }}
<template v-slot:loading>
<q-spinner />
</template>
</q-btn>
</div>
<FormSubmitButton v-if="saveButton" :save-button="saveButton" :is-request-pending="isRequestPending" />
</q-form>
</template>
<style>
.btn--fullwidth {
width: 100%;
}
</style>
+60
View File
@@ -0,0 +1,60 @@
<script setup>
import {commonProps} from "src/composables/inputs";
import {onMounted, ref, watch} from "vue";
import {debounce} from "quasar";
import {useRoute, useRouter} from "vue-router";
const props = defineProps({
emitDebounce: {
type: Number,
required: false,
default: 200
}
})
const emits = defineEmits(["filtersChanged"]);
const filters = ref({
search: ""
});
const $route = useRoute();
const $router = useRouter();
watch((() => filters.value), debounce(() => {
emits("filtersChanged", filters.value);
const query = {...$route.query};
for (const key in filters.value) {
if (filters.value[key] === "" || filters.value['key'] === null) {
delete query[key];
} else {
query[key] = filters.value[key];
}
}
$router.replace({query});
}, props.emitDebounce), {deep: true});
onMounted(() => {
const search = $route.query.search;
if (search) {
filters.value.search = search;
} else {
filters.value = {};
}
})
</script>
<template>
<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>
+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>
@@ -1,59 +0,0 @@
<script setup>
import {aircraft} from "src/composables/objects/aircraft";
import {confirmObject} from "src/composables/dialog";
import {useQuasar} from "quasar";
const props = defineProps({
aircraft: {
type: Object,
required: true
}
});
const emits = defineEmits(["delete"]);
const $q = useQuasar();
const confirmDelete = (aircraftId) => {
$q.dialog(confirmObject('Opravdu chceš smazat toto letadlo?'))
.onOk(() => {
emits("delete", aircraftId);
});
};
</script>
<template>
<q-card class="q-mx-md q-my-md flight-detail-card">
<q-img :src="aircraft.photoUrl" v-if="aircraft.photoUrl"/>
<q-card-actions class="q-my-md">
<div class="col-grow">
<q-btn
flat
:to='{name: "aircraftEdit", params: {id: aircraft.id}}'
icon="edit"
text-color="primary"
label="Upravit"
/>
</div>
<q-btn @click="confirmDelete(aircraft.id)" icon="delete" flat text-color="red" label="Smazat"/>
</q-card-actions>
<q-card-section>
<q-list>
<q-item v-if="aircraft.manufacturer">
Výrobce: {{ aircraft.manufacturer }}
</q-item>
<q-item v-if="aircraft.model">
Model: {{ aircraft.model }}
</q-item>
<q-item v-if="aircraft.seats">
Počet míst: {{ aircraft.seats }}
</q-item>
</q-list>
</q-card-section>
<q-card-section>
<article v-html="aircraft.description" class="q-my-md"/>
</q-card-section>
</q-card>
</template>
+44 -23
View File
@@ -2,9 +2,26 @@
import {onMounted, reactive} from "vue";
import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling";
import {defaultValues, isRequestPending} from "src/composables/form/props";
import {isNumber, isRequired, maxLength, minLength} from "src/composables/form/rules";
import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
const props = defineProps({
defaultValues: {
type: Object,
required: false,
default: () => ({})
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
},
organizations: {
type: Array,
required: true,
}
});
const formData = reactive({
id: null,
@@ -13,13 +30,8 @@ const formData = reactive({
seats: null,
manufacturer: "",
description: "",
photo: null,
organizationId: null
});
const props = defineProps({
defaultValues,
isRequestPending
organization: null,
isPublic: false
});
const rules = {
@@ -28,8 +40,12 @@ const rules = {
};
onMounted(() => {
if (Object.keys(props.defaultValues).length > 0)
if (Object.keys(props.defaultValues).length > 0) {
setDefaultData(formData, props.defaultValues);
if (props.defaultValues.organization) {
formData.organization = {id: props.defaultValues.organization.id, name: props.defaultValues.organization.name};
}
}
})
</script>
@@ -45,6 +61,23 @@ onMounted(() => {
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"
/>
<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."
/>
<q-input
type="text"
v-model="formData.manufacturer"
@@ -58,15 +91,6 @@ onMounted(() => {
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"
/>
<q-input
type="textarea"
v-model="formData.description"
@@ -74,10 +98,7 @@ onMounted(() => {
v-bind="commonProps"
/>
<q-file v-model="formData.photo" label="Fotografie" v-bind="commonProps">
<template #prepend>
<q-icon name="upload"/>
</template>
</q-file>
<q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/>
</FormWrapper>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import {useQuasar} from "quasar";
const props = defineProps({
aircraft: {
type: Object,
required: true
}
});
</script>
<template>
<q-list>
<q-item v-if="aircraft.organization">
Provozovatel: {{ aircraft.organization.name }}
</q-item>
<q-item v-if="aircraft.manufacturer">
Výrobce: {{ aircraft.manufacturer }}
</q-item>
<q-item v-if="aircraft.model">
Model: {{ aircraft.model }}
</q-item>
<q-item v-if="aircraft.seats">
Počet míst: {{ aircraft.seats }}
</q-item>
</q-list>
<article v-html="aircraft.description" class="q-my-md"/>
</template>
+41 -30
View File
@@ -1,42 +1,53 @@
<script setup>
import {useAppStore} from "stores/App";
import {useInfiniteScroll} from "src/composables/pagination";
const props = defineProps({
items: {
type: Array,
required: true
}
},
...useInfiniteScroll().propsDef
});
useAppStore().setPageTitle("Letadla");
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script>
<template>
<q-list bordered separator>
<q-item
clickable
v-ripple
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
v-for="aircraft of props.items" :key="aircraft"
>
<q-item-section avatar>
<q-avatar v-if="aircraft.photoUrl">
<img :src="aircraft.photoUrl" 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-section>
</q-item>
</q-list>
<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>
@@ -13,11 +13,11 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</q-card-section>
<q-card-section class="q-my-md">
<BasicTextSkeleton :paragraphs="1" :lines="randomInt(1, 3)"/>
<BasicTextSkeleton :paragraphs="1" :lines="2"/>
</q-card-section>
</q-card>
<q-card class="flight-detail-card" flat>
<BasicListSkeleton :items-count-min="1" :items-count-max="4"/>
<h3>Lety</h3>
<BasicListSkeleton :items="5" />
</q-card>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup>
const props = defineProps({
avatars: {
type: Array,
required: true
}
});
</script>
<template>
<div style="height: 60px">
<q-avatar
v-for="(avatar, idx) in avatars"
:key="idx"
class="overlapping"
:style="`left: ${(idx+1) * 20}px`"
>
<img :src="avatar"/>
</q-avatar>
</div>
</template>
<style lang="sass" scoped>
.overlapping
position: absolute
</style>
@@ -1,6 +1,4 @@
<script setup>
import {poi} from "src/composables/objects/poi";
const props = defineProps({
routeBack: {
type: Object,
+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">
@@ -1,5 +1,6 @@
<script setup>
import {randomInt} from "src/composables/random";
import {computed} from "vue";
const props = defineProps({
itemsCountMin: {
@@ -11,14 +12,19 @@ const props = defineProps({
type: Number,
required:false,
default: 5
},
items: {
type: Number,
required: false,
}
})
});
const renderedItems = computed(() => props.items || randomInt(props.itemsCountMin, props.itemsCountMax));
</script>
<template>
<q-list bordered separator>
<q-item v-ripple v-for="i in randomInt(itemsCountMin, itemsCountMax)" :key="i">
<q-item v-ripple v-for="i in renderedItems" :key="i">
<q-item-section avatar>
<q-skeleton type="QAvatar" />
</q-item-section>
+28 -3
View File
@@ -4,24 +4,35 @@ import {onMounted, reactive} from "vue";
import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs";
import {isRequestPending} from "src/composables/form/props";
import {isRequired} from "src/composables/form/rules";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({
copilot: {
type: Object
},
isRequestPending
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
name: null,
urlSlug: "",
isPublic: false,
});
const rules = {
name: [isRequired]
name: [isRequired],
urlSlug: [],
}
const loggedUser = storeToRefs(useLoggedUserStore()).user;
onMounted(() => {
if (props.copilot) {
setDefaultData(formData, props.copilot);
@@ -38,5 +49,19 @@ onMounted(() => {
:rules="rules.name"
v-model="formData.name"
/>
<template v-if="loggedUser?.publicUsername">
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser.publicUsername}/a/`"
:slug-source="formData.name"
:rules="rules.urlSlug"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
</FormWrapper>
</template>
+10 -5
View File
@@ -11,14 +11,19 @@ const props = defineProps({
<template>
<q-list bordered separator>
<q-item clickable v-ripple v-for="pilot of items" :key="pilot">
<q-item
clickable
:to="{name: 'copilotDetail', params: {id: pilot.id }}"
v-ripple
v-for="pilot of items" :key="pilot">
<q-item-section avatar>
<q-avatar icon="person" color="primary" text-color="white"/>
<q-avatar v-if="pilot.titlePhoto">
<img :src="pilot.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar v-else icon="person" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
<router-link :to="{name: 'copilotDetail', params: {id: pilot.id }}">
{{ pilot.name }}
</router-link>
{{ pilot.name }}
</q-item-section>
</q-item>
</q-list>
@@ -0,0 +1,8 @@
<script setup>
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
</script>
<template>
<q-skeleton type="rect" class="q-my-lg" style="width: 200px; height: 40px" />
<BasicListSkeleton :items="4"/>
</template>
@@ -1,5 +1,5 @@
<script setup></script>
<template>
<em>Copilot skeleton</em>
<q-skeleton type="QInput" />
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
import {FORMAT_DATE, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
const props = defineProps({
event: {
type: Object,
required: true
}
})
</script>
<template>
<q-chip icon="date_range" v-if="event.dateFrom || event.dateTo">
<template v-if="event.dateFrom !== event.dateTo">
{{ formatDatetime(event.dateFrom, FORMAT_DATE) }} - {{ formatDatetime(event.dateTo, FORMAT_DATE) }}
</template>
<template v-else>
{{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
</template>
</q-chip>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import MapWithMarkers from "components/map/MapWithMarkers.vue";
import {ref} from "vue";
import {useEventsStore} from "stores/Events";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {pluralize} from "src/composables/utils";
const props = defineProps({
eventId: {
type: Number,
required: false
},
eventUrlSlug: {
type: String,
required: false,
},
onlyPublic: {
type: Boolean,
required: true
}
})
const tracks = ref([]);
const totalTime = ref(0);
const totalFlights = ref(0);
useEventsStore().fetchFlightsTracks({
eventId: props.eventId,
eventUrlSlug: props.eventUrlSlug,
onlyPublic: props.onlyPublic
}).then((data) => {
if (!data.flights) return;
totalFlights.value = data.flights.length;
for (const flightTrack of data.flights) {
totalTime.value += flightTrack.durationMinCalculated;
if (flightTrack.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>
+78
View File
@@ -0,0 +1,78 @@
<script setup>
import {onMounted, reactive} from "vue";
import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules";
import RichTextEditor from "components/inputs/RichTextEditor.vue";
import DatetimePicker from "components/inputs/DatetimePicker.vue";
import {FORMAT_DATE, FORMAT_DATETIME_LONG} from "src/composables/datetimeUtils";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({
event: {
type: Object,
required: false,
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
name: "",
urlSlug: "",
isPublic: false,
description: "",
dateFrom: null,
dateTo: null,
});
const rules = {
name: [isRequired]
}
onMounted(() => {
if (props.event) {
setDefaultData(formData, props.event);
}
})
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</script>
<template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<q-input
v-bind="commonProps"
label="Jméno"
:rules="rules.name"
v-model="formData.name"
/>
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser?.publicUsername}/udalosti/`"
:slug-source="formData.name"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<div class="row">
<div class="col q-pr-sm">
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateFrom" label="Od"/>
</div>
<div class="col q-pl-sm">
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateTo" label="Do"/>
</div>
</div>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
</FormWrapper>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
import AvatarsGroup from "components/avatars/AvatarsGroup.vue";
const props = defineProps({
items: {
type: Array,
required: true
},
routeDetail: {
type: Object,
required: true,
},
});
const getCoverPhotos = (item) => item.flights.filter(f => !!f.titlePhoto).map(f => f.titlePhoto.thumbnailUrl);
</script>
<template>
<q-list bordered separator>
<q-item
v-for="eventDetail of items" :key="eventDetail"
clickable
:to="{name: props.routeDetail.name, params: {...props.routeDetail.params, eventSlug: eventDetail.urlSlug, id: eventDetail.id}}"
v-ripple
>
<q-item-section>
<q-item-label>
<q-avatar icon="event" color="primary" text-color="white" v-if="!eventDetail.flights || eventDetail.flights.length === 0"/>
<AvatarsGroup :avatars="getCoverPhotos(eventDetail)" v-else/>
{{ eventDetail.name }}
</q-item-label>
<q-item-label lines="4" caption>
<article v-html="eventDetail.description"/>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
@@ -0,0 +1,17 @@
<script setup>
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue";
</script>
<template>
<q-list bordered separator class="q-my-lg">
<ListItemSkeleton/>
<ListItemSkeleton/>
</q-list>
<BasicTextSkeleton :lines="3" :paragraphs="2"/>
<BasicListSkeleton :items="4"/>
</template>
@@ -0,0 +1,18 @@
<script setup>
</script>
<template>
<q-skeleton type="QInput" class="q-my-lg"/>
<div class="row">
<div class="col q-pr-sm">
<q-skeleton type="QInput"/>
</div>
<div class="col q-pl-sm">
<q-skeleton type="QInput"/>
</div>
</div>
<q-skeleton type="rect" class="q-my-lg" height="400px"/>
</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++;
}
+96 -13
View File
@@ -1,17 +1,37 @@
<script setup>
import {flight} from "src/composables/objects/flight";
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: {
type: Object,
required: true
},
showCopilots: {
type: Boolean,
copilotDetailRoute: {
type: Object,
required: false,
default: false
default: null
},
aircraftDetailRoute: {
type: Object,
required: false,
default: null
},
poiDetailRoute: {
type: Object,
required: false,
default: null
},
eventDetailRoute: {
type: Object,
required: false,
default: null
},
loggedUser: {
type: Object,
required: false,
default: null
}
})
</script>
@@ -23,7 +43,15 @@ const props = defineProps({
<q-icon name="flight"/>
</q-item-section>
<q-item-section>
{{ flight.aircraft.callSign }}
<router-link
:to="{...aircraftDetailRoute, params: {id: flight.aircraft.id, callSign: flight.aircraft.callSign}}"
v-if="flight.aircraft.isPublic"
>
{{ flight.aircraft.callSign }}
</router-link>
<template v-else>
{{ flight.aircraft.callSign }}
</template>
</q-item-section>
</q-item>
@@ -44,7 +72,9 @@ const props = defineProps({
</q-item-section>
<q-item-section>
<div>
<strong>{{flight.takeoffAirport.icaoCode}}</strong> ({{ flight.takeoffAirport.name }}),
<template v-if="flight.takeoffAirport">
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}),
</template>
čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/>
@@ -52,12 +82,29 @@ 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"/>
<q-icon name="place" v-if="poi.pointOfInterest"/>
<q-icon name="local_airport" v-if="poi.airport"/>
</q-item-section>
<q-item-section>
{{ poi.pointOfInterest.name }}
<template v-if="poi.pointOfInterest">
<router-link
:to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, slug: poi.pointOfInterest.urlSlug, id: poi.pointOfInterest.id}}"
v-if="poi.pointOfInterest.isPublic"
>
{{ poi.pointOfInterest.name }}
</router-link>
<template v-else>{{ poi.pointOfInterest.name }}</template>
</template>
<span v-if="poi.airport">
<strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}),
<template v-if="poi.landingDuration > 0">
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }}
</template>
<template v-else>touch and go</template>
</span>
</q-item-section>
</q-item>
@@ -67,7 +114,9 @@ const props = defineProps({
</q-item-section>
<q-item-section>
<div>
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}),
<template v-if="flight.landingAirport">
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}),
</template>
čas přistání: {{ formatDatetime(flight.landingDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/>
</div>
@@ -84,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">
@@ -91,9 +152,31 @@ const props = defineProps({
<q-icon name="people"/>
</q-item-section>
<q-item-section>
<router-link :to="{name: 'copilotDetail', params: {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>
@@ -1,20 +0,0 @@
<script setup>
import {randomInt} from "src/composables/random";
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
</script>
<template>
<q-card class="flight-detail-card">
<q-skeleton type="rect" height="400px"/>
<q-card-section>
<q-skeleton type="QInput" v-for="i in 3" :key="i" />
</q-card-section>
</q-card>
<q-card class="flight-detail-card" flat>
<BasicListSkeleton :items-count-min="1" :items-count-max="4"/>
</q-card></template>
@@ -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>
+65 -55
View File
@@ -1,65 +1,56 @@
<script setup>
import {flight} from "src/composables/objects/flight";
import MapWithMarkers from "components/map/MapWithMarkers.vue";
import {computed, ref} from "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,
}
})
const mapPhotos = computed(() => flight.value.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
const poisWithGPS = computed(() => {
return flight.value.track.filter(poi => poi.pointOfInterest.gpsLatitude && poi.pointOfInterest.gpsLongitude)
});
const mapPhotos = computed(() => props.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
const poisWithGPS = computed(() => props.flight.turnPoints.map(poi => poi.pointOfInterest || poi.airport));
const pointsOfInterests = computed(() => {
const path = [{
label: flight.value.takeoffAirport.name,
position:
{
lat: flight.value.takeoffAirport.gpsLatitude,
lng: flight.value.takeoffAirport.gpsLongitude
}
}];
const mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]);
const pointsOfInterests = computed(() => mapMarkers
.filter(marker => marker && marker.gpsLatitude && marker.gpsLongitude)
.map(
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
));
for (const poi of poisWithGPS.value) {
path.push({
label: poi.pointOfInterest.name,
position: {lat: poi.pointOfInterest.gpsLatitude, lng: poi.pointOfInterest.gpsLongitude}
});
}
path.push({
label: flight.value.landingAirport.name,
position: {
lat: flight.value.landingAirport.gpsLatitude,
lng: flight.value.landingAirport.gpsLongitude
}
});
return path;
});
const gpxTrack = flight.value.gpxTrack;
// 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 ||
flight.value.landingAirport.id !== flight.value.takeoffAirport.id
props.flight.landingAirport.id !== props.flight.takeoffAirport.id
)
});
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();
});
@@ -69,7 +60,7 @@ if (gpxTrack) {
backgroundColor: "#38bd3e",
borderColor: "#38bd3e",
label: "Rychlost (km/h)",
data: gpxTrack.speed,
data: speed,
pointRadius: 1,
}]
};
@@ -80,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,
});
@@ -101,13 +92,13 @@ if (gpxTrack) {
const currentPositionMarker = ref(null);
const drawCurrentPositionMarker = (e) => {
const res = e.chart.getElementsAtEventForMode(e, 'nearest', {intersect: false},);
const res = e.chart.getElementsAtEventForMode(e, 'nearest', {intersect: false});
if (res.length === 0) {
currentPositionMarker.value = null;
return;
}
const idx = res[0].index;
currentPositionMarker.value = flight.value.gpxTrack.coordinates[idx];
currentPositionMarker.value = props.flight.track.points[idx].coordinates;
}
const chartOptions = {
@@ -120,19 +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>
<MapWithMarkers
v-if="displayMap"
style="width: 100%; height: 500px;"
:gpx-track="flight.gpxTrack?.coordinates || []"
:markers="pointsOfInterests"
:highlight-position="currentPositionMarker"
:photos="mapPhotos"
/>
<q-no-ssr>
<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>
+86 -41
View File
@@ -1,59 +1,104 @@
<script setup>
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {flightName} from "src/composables/objects/flight";
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
import {flightName} from "src/composables/flight";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import {useInfiniteScroll} from "src/composables/pagination";
const props = defineProps({
items: {
type: Array,
required: true
},
routeDetail: {
type: Object,
required: false,
default: () => ({name: 'flightDetail', params: {}})
},
displayVisibilityInfo: {
public: {
type: Boolean,
required: false,
default: true
}
required: true
},
...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}}
};
</script>
<template>
<q-list bordered separator>
<q-item
v-for="flight of items" :key="flight"
ripple
clickable
:to="{name: routeDetail.name, params:{...routeDetail.params, id: flight.id}}"
>
<q-item-section avatar>
<AvatarFromPhoto :size="120" v-if="flight.coverPhoto" :src="flight.coverPhoto.thumbnailUrl"/>
</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-secondary" v-if="flight.copilot">
<q-icon name="person"/>
{{ flight.copilot.name }}
</div>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<div class="text-secondary">
<q-icon name="schedule"/>
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
</div>
<div class="text-secondary">
<q-icon name="flight"/>
{{ flight.aircraft.callSign }}
</div>
</q-item-section>
<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 }}
</div>
<q-item-section side top v-if="flight.isPublic && displayVisibilityInfo">
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
</q-list>
<div class="text-secondary" v-if="flight.copilots">
<q-icon name="person"/>
{{ flight.pilot.name }}
{{ flight.copilots }}
</div>
<div class="text-secondary">
<q-icon name="schedule"/>
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
</div>
<div class="text-secondary">
<q-icon name="flight"/>
{{ flight.aircraft.callSign }}
</div>
<div class="text-secondary" v-if="flight.event">
<q-icon name="event"/>
{{ flight.event.name }}
</div>
</div>
</q-item-section>
<q-item-section side top v-if="!props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility" v-if="flight.isPublic"/>
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
<q-tooltip>
Let je sice publikovaný, nemá ale nastavenou URL adresu!
</q-tooltip>
</q-icon>
</q-item-section>
</q-item>
</q-list>
<template v-slot:loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px"/>
</div>
</template>
</q-infinite-scroll>
</template>
<style>
.list-secondary-info div {
padding: 0 1em;
}
</style>
@@ -0,0 +1,62 @@
<script setup>
import {ref} from "vue";
import FlightHeaderInfo from "components/flights/FlightHeaderInfo.vue";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
const props = defineProps({
items: {
type: Array,
required: true
}
});
const slide = ref(0);
</script>
<template>
<div class="bg-blue-1 q-py-lg">
<h3 class="text-center">Poslední lety</h3>
<q-carousel
v-model="slide"
swipeable
animated
control-color="primary"
navigation
padding
arrows
height="500px"
class="bg-blue-1 text-white"
>
<q-carousel-slide :name="idx" v-for="(flight, idx) in items" :key="idx" class="column no-wrap flex-center text-center">
<q-img
:src="flight.titlePhoto.url"
fit="cover"
ratio="16/9"
style="max-width: 1000px;"
height="400px"
v-if="flight.titlePhoto"
>
<div class="absolute-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>
</q-carousel-slide>
</q-carousel>
</div>
</template>
-58
View File
@@ -1,58 +0,0 @@
<script setup>
import PhotoDetail from "components/photos/PhotoDetail.vue";
import { ref, watch} from "vue";
const props = defineProps({
photos: {
type: Array,
required: true
},
coverPhotoUrl: {
type: String,
required: false
},
defaultTitle: {
type: String,
required: false
}
})
const displayedPhoto = ref(null);
const openPhotoDetail = ref(false);
const photoData = ref(null);
const openDetail = ({photo, idx, allPhotos}) => {
openPhotoDetail.value = true;
photoData.value = {photo, idx, allPhotos}
}
watch(() => props.photos, (newVal) => {
if (newVal.length === 0) return;
displayedPhoto.value = props.coverPhotoUrl || newVal[0].url;
}, {immediate: true});
</script>
<template>
<q-carousel
v-if="photos.length > 0"
animated
v-model="displayedPhoto"
arrows
swipeable
height="auto"
infinite
v-bind="$attrs"
>
<q-carousel-slide style="padding: 0" v-for="(photo, idx) in photos" :key="photo.url" :name="photo.url">
<q-img :src="photo.url" @click="openDetail({photo, idx, allPhotos: photos})"/>
</q-carousel-slide>
</q-carousel>
<PhotoDetail
v-if="photoData"
v-model="openPhotoDetail"
:default-title="defaultTitle"
:photo="photoData.photo"
:photos="photoData.allPhotos"
:opened-index="photoData.idx"
/>
</template>
+82 -77
View File
@@ -6,7 +6,6 @@ import DatetimePicker from "components/inputs/DatetimePicker.vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules";
import {isRequestPending} from "src/composables/form/props";
const props = defineProps({
aircrafts: {
@@ -21,19 +20,29 @@ const props = defineProps({
type: Object,
required: false
},
isRequestPending,
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
});
const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
const airports = computed(() => props.airports.map(a => ({id: a.id, name: airportName(a)})));
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
const manualInput = ref(false);
const allowGPXUpload = ref(true);
const formData = reactive({
aircraft: null,
gpxTrackFile: null,
takeoffAirport: null,
landingAirport: null,
takeoffDatetime: null,
landingDatetime: null,
landings: 1,
isPublic: false,
name: "",
description: "",
});
const rules = {
@@ -43,19 +52,21 @@ const rules = {
landingDatetime: [isRequired],
landingAirport: [isRequired],
};
watch(() => props.flight, (newVal) => {
if (newVal) {
setDefaultData(formData, newVal);
formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign};
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: newVal.takeoffAirport.name}
formData.landingAirport = {id: newVal.landingAirport.id, name: newVal.landingAirport.name}
if (newVal.takeoffAirport)
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
if (newVal.landingAirport)
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)}
allowGPXUpload.value = false;
manualInput.value = true;
}
}, {immediate: true});
const aircrafts = computed(() => {
return props.aircrafts.map(a => ({id: a.id, name: a.callSign}));
});
watch(() => formData.takeoffDatetime, () => {
if (!formData.landingDatetime) {
formData.landingDatetime = structuredClone(formData.takeoffDatetime);
@@ -67,81 +78,75 @@ watch(() => formData.takeoffAirport, () => {
formData.landingAirport = {...formData.takeoffAirport};
}
});
</script>
<template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<div class="row">
<div class="col">
<q-card class="q-mx-md q-my-md">
<q-card-section>
<div class="text-h6">Vyber letadlo</div>
</q-card-section>
<q-card-section>
<ComboboxInput
:rules="rules.aircraft"
v-bind="commonProps"
:options="aircrafts"
v-model="formData.aircraft"
label="Letadlo"
/>
</q-card-section>
</q-card>
<ComboboxInput
:rules="rules.aircraft"
v-bind="commonProps"
:options="aircrafts"
v-model="formData.aircraft"
label="Letadlo"
/>
</div>
</div>
<div class="row">
<div class="col col-12 col-md-6">
<q-card class="q-mx-md q-my-md">
<q-card-section>
<div class="text-h6">Vzlet</div>
</q-card-section>
<q-card-section>
<DatetimePicker
v-model="formData.takeoffDatetime"
:rules="rules.takeoffDatetime"
label="Datum a čas vzletu: "
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
:rules="rules.takeoffAirport"
v-model="formData.takeoffAirport"
label="Letiště vzletu"
v-bind="commonProps"
:enable-new-values="false"
/>
</q-card-section>
</q-card>
</div>
<div class="row" v-if="!manualInput">
<div class="col">
<div class="text-h6">Mám trasu v GPX, chci importovat data</div>
<q-file v-bind="commonProps" v-model="formData.gpxTrackFile" label="Trasa v GPX" hint="Z trasy se načtou letiště a časy vzletu a přistání. Tyto hodnoty je možné později upravit."/>
<div class="col col-12 col-md-6">
<q-card class="q-mx-md q-my-md">
<q-card-section>
<div class="text-h6">Přistání</div>
</q-card-section>
<q-card-section>
<DatetimePicker
v-model="formData.landingDatetime"
:min-date="formData.takeoffDatetime"
:rules="rules.landingDatetime"
label="Datum a čas přistání"
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
v-model="formData.landingAirport"
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
:enable-new-values="false"
/>
</q-card-section>
</q-card>
<div class="text-center">
<q-btn @click="manualInput = true" rounded flat>Zadat informace ručně</q-btn>
</div>
</div>
</div>
<div class="row" v-if="manualInput">
<div class="col col-12 col-md-6 q-pr-md-sm">
<div class="text-h6">Vzlet</div>
<DatetimePicker
v-model="formData.takeoffDatetime"
:rules="rules.takeoffDatetime"
label="Datum a čas vzletu: "
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
:rules="rules.takeoffAirport"
v-model="formData.takeoffAirport"
label="Letiště vzletu"
v-bind="commonProps"
/>
</div>
<div class="col col-12 col-md-6 q-pl-md-sm">
<div class="text-h6">Přistání</div>
<DatetimePicker
v-model="formData.landingDatetime"
:min-date="formData.takeoffDatetime"
:rules="rules.landingDatetime"
label="Datum a čas přistání"
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
v-model="formData.landingAirport"
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
/>
</div>
<div class="col-12 text-center" v-if="allowGPXUpload">
<q-btn @click="manualInput = false" rounded flat>Mám GPX, načíst informace z trasy</q-btn>
</div>
</div>
</FormWrapper>
</template>
@@ -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>
+117 -12
View File
@@ -1,35 +1,140 @@
<script setup>
import {onMounted, ref} from "vue";
import {reactive, ref, watch} from "vue";
import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling";
import RichTextEditor from "components/inputs/RichTextEditor.vue";
import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const props = defineProps({
flight: {
type: Object,
required: false
},
events: {
type: Array,
required: false,
},
copilots: {
type: Array,
required: false,
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
});
const formData = ref({
const formData = reactive({
name: "",
description: ""
event: null,
description: "",
debriefingNotes: "",
urlSlug: "",
isPublic: null,
copilots: []
})
const isMounted = ref(false);
onMounted(() => {
if (props.flight)
setDefaultData(formData.value, props.flight);
isMounted.value = true;
})
const loggedUser = storeToRefs(useLoggedUserStore()).user;
watch(() => props.flight, (newVal) => {
if (newVal) {
setDefaultData(formData, newVal);
if (newVal.event) {
formData.event = {id: newVal.event.id, name: newVal.event.name};
}
}
}, {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>
<template>
<FormWrapper v-model="formData" v-if="isMounted">
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps" />
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..." />
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/>
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
<template v-if="loggedUser?.publicUsername">
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser.publicUsername}/`"
:slug-source="formData.name"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
<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-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>
</q-tab-panels>
</FormWrapper>
</template>
@@ -1,7 +1,6 @@
<script setup>
import FormWrapper from "components/FormWrapper.vue";
import {reactive} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
const props = defineProps({
flight: {
@@ -17,6 +16,10 @@ const props = defineProps({
const formData = reactive({
photos: []
});
const inlinePreview = (photo) => {
return URL.createObjectURL(photo);
}
</script>
<template>
@@ -26,6 +29,18 @@ const formData = reactive({
align: "left"
}'>
<div class="flex flex-center">
<div v-for="photo in formData.photos" :key="photo.name" style="width: 70px" class="q-ma-sm">
<q-img
:src="inlinePreview(photo)"
fit="fill"
>
<div class="absolute-top absolute-bottom text-center" v-if="isRequestPending">
<q-spinner/>
</div>
</q-img>
</div>
</div>
<q-file
class="q-my-lg"
outlined
@@ -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>
+82 -21
View File
@@ -1,8 +1,9 @@
<script setup>
import FormWrapper from "components/FormWrapper.vue";
import {onMounted, reactive, ref, watch} from "vue";
import {reactive, ref, watch} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
const props = defineProps({
modelValue: {
@@ -12,6 +13,10 @@ const props = defineProps({
type: Array,
required: true
},
airports: {
type: Array,
required: true
},
flight: {
type: Object,
required: false
@@ -20,45 +25,65 @@ const props = defineProps({
const formData = reactive({
track: [],
gpxTrack: null
gpxTrackFile: null
});
const removeTrackItem = (index) => {
formData.track.splice(index, 1);
}
const poi = ref(null);
const addTrackItem = (item) => {
formData.track.push(item);
const airport = ref(null);
const landingDuration = ref(null);
const addTrackPOIItem = (item) => {
formData.track.push({pointOfInterest: item});
poi.value = null;
}
const addAirportToTrack = (airportData, duration) => {
formData.track.push({airport: airportData, landingDuration: parseInt(duration)})
airport.value = null;
}
watch(poi, (newVal) => {
if (newVal?.name) {
addTrackItem(newVal);
addTrackPOIItem(newVal);
}
});
onMounted(() => {
for (const i in props.flight.track) {
const poiInfo = props.flight.track[i].pointOfInterest;
formData.track.push({
id: poiInfo.id,
name: poiInfo.name
});
watch(() => props.flight.turnPoints, (newVal) => {
formData.track = [];
for (const i in newVal) {
const trackInfo = newVal[i];
const info = {landingDuration: trackInfo.landingDuration};
if (trackInfo.pointOfInterest) {
info['pointOfInterest'] = {
id: trackInfo.pointOfInterest.id,
name: trackInfo.pointOfInterest.name,
};
}
if (trackInfo.airport) {
info['airport'] = {
id: trackInfo.airport.id,
name: trackInfo.airport.name,
}
}
formData.track.push(info);
}
});
}, {immediate: true});
</script>
<template>
<FormWrapper v-model="formData">
<q-banner class="bg-green text-white" v-if="flight.gpxTrackUrl">
<q-banner class="bg-green text-white" v-if="flight.track">
<q-icon name="check"/>
Trasa nahrána
</q-banner>
<q-file
accept=".gpx"
v-model="formData.gpxTrack"
v-model="formData.gpxTrackFile"
v-bind="commonProps"
label="Nahrát trasu v GPX"
hint="Slouží pro vykreslení kompletní a detailní trasy"
@@ -68,17 +93,46 @@ onMounted(() => {
</template>
</q-file>
<h5>Body na mapě</h5>
<ComboboxInput
v-bind="commonProps"
v-model="poi"
:key="formData.track.length"
:options="locations"
@select="addTrackItem"
@select="addTrackPOIItem"
clearable
label="Přidat otočný bod"
hint="Nastav významné otočné body"
/>
<div class="row">
<div class="col-12 col-md-8">
<ComboboxInput
v-bind="commonProps"
v-model="airport"
:key="formData.track.length"
:options="airports"
clearable
label="Letiště pro mezipřistání"
hint="Nastav letiště a dobu mezipřistání"
/>
</div>
<div class="col-md-4 col-12" v-if="airport?.name">
<q-input
type="number"
v-bind="commonProps"
v-model="landingDuration"
label="Délka mezipřistání (minuty)"
class="q-mx-md-md"
>
<template #after>
<q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn>
</template>
</q-input>
</div>
</div>
<q-list bordered>
<q-item-label header>Trasa</q-item-label>
<q-item>
@@ -86,7 +140,7 @@ onMounted(() => {
<q-avatar color="primary" text-color="white" icon="flight_takeoff"/>
</q-item-section>
<q-item-section>
{{ flight.takeoffAirport.name }}
{{ flight.takeoffAirport?.name || '???' }}
</q-item-section>
</q-item>
@@ -95,13 +149,20 @@ onMounted(() => {
<q-avatar size="32px" color="secondary" text-color="white">{{ index + 1 }}.</q-avatar>
</q-item-section>
<q-item-section>
<span>
{{ item.name }}
<span v-if="item.pointOfInterest">
{{ item.pointOfInterest.name }}
<q-badge color="green" v-if="!item.id">
<q-badge color="green" v-if="!item.pointOfInterest.id">
<q-icon name="add"/>
</q-badge>
</span>
<span v-if="item.airport">
{{ item.airport.name }},
<template v-if="item.landingDuration > 0">
mezipřistání {{ minutesToHumanReadable(item.landingDuration) }}
</template>
<template v-else>touch and go</template>
</span>
</q-item-section>
<q-item-section top side>
@@ -114,7 +175,7 @@ onMounted(() => {
<q-avatar color="primary" text-color="white" icon="flight_land"/>
</q-item-section>
<q-item-section>
{{ flight.landingAirport.name }}
{{ flight.landingAirport?.name || '???' }}
</q-item-section>
</q-item>
</q-list>
@@ -1,5 +1,4 @@
<script setup>
import {randomInt} from "src/composables/random";
import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue";
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</script>
@@ -16,7 +15,7 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
<q-item>
<q-list>
<ListItemSkeleton v-for="i in randomInt(1, 3)" :key="i"/>
<ListItemSkeleton v-for="i in 3" :key="i"/>
</q-list>
</q-item>
@@ -26,7 +25,7 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</q-list>
<article class="q-my-lg">
<BasicTextSkeleton :lines="randomInt(2, 5)" :paragraphs="randomInt(1, 2)" />
<BasicTextSkeleton :lines="7" :paragraphs="2" />
</article>
<q-skeleton height="400px" square class="q-mt-lg"/>
@@ -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>
@@ -1,6 +1,7 @@
<script setup>
</script>
<template>
<em>POI edit skeleton</em>
edit copilot skeleton
</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>
@@ -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>

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