Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8ef6b985 | ||
|
|
160bc1b031 | ||
|
|
ae7072ddee | ||
|
|
f7845b963d | ||
|
|
65ea19664c | ||
|
|
348025afee | ||
|
|
33e7aa6e36 | ||
|
|
ea88a29750 | ||
|
|
448d97e310 | ||
|
|
018153aff1 | ||
|
|
87c7f17116 | ||
|
|
532aee9f32 | ||
|
|
92c9e53ea0 | ||
|
|
be977ee241 | ||
|
|
bffdd96be2 | ||
|
|
86e8dbf8e7 | ||
|
|
786c59cc4a | ||
|
|
7011aa3223 | ||
|
|
e8dc51a80d | ||
|
|
88a760846c | ||
|
|
a8ad8467ce | ||
|
|
ba1089af3d | ||
|
|
16650cc0cb | ||
|
|
25b1735c87 | ||
|
|
5280f60f34 | ||
|
|
ffbb3ff178 | ||
|
|
e01bafee73 | ||
|
|
852d00b932 | ||
|
|
aa6bed6f7f | ||
|
|
70bc57d3c7 | ||
|
|
32ff47a3a7 | ||
|
|
97d1787642 | ||
|
|
8cc1746be7 | ||
|
|
3c51631bcd |
@@ -28,3 +28,5 @@ yarn-error.log*
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
quasar.config.js.temporary.*
|
||||||
|
|||||||
+6
-3
@@ -1,5 +1,5 @@
|
|||||||
# develop stage
|
# develop stage
|
||||||
FROM node:20-alpine as develop-stage
|
FROM node:22-alpine AS develop-stage
|
||||||
|
|
||||||
ENV TZ=Europe/Prague
|
ENV TZ=Europe/Prague
|
||||||
|
|
||||||
@@ -12,12 +12,15 @@ RUN npx update-browserslist-db@latest
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# build stage
|
# build stage
|
||||||
FROM develop-stage as build-stage
|
FROM develop-stage AS build-stage
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
|
ENV API_URL "https://api.poletani.cz"
|
||||||
|
ENV GOOGLE_MAPS_API_KEY "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
|
||||||
|
|
||||||
RUN quasar build -m ssr
|
RUN quasar build -m ssr
|
||||||
|
RUN cp /app/dist/ssr/server/server-entry.mjs /app/dist/ssr/server/server-entry.js
|
||||||
|
|
||||||
FROM node:20-alpine as production-stage
|
FROM node:22-alpine AS production-stage
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
ENV TZ=Europe/Prague
|
ENV TZ=Europe/Prague
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ version: "3.4"
|
|||||||
services:
|
services:
|
||||||
poletani-app:
|
poletani-app:
|
||||||
image: "docker.kvacek.cz/poletani/app:latest"
|
image: "docker.kvacek.cz/poletani/app:latest"
|
||||||
# build:
|
|
||||||
# context: .
|
|
||||||
# dockerfile: Dockerfile
|
|
||||||
# target: "production-stage"
|
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
# - 9000:80
|
|
||||||
- 9000:3000
|
- 9000:3000
|
||||||
|
|||||||
+2
-35
@@ -1,30 +1,5 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
#
|
|
||||||
# db:
|
|
||||||
# image: mariadb:10.11
|
|
||||||
# ports:
|
|
||||||
# - 3306:3306
|
|
||||||
# environment:
|
|
||||||
# - MYSQL_USER=root
|
|
||||||
# - MYSQL_DATABASE=ull_tracker
|
|
||||||
# - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=true
|
|
||||||
# volumes:
|
|
||||||
# - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
|
||||||
# - db-data:/var/lib/mysql:rw
|
|
||||||
# api:
|
|
||||||
# build:
|
|
||||||
# context: ../api
|
|
||||||
# ports:
|
|
||||||
# - 8000:8000
|
|
||||||
# volumes:
|
|
||||||
# - ../api:/app
|
|
||||||
# environment:
|
|
||||||
# TZ: "Europe/Prague"
|
|
||||||
## command: ls -l /app
|
|
||||||
|
|
||||||
app:
|
app:
|
||||||
network_mode: host
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: "develop-stage"
|
target: "develop-stage"
|
||||||
@@ -32,19 +7,11 @@ services:
|
|||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
- "9001:9100"
|
- "9001:9100"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
- ./:/app
|
||||||
- ./src-ssr:/app/src-ssr
|
|
||||||
- ./public:/app/public
|
|
||||||
# - ./node_modules:/app/node_modules:delegated
|
|
||||||
command: quasar dev # -m ssr
|
command: quasar dev # -m ssr
|
||||||
environment:
|
environment:
|
||||||
API_URL: "http://127.0.0.1:8000"
|
API_URL: "http://127.0.0.1:8000"
|
||||||
|
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
|
||||||
# nginx:
|
|
||||||
# image:
|
|
||||||
#
|
|
||||||
#
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data: { }
|
db-data: { }
|
||||||
|
|||||||
+16
@@ -11,8 +11,24 @@
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
||||||
<link rel="icon" type="image/ico" href="favicon.ico">
|
<link rel="icon" type="image/ico" href="favicon.ico">
|
||||||
|
|
||||||
|
<!-- Google Tag Manager -->
|
||||||
|
<script v-if="process.env.NODE_ENV === 'production'">
|
||||||
|
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||||
|
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||||
|
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||||
|
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||||
|
})(window,document,'script','dataLayer','GTM-WC8BTPJ4');
|
||||||
|
</script>
|
||||||
|
<!-- End Google Tag Manager -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Google Tag Manager (noscript) -->
|
||||||
|
<noscript v-if="process.env.NODE_ENV === 'production'">
|
||||||
|
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WC8BTPJ4" height="0" width="0" style="display:none;visibility:hidden"></iframe>
|
||||||
|
</noscript>
|
||||||
|
<!-- End Google Tag Manager (noscript) -->
|
||||||
|
|
||||||
<!-- quasar:entry-point -->
|
<!-- quasar:entry-point -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+37
-22
@@ -10,34 +10,49 @@
|
|||||||
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore"
|
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.7.14",
|
"@apollo/client": "^4.0.5",
|
||||||
"@quasar/extras": "^1.15.11",
|
"@quasar/extras": "^1.16.16",
|
||||||
"@vue/apollo-composable": "4.0.0-beta.2",
|
"@sentry/browser": "^10.14.0",
|
||||||
"@vue/apollo-option": "^4.0.0-beta.5",
|
"@sentry/integrations": "^7.114.0",
|
||||||
"apollo-upload-client": "^17.0.0",
|
"@sentry/vue": "^10.14.0",
|
||||||
"axios": "^1.6.2",
|
"@vue/apollo-composable": "4.2.2",
|
||||||
"chart.js": "^4.4.1",
|
"@vue/apollo-option": "^4.2.0",
|
||||||
"graphql": "16.6.0",
|
"apollo-upload-client": "^19.0.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"chart.js": "^4.4.7",
|
||||||
|
"graphql": "16.11.0",
|
||||||
"graphql-tag": "2.12.6",
|
"graphql-tag": "2.12.6",
|
||||||
"photoswipe": "^5.4.2",
|
"photoswipe": "^5.4.4",
|
||||||
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
||||||
"pinia": "^2.0.33",
|
"pinia": "^3.0.3",
|
||||||
"quasar": "^2.14.1",
|
"quasar": "2.18.2",
|
||||||
"vue": "^3.3.11",
|
"rxjs": "^7.8.2",
|
||||||
"vue-chartjs": "^5.2.0",
|
"vue": "3.5.21",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-picture-cropper": "^0.7.0",
|
"vue-picture-cropper": "^0.7.0",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.5.0",
|
||||||
"vue3-google-map": "^0.15.0"
|
"vue3-google-map": "^0.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@quasar/app-vite": "^1.7.1",
|
"@quasar/app-vite": "2.4.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-config-prettier": "^8.10.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-vue": "^9.19.2",
|
"eslint-plugin-vue": "^10.5.0",
|
||||||
"postcss": "^8.4.14",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^2.8.8"
|
"prettier": "^3.6.2"
|
||||||
},
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"last 10 Chrome versions",
|
||||||
|
"last 10 Firefox versions",
|
||||||
|
"last 4 Edge versions",
|
||||||
|
"last 7 Safari versions",
|
||||||
|
"last 8 Android versions",
|
||||||
|
"last 8 ChromeAndroid versions",
|
||||||
|
"last 8 FirefoxAndroid versions",
|
||||||
|
"last 10 iOS versions",
|
||||||
|
"last 5 Opera versions"
|
||||||
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"npm": ">= 6.13.4",
|
"npm": ">= 6.13.4",
|
||||||
"yarn": ">= 1.21.1"
|
"yarn": ">= 1.21.1"
|
||||||
|
|||||||
@@ -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')
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -3,3 +3,9 @@ apps:
|
|||||||
script: /app/index.js
|
script: /app/index.js
|
||||||
exec_mode: cluster
|
exec_mode: cluster
|
||||||
instances: 3
|
instances: 3
|
||||||
|
# Pojistka proti bodu 1 (a podobným leakům): pokud paměť workeru
|
||||||
|
# přeroste limit, PM2 ho sám restartuje dřív, než dojde k OOM na
|
||||||
|
# celém VPS. Hodnotu je potřeba doladit dle reálné spotřeby jednoho
|
||||||
|
# SSR workeru po opravě bodu 1 (pm2 monit / pm2 list) - viz
|
||||||
|
# handoffs/05-pm2-docker-memory-limits.md.
|
||||||
|
max_memory_restart: '400M'
|
||||||
|
|||||||
+13
-8
@@ -8,8 +8,7 @@
|
|||||||
// Configuration for your app
|
// Configuration for your app
|
||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||||
|
|
||||||
const {configure} = require('quasar/wrappers');
|
import { configure } from 'quasar/wrappers'
|
||||||
|
|
||||||
|
|
||||||
module.exports = configure(function (ctx) {
|
module.exports = configure(function (ctx) {
|
||||||
return {
|
return {
|
||||||
@@ -29,6 +28,12 @@ module.exports = configure(function (ctx) {
|
|||||||
// --> boot files are part of "main.js"
|
// --> boot files are part of "main.js"
|
||||||
// https://v2.quasar.dev/quasar-cli/boot-files
|
// https://v2.quasar.dev/quasar-cli/boot-files
|
||||||
boot: [
|
boot: [
|
||||||
|
// client-only: Sentry browser SDK (browserTracingIntegration,
|
||||||
|
// replayIntegration) is DOM-only and has no business running per SSR
|
||||||
|
// request - server: false means Quasar excludes this boot file from
|
||||||
|
// the server bundle entirely (not just a runtime guard).
|
||||||
|
// See handoffs/04-sentry-ssr-guard.md.
|
||||||
|
{path: "sentry", server: false},
|
||||||
"cookies",
|
"cookies",
|
||||||
"axios",
|
"axios",
|
||||||
"apollo",
|
"apollo",
|
||||||
@@ -56,13 +61,13 @@ module.exports = configure(function (ctx) {
|
|||||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
||||||
build: {
|
build: {
|
||||||
target: {
|
target: {
|
||||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
|
||||||
node: 'node20'
|
node: 'node20'
|
||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
API_URL: ctx.dev ? 'http://localhost:8000' : 'https://api.poletani.cz',
|
API_URL: process.env.API_URL,
|
||||||
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
|
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY
|
||||||
},
|
},
|
||||||
|
|
||||||
vueRouterMode: 'history', // available values: 'hash', 'history'
|
vueRouterMode: 'history', // available values: 'hash', 'history'
|
||||||
@@ -80,8 +85,7 @@ module.exports = configure(function (ctx) {
|
|||||||
minify: true,
|
minify: true,
|
||||||
// polyfillModulePreload: true,
|
// polyfillModulePreload: true,
|
||||||
// distDir
|
// distDir
|
||||||
|
// extendViteConf(viteConf, {isServer}) {},
|
||||||
// extendViteConf (viteConf) {},
|
|
||||||
// viteVuePluginOptions: {},
|
// viteVuePluginOptions: {},
|
||||||
|
|
||||||
// vitePlugins: []
|
// vitePlugins: []
|
||||||
@@ -140,7 +144,8 @@ module.exports = configure(function (ctx) {
|
|||||||
ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
|
ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
|
||||||
// will mess up SSR
|
// will mess up SSR
|
||||||
|
|
||||||
extendSSRWebserverConf (esbuildConf) {},
|
extendSSRWebserverConf(esbuildConf) {
|
||||||
|
},
|
||||||
// extendPackageJson (json) {},
|
// extendPackageJson (json) {},
|
||||||
|
|
||||||
pwa: false,
|
pwa: false,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ssrMiddleware } from 'quasar/wrappers'
|
import { defineSsrMiddleware } from '#q-app/wrappers'
|
||||||
|
|
||||||
// This middleware should execute as last one
|
// This middleware should execute as last one
|
||||||
// since it captures everything and tries to
|
// since it captures everything and tries to
|
||||||
// render the page with Vue
|
// render the page with Vue
|
||||||
|
|
||||||
export default ssrMiddleware(({ app, resolve, render, serve }) => {
|
export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
|
||||||
// we capture any other Express route and hand it
|
// we capture any other Express route and hand it
|
||||||
// over to Vue and Vue Router to render our page
|
// over to Vue and Vue Router to render our page
|
||||||
app.get(resolve.urlPath('*'), (req, res) => {
|
app.get(resolve.urlPath('*'), (req, res) => {
|
||||||
@@ -28,12 +28,9 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
|
|||||||
} else if (err.code === 404) {
|
} else if (err.code === 404) {
|
||||||
// hmm, Vue Router could not find the requested route
|
// hmm, Vue Router could not find the requested route
|
||||||
|
|
||||||
console.log("404 v renderu")
|
|
||||||
|
|
||||||
// Should reach here only if no "catch-all" route
|
// Should reach here only if no "catch-all" route
|
||||||
// is defined in /src/routes
|
// is defined in /src/routes
|
||||||
// res.status(404).send('404 | Page Not Found')
|
res.status(404).send('404 | Page Not Found')
|
||||||
res.redirect(resolve.urlPath('404'))
|
|
||||||
} else if (process.env.DEV) {
|
} else if (process.env.DEV) {
|
||||||
// well, we treat any other code as error;
|
// well, we treat any other code as error;
|
||||||
// if we're in dev mode, then we can use Quasar CLI
|
// if we're in dev mode, then we can use Quasar CLI
|
||||||
@@ -42,7 +39,6 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
|
|||||||
|
|
||||||
// serve.error is available on dev only
|
// serve.error is available on dev only
|
||||||
serve.error({ err, req, res })
|
serve.error({ err, req, res })
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// we're in production, so we should have another method
|
// we're in production, so we should have another method
|
||||||
// to display something to the client when we encounter an error
|
// to display something to the client when we encounter an error
|
||||||
@@ -51,7 +47,11 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
|
|||||||
|
|
||||||
// Render Error Page on production or
|
// Render Error Page on production or
|
||||||
// create a route (/src/routes) for an error page and redirect to it
|
// create a route (/src/routes) for an error page and redirect to it
|
||||||
res.redirect(resolve.urlPath('500'))
|
res.status(500).send('500 | Internal Server Error')
|
||||||
|
|
||||||
|
if (process.env.DEBUGGING) {
|
||||||
|
console.error(err.stack)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+46
-25
@@ -12,21 +12,22 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import compression from 'compression'
|
import compression from 'compression'
|
||||||
import {
|
import {
|
||||||
ssrClose,
|
defineSsrCreate,
|
||||||
ssrCreate,
|
defineSsrInjectDevMiddleware,
|
||||||
ssrListen,
|
defineSsrListen,
|
||||||
ssrRenderPreloadTag,
|
defineSsrClose,
|
||||||
ssrServeStaticContent
|
defineSsrServeStaticContent,
|
||||||
} from 'quasar/wrappers'
|
defineSsrRenderPreloadTag
|
||||||
|
} from '#q-app/wrappers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create your webserver and return its instance.
|
* Create your webserver and return its instance.
|
||||||
* If needed, prepare your webserver to receive
|
* If needed, prepare your webserver to receive
|
||||||
* connect-like middlewares.
|
* connect-like middlewares.
|
||||||
*
|
*
|
||||||
* Should NOT be async!
|
* Can be async: defineSsrCreate(async ({ ... }) => { ... })
|
||||||
*/
|
*/
|
||||||
export const create = ssrCreate((/* { ... } */) => {
|
export const create = defineSsrCreate((/* { ... } */) => {
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
// attackers can use this header to detect apps running Express
|
// attackers can use this header to detect apps running Express
|
||||||
@@ -42,6 +43,19 @@ export const create = ssrCreate((/* { ... } */) => {
|
|||||||
return app
|
return app
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by Quasar SSR dev server to inject middleware into the webserver.
|
||||||
|
* It uses it to handle Vite dev server, handle public paths, etc.
|
||||||
|
* The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
|
||||||
|
*
|
||||||
|
* Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
|
||||||
|
*/
|
||||||
|
export const injectDevMiddleware = defineSsrInjectDevMiddleware(({ app }) => {
|
||||||
|
return (middleware) => {
|
||||||
|
app.use(middleware)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* You need to make the server listen to the indicated port
|
* You need to make the server listen to the indicated port
|
||||||
* and return the listening instance or whatever you need to
|
* and return the listening instance or whatever you need to
|
||||||
@@ -52,10 +66,12 @@ export const create = ssrCreate((/* { ... } */) => {
|
|||||||
*
|
*
|
||||||
* For production, you can instead export your
|
* For production, you can instead export your
|
||||||
* handler for serverless use or whatever else fits your needs.
|
* handler for serverless use or whatever else fits your needs.
|
||||||
|
*
|
||||||
|
* Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
|
||||||
*/
|
*/
|
||||||
export const listen = ssrListen(async ({ app, port, isReady }) => {
|
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
|
||||||
await isReady()
|
const server = devHttpsApp || app
|
||||||
return app.listen(port, () => {
|
return server.listen(port, () => {
|
||||||
if (process.env.PROD) {
|
if (process.env.PROD) {
|
||||||
console.log('Server listening at port ' + port)
|
console.log('Server listening at port ' + port)
|
||||||
}
|
}
|
||||||
@@ -70,9 +86,9 @@ export const listen = ssrListen(async ({ app, port, isReady }) => {
|
|||||||
* Should you need the result of the "listen()" call above,
|
* Should you need the result of the "listen()" call above,
|
||||||
* you can use the "listenResult" param.
|
* you can use the "listenResult" param.
|
||||||
*
|
*
|
||||||
* Can be async.
|
* Can be async: defineSsrClose(async ({ listenResult }) => { ... })
|
||||||
*/
|
*/
|
||||||
export const close = ssrClose(({ listenResult }) => {
|
export const close = defineSsrClose(({ listenResult }) => {
|
||||||
return listenResult.close()
|
return listenResult.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -81,14 +97,19 @@ const maxAge = process.env.DEV
|
|||||||
: 1000 * 60 * 60 * 24 * 30
|
: 1000 * 60 * 60 * 24 * 30
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Should return middleware that serves the indicated path
|
* Should return a function that will be used to configure the webserver
|
||||||
* with static content.
|
* to serve static content at "urlPath" from "pathToServe" folder/file.
|
||||||
|
*
|
||||||
|
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
|
||||||
|
*
|
||||||
|
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
|
||||||
|
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
|
||||||
*/
|
*/
|
||||||
export const serveStaticContent = ssrServeStaticContent((path, opts) => {
|
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
|
||||||
return express.static(path, {
|
return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
|
||||||
maxAge,
|
const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
|
||||||
...opts
|
app.use(resolve.urlPath(urlPath), serveFn)
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const jsRE = /\.js$/
|
const jsRE = /\.js$/
|
||||||
@@ -103,13 +124,13 @@ const pngRE = /\.png$/
|
|||||||
* Should return a String with HTML output
|
* Should return a String with HTML output
|
||||||
* (if any) for preloading indicated file
|
* (if any) for preloading indicated file
|
||||||
*/
|
*/
|
||||||
export const renderPreloadTag = ssrRenderPreloadTag((file) => {
|
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
|
||||||
if (jsRE.test(file) === true) {
|
if (jsRE.test(file) === true) {
|
||||||
return `<link rel="modulepreload" href="${file}" crossorigin>`
|
return `<link rel="modulepreload" href="${file}" crossorigin>`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cssRE.test(file) === true) {
|
if (cssRE.test(file) === true) {
|
||||||
return `<link rel="stylesheet" href="${file}">`
|
return `<link rel="stylesheet" href="${file}" crossorigin>`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (woffRE.test(file) === true) {
|
if (woffRE.test(file) === true) {
|
||||||
@@ -121,15 +142,15 @@ export const renderPreloadTag = ssrRenderPreloadTag((file) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (gifRE.test(file) === true) {
|
if (gifRE.test(file) === true) {
|
||||||
return `<link rel="preload" href="${file}" as="image" type="image/gif">`
|
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jpgRE.test(file) === true) {
|
if (jpgRE.test(file) === true) {
|
||||||
return `<link rel="preload" href="${file}" as="image" type="image/jpeg">`
|
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pngRE.test(file) === true) {
|
if (pngRE.test(file) === true) {
|
||||||
return `<link rel="preload" href="${file}" as="image" type="image/png">`
|
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
Vendored
-10
@@ -1,10 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
|
|
||||||
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
|
|
||||||
import "quasar/dist/types/feature-flag";
|
|
||||||
|
|
||||||
declare module "quasar/dist/types/feature-flag" {
|
|
||||||
interface QuasarFeatureFlags {
|
|
||||||
ssr: true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ import {computed} from "vue";
|
|||||||
import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
|
import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
|
||||||
import {storeToRefs} from "pinia";
|
import {storeToRefs} from "pinia";
|
||||||
import {useAppStore} from "stores/App";
|
import {useAppStore} from "stores/App";
|
||||||
|
import {useMeta} from "quasar";
|
||||||
|
import {metaData} from "src/composables/meta";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
@@ -13,6 +15,10 @@ const layoutComponent = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const appStatus = storeToRefs(useAppStore()).appStatus;
|
const appStatus = storeToRefs(useAppStore()).appStatus;
|
||||||
|
|
||||||
|
useMeta(() => metaData({
|
||||||
|
title: useAppStore().pageTitle,
|
||||||
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
+173
-86
@@ -1,105 +1,192 @@
|
|||||||
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core'
|
import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core'
|
||||||
import {onError} from "@apollo/client/link/error";
|
import {ErrorLink} from "@apollo/client/link/error";
|
||||||
|
import {defineStore} from "pinia";
|
||||||
import {useAppStore} from "stores/App";
|
import {useAppStore} from "stores/App";
|
||||||
import {Notify} from "quasar";
|
import {Dialog, Notify} from "quasar";
|
||||||
import {errorObject, warningObject} from "src/composables/dialog";
|
import {errorObject, warningObject} from "src/composables/dialog";
|
||||||
import {createUploadLink} from "apollo-upload-client";
|
|
||||||
import {useAuthStore} from "stores/Auth";
|
import {useAuthStore} from "stores/Auth";
|
||||||
import {useRouter} from "vue-router";
|
import UploadHttpLink from "apollo-upload-client/UploadHttpLink.mjs";
|
||||||
// import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
|
import LoginOverlayDialog from "components/auth/LoginOverlayDialog.vue";
|
||||||
|
|
||||||
|
// Dedup across concurrently failing operations: if a query and a mutation
|
||||||
|
// both 401 around the same time, they must share one login overlay instead
|
||||||
|
// of each opening its own dialog. Module-level state is fine here (unlike
|
||||||
|
// the SSR-sensitive apolloWrapper below) because this path only ever runs
|
||||||
|
// client-side, one browser tab per module instance.
|
||||||
|
let pendingLoginPromise = null;
|
||||||
|
|
||||||
|
const showLoginOverlay = (store) => {
|
||||||
|
if (pendingLoginPromise) return pendingLoginPromise;
|
||||||
|
|
||||||
|
Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu."));
|
||||||
|
|
||||||
|
pendingLoginPromise = new Promise((resolve) => {
|
||||||
|
Dialog.create({
|
||||||
|
component: LoginOverlayDialog,
|
||||||
|
persistent: true,
|
||||||
|
}).onOk(() => resolve(useAuthStore(store).getAccessToken()));
|
||||||
|
}).finally(() => {
|
||||||
|
pendingLoginPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return pendingLoginPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryWithToken = (operation, forward) => (accessToken) => {
|
||||||
|
const oldHeaders = operation.getContext().headers;
|
||||||
|
operation.setContext({
|
||||||
|
headers: {
|
||||||
|
...oldHeaders,
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// retry the request, returning the new observable
|
||||||
|
return forward(operation);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefreshToken = (store, router, forward, operation) => {
|
const handleRefreshToken = (store, router, forward, operation) => {
|
||||||
if (!operation.query?._refreshUnauthorizedUser) return;
|
if (!operation.query?._refreshUnauthorizedUser) return;
|
||||||
|
|
||||||
console.log("refreshing token, ", operation.query);
|
// Loop guard: if the operation retried after a refresh/login still comes
|
||||||
return fromPromise(useAuthStore(store).refreshAccessToken()
|
// back 401, don't try forever - surface a hard error instead.
|
||||||
.catch((error) => {
|
if (operation.getContext()._tokenRefreshAttempted) {
|
||||||
Notify.create(warningObject("Tvoje přihlášení vypřešlo. Přihlaš se prosím znovu."));
|
Notify.create(errorObject("Přihlášení se nezdařilo, zkus to prosím znovu."));
|
||||||
router.push({name: "login"})
|
return Promise.reject(new Error("Token refresh retry failed"));
|
||||||
}))
|
}
|
||||||
.filter((value) => Boolean(value))
|
operation.setContext({_tokenRefreshAttempted: true});
|
||||||
.flatMap((accessToken) => {
|
|
||||||
const oldHeaders = operation.getContext().headers;
|
|
||||||
operation.setContext({
|
|
||||||
headers: {
|
|
||||||
...oldHeaders,
|
|
||||||
authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// retry the request, returning the new observable
|
const retry = retryWithToken(operation, forward);
|
||||||
return forward(operation);
|
|
||||||
});
|
// The overlay only makes sense client-side (no DOM during SSR); keep the
|
||||||
|
// previous redirect-to-login behavior there.
|
||||||
|
const onRefreshFailed = process.env.SERVER
|
||||||
|
? () => {
|
||||||
|
router.push({name: "login"});
|
||||||
|
return Promise.reject(new Error("Token refresh failed (SSR)"));
|
||||||
|
}
|
||||||
|
: () => showLoginOverlay(store).then(retry);
|
||||||
|
|
||||||
|
return useAuthStore(store).refreshAccessToken()
|
||||||
|
.then(retry)
|
||||||
|
.catch(onRefreshFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apolloWrapper = {};
|
// SSR: boot() runs again for every request. The client/query/mutate below
|
||||||
export default (({store, router}) => {
|
// used to live on a shared module-level `apolloWrapper` object that every
|
||||||
const uploadLink = createUploadLink({
|
// request's boot() reassigned in place - under concurrent SSR requests, one
|
||||||
uri: process.env.API_URL + '/graphql',
|
// request could end up calling into another request's (still-being-set-up
|
||||||
credentials: 'include'
|
// or already-overwritten) apolloClient/store, i.e. querying/mutating with
|
||||||
})
|
// the wrong user's auth context. See
|
||||||
|
// handoffs/02-apollo-wrapper-race-condition.md for the full analysis.
|
||||||
|
//
|
||||||
|
// Fix: make this a Pinia store instead of a plain shared object. `init()`
|
||||||
|
// is called from boot() with the request's own `store`/`router` explicitly
|
||||||
|
// (same proven pattern as useAuthStore(store) elsewhere in this file), so
|
||||||
|
// the closures below always close over the correct request. `query`/
|
||||||
|
// `mutate` are then read back through Pinia's own SSR-safe per-request
|
||||||
|
// resolution (useApolloStore() with no arg, called from preFetch/
|
||||||
|
// onServerPrefetch-triggered store actions - the same mechanism every
|
||||||
|
// other useXStore() call in this codebase already relies on), instead of
|
||||||
|
// through a shared mutable object.
|
||||||
|
export const useApolloStore = defineStore('Apollo', () => {
|
||||||
|
let client = null
|
||||||
|
let queryImpl = null
|
||||||
|
let mutateImpl = null
|
||||||
|
|
||||||
const authLink = new ApolloLink((operation, forward) => {
|
const init = ({store, router}) => {
|
||||||
const accessToken = useAuthStore(store).getAccessToken();
|
if (client) return // boot() already initialized this request's store
|
||||||
if (accessToken) {
|
|
||||||
operation.setContext({
|
|
||||||
headers: {"Authorization": `Bearer ${accessToken}`},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return forward(operation);
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
|
const uploadLink = new UploadHttpLink({
|
||||||
if (networkError?.statusCode === 401) {
|
uri: process.env.API_URL + '/graphql',
|
||||||
return handleRefreshToken(store, router, forward, operation);
|
credentials: 'include'
|
||||||
}
|
})
|
||||||
|
|
||||||
if (graphQLErrors) {
|
const authLink = new ApolloLink((operation, forward) => {
|
||||||
for (const error of graphQLErrors) {
|
const accessToken = useAuthStore(store).getAccessToken();
|
||||||
if (error.message.includes("401") || error.message.includes("Not authorized")) {
|
if (accessToken) {
|
||||||
return handleRefreshToken(store, router, forward, operation);
|
operation.setContext({
|
||||||
} else if (error.message.includes("404") || error.message.includes("Not found")) {
|
headers: {"Authorization": `Bearer ${accessToken}`},
|
||||||
useAppStore(store).appStatus = 404;
|
});
|
||||||
} else {
|
}
|
||||||
Notify.create(errorObject("API error: " + error.message));
|
return forward(operation);
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => {
|
||||||
|
if (networkError?.statusCode === 401) {
|
||||||
|
return handleRefreshToken(store, router, forward, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphQLErrors) {
|
||||||
|
for (const error of graphQLErrors) {
|
||||||
|
if (error.message.includes("401") || error.message.includes("Not authorized")) {
|
||||||
|
return handleRefreshToken(store, router, forward, operation);
|
||||||
|
} else if (error.message.includes("404") || error.message.includes("Not found")) {
|
||||||
|
useAppStore(store).appStatus = 404;
|
||||||
|
} else {
|
||||||
|
Notify.create(errorObject("API error: " + error.message));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const apolloClient = new ApolloClient({
|
|
||||||
link: ApolloLink.from([authLink, errorLink, uploadLink]), cache: new InMemoryCache(), connectToDevTools: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
apolloWrapper.query = (query, variables = {}) => {
|
|
||||||
return apolloClient.query({
|
|
||||||
query: query,
|
|
||||||
fetchPolicy: 'no-cache',
|
|
||||||
variables: variables
|
|
||||||
})
|
})
|
||||||
.then((response) => response)
|
|
||||||
.catch(err => {
|
client = new ApolloClient({
|
||||||
const errStr = err.toString();
|
link: ApolloLink.from([authLink, errorLink, uploadLink]),
|
||||||
if (errStr.includes('Not found') || errStr.includes("No row was found")) {
|
cache: new InMemoryCache(),
|
||||||
useAppStore(store).appStatus = 404;
|
connectToDevTools: true,
|
||||||
return Promise.resolve();
|
ssrMode: true,
|
||||||
}
|
});
|
||||||
else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
|
|
||||||
console.error("Error during query: ", err)
|
queryImpl = (query, variables = {}) => {
|
||||||
}
|
return client.query({
|
||||||
return Promise.reject(err);
|
query: query,
|
||||||
})
|
|
||||||
};
|
|
||||||
apolloWrapper.mutate = (mutation, variables) => {
|
|
||||||
return apolloClient
|
|
||||||
.mutate({
|
|
||||||
mutation: mutation,
|
|
||||||
fetchPolicy: 'no-cache',
|
fetchPolicy: 'no-cache',
|
||||||
variables: variables,
|
variables: variables
|
||||||
})
|
})
|
||||||
.then((response) => response)
|
.then((response) => response)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error during mutation: ", err);
|
const errStr = err.toString();
|
||||||
})
|
if (errStr.includes('Not found') || errStr.includes("No row was found")) {
|
||||||
};
|
useAppStore(store).appStatus = 404;
|
||||||
|
return Promise.resolve();
|
||||||
|
} else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
|
||||||
|
console.error("Error during query: ", err)
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
mutateImpl = (mutation, variables) => {
|
||||||
|
return client
|
||||||
|
.mutate({
|
||||||
|
mutation: mutation,
|
||||||
|
fetchPolicy: 'no-cache',
|
||||||
|
variables: variables,
|
||||||
|
})
|
||||||
|
.then((response) => response)
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error during mutation: ", err);
|
||||||
|
return Promise.reject(err);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = (query, variables) => queryImpl(query, variables)
|
||||||
|
const mutate = (mutation, variables) => mutateImpl(mutation, variables)
|
||||||
|
|
||||||
|
return {init, query, mutate}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Backward-compatible shape so existing call sites
|
||||||
|
// (`apolloWrapper.query(...)`, `apolloWrapper.mutate(...)` in
|
||||||
|
// stores/helpers.js and stores/Organizations.js) keep working unchanged,
|
||||||
|
// now delegating to the request-scoped store above instead of a shared
|
||||||
|
// mutable singleton.
|
||||||
|
export const apolloWrapper = {
|
||||||
|
query: (...args) => useApolloStore().query(...args),
|
||||||
|
mutate: (...args) => useApolloStore().mutate(...args),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (({store, router}) => {
|
||||||
|
useApolloStore(store).init({store, router})
|
||||||
});
|
});
|
||||||
|
|||||||
+18
-2
@@ -13,8 +13,24 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// SSR: boot() runs again for every request, but `api` above is a
|
||||||
|
// module-level singleton created once. Without ejecting the previous
|
||||||
|
// request's interceptors before adding new ones, api.interceptors.*
|
||||||
|
// would grow without bound for the lifetime of the process, each
|
||||||
|
// handler closing over that request's (by-then-stale) store - see
|
||||||
|
// handoffs/01-axios-interceptors-leak.md for the full analysis.
|
||||||
|
let requestInterceptorId
|
||||||
|
let responseInterceptorId
|
||||||
|
|
||||||
export default boot(({store}) => {
|
export default boot(({store}) => {
|
||||||
api.interceptors.request.use((config) => {
|
if (requestInterceptorId !== undefined) {
|
||||||
|
api.interceptors.request.eject(requestInterceptorId)
|
||||||
|
}
|
||||||
|
if (responseInterceptorId !== undefined) {
|
||||||
|
api.interceptors.response.eject(responseInterceptorId)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestInterceptorId = api.interceptors.request.use((config) => {
|
||||||
const token = useAuthStore(store).getAccessToken();
|
const token = useAuthStore(store).getAccessToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers['Authorization'] = `Bearer ${token}`
|
config.headers['Authorization'] = `Bearer ${token}`
|
||||||
@@ -28,7 +44,7 @@ export default boot(({store}) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Response interceptor for API calls
|
// Response interceptor for API calls
|
||||||
api.interceptors.response.use(
|
responseInterceptorId = api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async function (error) {
|
async function (error) {
|
||||||
const originalRequest = error.config;
|
const originalRequest = error.config;
|
||||||
|
|||||||
+28
-5
@@ -1,9 +1,32 @@
|
|||||||
import {Cookies} from 'quasar'
|
import {Cookies} from 'quasar'
|
||||||
import {boot} from "quasar/wrappers";
|
import {boot} from "quasar/wrappers";
|
||||||
|
import {defineStore} from 'pinia'
|
||||||
|
|
||||||
let cookies = Cookies;
|
// SSR: boot() runs again for every request. `cookies` used to be a single
|
||||||
export default boot(({ssrContext}) => {
|
// shared module-level variable reassigned in place by every request's
|
||||||
cookies = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
|
// boot() - under concurrent SSR requests, one request's
|
||||||
|
// Cookies.parseSSR(ssrContext) result (which carries that request's own
|
||||||
|
// accessToken) could overwrite another still-in-flight request's
|
||||||
|
// `cookies` before it finished reading it, leaking one user's access
|
||||||
|
// token to another. See handoffs/03-cookies-shared-state.md.
|
||||||
|
//
|
||||||
|
// Fix: make this a Pinia store, initialized explicitly per request from
|
||||||
|
// boot() with that request's own `store` (same proven pattern as
|
||||||
|
// boot/apollo.js), instead of a shared mutable variable.
|
||||||
|
export const useCookiesStore = defineStore('Cookies', () => {
|
||||||
|
let instance = null
|
||||||
|
|
||||||
|
const init = (ssrContext) => {
|
||||||
|
instance = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = (...args) => instance.get(...args)
|
||||||
|
const set = (...args) => instance.set(...args)
|
||||||
|
const remove = (...args) => instance.remove(...args)
|
||||||
|
|
||||||
|
return {init, get, set, remove}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default boot(({store, ssrContext}) => {
|
||||||
|
useCookiesStore(store).init(ssrContext)
|
||||||
});
|
});
|
||||||
|
|
||||||
export {cookies}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {computed} from "vue";
|
import {computed} from "vue";
|
||||||
|
import {useAltitudeUtils} from "src/composables/airspaces";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
lowerLimit: {
|
lowerLimit: {
|
||||||
@@ -20,12 +21,10 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const getLimitFormatted = (limit, unit) => {
|
const { altitudeLimitFormatted } = useAltitudeUtils();
|
||||||
return `${limit} ${unit}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const upper = computed(() => getLimitFormatted(props.upperLimit, props.upperLimitUnit));
|
const upper = computed(() => altitudeLimitFormatted(props.upperLimit, props.upperLimitUnit));
|
||||||
const lower = computed(() => getLimitFormatted(props.lowerLimit, props.lowerLimitUnit));
|
const lower = computed(() => altitudeLimitFormatted(props.lowerLimit, props.lowerLimitUnit));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const config = computed(() => {
|
const config = computed(() => {
|
||||||
|
|
||||||
if (props.mode === "add") {
|
if (props.mode === "add") {
|
||||||
return {icon: "add", label: "Přidat"};
|
return {icon: "add", label: "Přidat"};
|
||||||
} else if (props.mode === "edit") {
|
} else if (props.mode === "edit") {
|
||||||
@@ -26,7 +25,11 @@ const config = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-page-sticky location="bottom" :offset="[30, 30]">
|
<q-page-sticky
|
||||||
|
location="bottom"
|
||||||
|
:offset="[30, 30]"
|
||||||
|
v-if="$q.screen.lt.md"
|
||||||
|
>
|
||||||
<q-fab :icon="config.icon" color="primary" :label="config.label" @click="$router.push(props.routerLink)"/>
|
<q-fab :icon="config.icon" color="primary" :label="config.label" @click="$router.push(props.routerLink)"/>
|
||||||
</q-page-sticky>
|
</q-page-sticky>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -30,6 +30,6 @@ const saveForm = () => {
|
|||||||
<template>
|
<template>
|
||||||
<q-form @submit.prevent="saveForm" ref="form">
|
<q-form @submit.prevent="saveForm" ref="form">
|
||||||
<slot name="default"/>
|
<slot name="default"/>
|
||||||
<FormSubmitButton v-if="saveButton" :save-button="saveButton"/>
|
<FormSubmitButton v-if="saveButton" :save-button="saveButton" :is-request-pending="isRequestPending" />
|
||||||
</q-form>
|
</q-form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -38,14 +38,23 @@ onMounted(() => {
|
|||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
filters.value.search = search;
|
filters.value.search = search;
|
||||||
|
} else {
|
||||||
|
filters.value = {};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
|
<div class="flex">
|
||||||
<template #prepend>
|
<div class="col-grow">
|
||||||
<q-icon name="search"/>
|
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
|
||||||
</template>
|
<template #prepend>
|
||||||
</q-input>
|
<q-icon name="search"/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<slot name="append" class="full-height"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
totalItemsCount: {
|
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
itemsLength: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
itemsPerPage: {
|
|
||||||
type: Number,
|
|
||||||
required: false,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
isQueryRunning: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const emits = defineEmits(["load-more"]);
|
|
||||||
const loadMore = () => {
|
|
||||||
emits("load-more", props.itemsPerPage);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<slot name="items"/>
|
|
||||||
<slot name="skeleton" v-if="isQueryRunning"/>
|
|
||||||
|
|
||||||
<div class="text-center q-my-md">
|
|
||||||
<q-btn color="primary" :loading="isQueryRunning" rounded size="lg" icon="expand_more" @click="loadMore()" v-if="totalItemsCount > itemsLength">
|
|
||||||
<slot name="loadMoreText"/>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -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,68 +1,53 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import LoadMoreList from "components/LoadMoreList.vue";
|
import {useInfiniteScroll} from "src/composables/pagination";
|
||||||
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
|
|
||||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: {
|
items: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
totalItemsCount: {
|
...useInfiniteScroll().propsDef
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
itemsPerPage: {
|
|
||||||
type: Number,
|
|
||||||
required: false,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
isQueryRunning: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits([...useInfiniteScroll().emitsDef])
|
||||||
|
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
|
<q-infinite-scroll
|
||||||
<template #items>
|
:disable="!hasMoreData"
|
||||||
<q-list bordered separator>
|
@load="emitLoadMore"
|
||||||
<q-item
|
:offset="250"
|
||||||
clickable
|
>
|
||||||
v-ripple
|
<q-list bordered separator>
|
||||||
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
|
<q-item
|
||||||
v-for="aircraft of props.items" :key="aircraft"
|
clickable
|
||||||
>
|
v-ripple
|
||||||
<q-item-section avatar>
|
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
|
||||||
<q-avatar v-if="aircraft.titlePhoto">
|
v-for="aircraft of props.items" :key="aircraft"
|
||||||
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
|
>
|
||||||
</q-avatar>
|
<q-item-section avatar>
|
||||||
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
|
<q-avatar v-if="aircraft.titlePhoto">
|
||||||
</q-item-section>
|
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
|
||||||
<q-item-section>
|
</q-avatar>
|
||||||
<q-item-label label>
|
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
|
||||||
{{ aircraft.callSign }}
|
</q-item-section>
|
||||||
</q-item-label>
|
<q-item-section>
|
||||||
<q-item-label caption>
|
<q-item-label label>
|
||||||
{{ aircraft.model }}
|
{{ aircraft.callSign }}
|
||||||
<template v-if="aircraft.manufacturer">
|
</q-item-label>
|
||||||
({{ aircraft.manufacturer }})
|
<q-item-label caption>
|
||||||
</template>
|
{{ aircraft.model }}
|
||||||
</q-item-label>
|
<template v-if="aircraft.manufacturer">
|
||||||
<q-item-label caption v-if="aircraft.organization">
|
({{ aircraft.manufacturer }})
|
||||||
Provozovatel: {{ aircraft.organization.name }}
|
</template>
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
<q-item-label caption v-if="aircraft.organization">
|
||||||
</q-item>
|
Provozovatel: {{ aircraft.organization.name }}
|
||||||
</q-list>
|
</q-item-label>
|
||||||
</template>
|
</q-item-section>
|
||||||
<template #skeleton>
|
</q-item>
|
||||||
<BasicListSkeleton :items="itemsPerPage"/>
|
</q-list>
|
||||||
</template>
|
</q-infinite-scroll>
|
||||||
<template #loadMoreText>
|
|
||||||
Další
|
|
||||||
</template>
|
|
||||||
</LoadMoreList>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<q-dialog ref="dialogRef" persistent @hide="onDialogHide">
|
||||||
|
<q-card style="width: 95%; max-width: 400px;">
|
||||||
|
<q-form @submit.prevent="logUser">
|
||||||
|
<q-card-section class="bg-primary">
|
||||||
|
<h4 class="text-h6 q-my-md text-white">Přihlášení vypršelo</h4>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<p>Tvoje přihlášení vypršelo. Přihlaš se prosím znovu, aby se neztratila rozpracovaná data.</p>
|
||||||
|
<q-input v-model="formData.email" :rules="rules.email" type="email" label="E-mail">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="email"/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<q-input v-model="formData.password" :rules="rules.password" type="password" label="Heslo">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="lock"/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn type="submit" unelevated size="lg" color="primary" class="full-width" :loading="loading" label="Přihlásit se"/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {reactive, ref} from "vue";
|
||||||
|
import {useDialogPluginComponent, useQuasar} from "quasar";
|
||||||
|
import {isRequired, isValidEmail, minLength} from "src/composables/form/rules";
|
||||||
|
import {errorObject} from "src/composables/dialog";
|
||||||
|
import {useAuthStore} from "stores/Auth";
|
||||||
|
|
||||||
|
const {dialogRef, onDialogHide, onDialogOK} = useDialogPluginComponent();
|
||||||
|
defineEmits([...useDialogPluginComponent.emits]);
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const loading = ref(false);
|
||||||
|
const formData = reactive({
|
||||||
|
email: "",
|
||||||
|
password: ""
|
||||||
|
});
|
||||||
|
const rules = {
|
||||||
|
email: [isRequired, isValidEmail],
|
||||||
|
password: [isRequired, minLength(4)]
|
||||||
|
};
|
||||||
|
|
||||||
|
const logUser = () => {
|
||||||
|
loading.value = true;
|
||||||
|
useAuthStore().login(formData)
|
||||||
|
.then(() => onDialogOK())
|
||||||
|
.catch(() => $q.notify(errorObject("Přihlášení se nezdařilo.")))
|
||||||
|
.finally(() => loading.value = false);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import {computed} from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
routeAdd: {
|
routeAdd: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -8,16 +10,44 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
default: "Přidat"
|
default: "Přidat"
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'info'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bannerClass = computed(() => {
|
||||||
|
const defaultClasses = 'bg-light-blue text-white';
|
||||||
|
const classMap = {
|
||||||
|
info: defaultClasses,
|
||||||
|
warning: 'bg-orange text-white',
|
||||||
|
error: 'bg-red text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
return classMap[props.color] || defaultClasses;
|
||||||
|
});
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
const iconsMap = {
|
||||||
|
info: "info",
|
||||||
|
warning: "warning",
|
||||||
|
error: "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
return iconsMap[props.color];
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="q-pa-md q-gutter-sm">
|
<div class="q-pa-md q-gutter-sm">
|
||||||
<q-banner class="bg-light-blue text-white">
|
<q-banner :class=bannerClass>
|
||||||
<template v-slot:avatar>
|
<template v-slot:avatar v-if="icon">
|
||||||
<q-icon name="info"/>
|
<q-icon :name="icon"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<slot/>
|
<slot/>
|
||||||
|
|
||||||
<template v-slot:action v-if="routeAdd">
|
<template v-slot:action v-if="routeAdd">
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ useEventsStore().fetchFlightsTracks({
|
|||||||
for (const flightTrack of data.flights) {
|
for (const flightTrack of data.flights) {
|
||||||
totalTime.value += flightTrack.durationMinCalculated;
|
totalTime.value += flightTrack.durationMinCalculated;
|
||||||
|
|
||||||
if (flightTrack.gpxTrack) {
|
if (flightTrack.track) {
|
||||||
tracks.value.push(flightTrack.gpxTrack.coordinates);
|
tracks.value.push(flightTrack.track.points.coordinates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "event",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git@kvacek.cz:poletani.cz/app.git"
|
|
||||||
},
|
|
||||||
"private": true
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import {computed, ref, watch} from "vue";
|
import {computed, ref, watch} from "vue";
|
||||||
import {Marker, CustomMarker, Polyline} from "vue3-google-map";
|
import {Marker, CustomMarker, Polyline} from "vue3-google-map";
|
||||||
import MapWrapper from "components/map/MapWrapper.vue";
|
import MapWrapper from "components/map/MapWrapper.vue";
|
||||||
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
|
import {useFlightPlanCalculations, useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||||
import {useGeocode} from "src/composables/geocoding";
|
import {useGeocode} from "src/composables/geocoding";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -24,15 +24,17 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
insertPosition: {
|
insertPosition: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true
|
required: false,
|
||||||
}
|
default: null
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const emits = defineEmits(["markers-changed", "change-insert-position"]);
|
const emits = defineEmits(["markers-changed", "change-insert-position"]);
|
||||||
|
|
||||||
const {center} = useGeocode();
|
const {center} = useGeocode();
|
||||||
|
|
||||||
const {markerObject, googleMapsMarkerObject, editableNameTypes} = useFlightPlanObjects();
|
const {markerObject, googleMapsMarkerObject, googleMapsCustomMarkerObject, editableNameTypes} = useFlightPlanObjects();
|
||||||
const {getMarkersFrom} = useFlightPlanUtils();
|
const {getMarkersFrom} = useFlightPlanUtils();
|
||||||
|
const {getDuration} = useFlightPlanCalculations();
|
||||||
|
|
||||||
const mapWrap = ref(null);
|
const mapWrap = ref(null);
|
||||||
|
|
||||||
@@ -40,30 +42,44 @@ const handleMarkerClick = (marker) => {
|
|||||||
mapWrap.value.openInfoWindow({marker});
|
mapWrap.value.openInfoWindow({marker});
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePoiClick = (poi) => {
|
|
||||||
mapWrap.value.openInfoWindow({poi});
|
|
||||||
}
|
|
||||||
|
|
||||||
const markers = ref([]);
|
const markers = ref([]);
|
||||||
watch(() => props.flightPlan.track, (newVal) => {
|
watch(() => props.flightPlan.track, (newVal) => {
|
||||||
markers.value = getMarkersFrom(props.flightPlan);
|
markers.value = getMarkersFrom(props.flightPlan);
|
||||||
}, {immediate: true, deep: true});
|
}, {immediate: true, deep: true});
|
||||||
|
|
||||||
const track = computed(() => markers.value.map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude})));
|
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) => {
|
const addMarker = async (point) => {
|
||||||
if (!point) return;
|
if (!point) return;
|
||||||
|
|
||||||
const insertAt = Math.min(markers.value.length, props.insertPosition);
|
const insertAt = Math.min(markers.value.length, props.insertPosition);
|
||||||
|
|
||||||
markers.value.splice(insertAt, 0, markerObject({...point}));
|
markers.value.splice(insertAt, 0, markerObject({...point}));
|
||||||
// infoWindow.value = infoWindowObject({active: false});
|
|
||||||
|
|
||||||
emits("markers-changed", markers.value);
|
emits("markers-changed", markers.value);
|
||||||
emits("change-insert-position", insertAt + 1);
|
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 {searchPlaceName} = useGeocode();
|
||||||
const handleMarkerMove = async (idx, event) => {
|
const handleMarkerMove = async (idx, event) => {
|
||||||
markers.value[idx].name = await searchPlaceName(event.lat(), event.lng());
|
markers.value[idx].name = await searchPlaceName(event.lat(), event.lng());
|
||||||
@@ -76,33 +92,26 @@ const handleMarkerMove = async (idx, event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<MapWrapper
|
<MapWrapper
|
||||||
ref="mapWrap"
|
ref="mapWrap"
|
||||||
style="width: 100%; height: 100%; min-height: 500px"
|
style="width: 100%; height: 65vh; min-height: 500px"
|
||||||
:center="center"
|
:center="center"
|
||||||
:zoom="8"
|
:zoom="8"
|
||||||
|
:track="{path: track, geodesic: true, strokeColor: '#ff00ff', strokeOpacity: 1, strokeWeight: 3}"
|
||||||
|
:points-of-interest="pointsOfInterest"
|
||||||
:airspaces-data="airspaceData"
|
:airspaces-data="airspaceData"
|
||||||
:airports="airports"
|
:airports="airports"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:fullscreen-control="false"
|
:fullscreen-control="false"
|
||||||
:show-info-window="true"
|
:show-info-window="true"
|
||||||
:show-layer-controls="true"
|
:show-layer-controls="true"
|
||||||
:allow-add-marker="true"
|
:allow-add-marker="insertPosition !== null"
|
||||||
@add-to-route="addMarker"
|
@add-to-route="addMarker"
|
||||||
|
@remove-from-route="removeMarker"
|
||||||
>
|
>
|
||||||
<template v-for="poi in pointsOfInterest" :key="poi.name">
|
|
||||||
<CustomMarker
|
|
||||||
v-if="poi.gpsLatitude && poi.gpsLongitude"
|
|
||||||
:options="{position: {lat: poi.gpsLatitude, lng: poi.gpsLongitude}, anchorPoint: 'BOTTOM_CENTER'}"
|
|
||||||
@click.stop="handlePoiClick(poi)"
|
|
||||||
>
|
|
||||||
<q-icon name="place" style="color: orange; font-size: 30px"/>
|
|
||||||
</CustomMarker>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<template v-for="(marker, i) in markers" :key="marker.name">
|
<template v-for="(marker, i) in markers" :key="marker.name">
|
||||||
|
<!-- TODO: ve verejne casti by nemel byt draggable!-->
|
||||||
<Marker
|
<Marker
|
||||||
v-if="editableNameTypes.includes(marker.type)"
|
v-if="editableNameTypes.includes(marker.type)"
|
||||||
:options="googleMapsMarkerObject(marker)"
|
:options="googleMapsMarkerObject(marker)"
|
||||||
@@ -110,6 +119,65 @@ const handleMarkerMove = async (idx, event) => {
|
|||||||
@dragend="handleMarkerMove(i, $event.latLng)"
|
@dragend="handleMarkerMove(i, $event.latLng)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<Polyline :options="{path: track, geodesic: true, strokeColor: '#ff00ff', strokeOpacity: 1, strokeWeight: 3}"/>
|
|
||||||
|
<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 }}° <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>
|
</MapWrapper>
|
||||||
</template>
|
</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>
|
||||||
@@ -29,13 +29,18 @@ const placesFilter = (val, update, abort) => {
|
|||||||
searchGeocode(val).then(results => {
|
searchGeocode(val).then(results => {
|
||||||
if (!results) return;
|
if (!results) return;
|
||||||
|
|
||||||
placesOptions.value = results.map(result => ({
|
console.log(results)
|
||||||
name: result.formatted_address,
|
|
||||||
type: "geocode",
|
placesOptions.value = results.map(result => {
|
||||||
id: null,
|
console.log(result, result.geometry.location)
|
||||||
gpsLatitude: result.geometry.location.lat,
|
return {
|
||||||
gpsLongitude: result.geometry.location.lng,
|
name: result.formatted_address,
|
||||||
}))
|
type: "geocode",
|
||||||
|
id: null,
|
||||||
|
gpsLatitude: result.geometry.location.lat(),
|
||||||
|
gpsLongitude: result.geometry.location.lng(),
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ const swapMarkers = (idx1, idx2) => {
|
|||||||
model.value[idx2] = tmp;
|
model.value[idx2] = tmp;
|
||||||
};
|
};
|
||||||
const handlePlaceSelect = (modelVal) => {
|
const handlePlaceSelect = (modelVal) => {
|
||||||
console.log(modelVal);
|
|
||||||
model.value.push(modelVal);
|
model.value.push(modelVal);
|
||||||
|
|
||||||
|
// TODO: upravit bounds u mapy
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => model.value, () => {
|
watch(() => model.value, () => {
|
||||||
@@ -42,10 +44,9 @@ const {getIcon} = useFlightPlanUtils();
|
|||||||
const {editableNameTypes} = useFlightPlanObjects();
|
const {editableNameTypes} = useFlightPlanObjects();
|
||||||
|
|
||||||
const allPlacesOptions = computed(() => {
|
const allPlacesOptions = computed(() => {
|
||||||
|
|
||||||
const reportingPoints = props.airports.map(a => a.reportingPoints.map(r => ({...r, airport: a}))).flat()
|
const reportingPoints = props.airports.map(a => a.reportingPoints.map(r => ({...r, airport: a}))).flat()
|
||||||
return [
|
return [
|
||||||
...props.pointsOfInterest.map(poi => ({...poi, type: "poi"})),
|
...props.pointsOfInterest.map(poi => ({...poi, pointOfInterestId: poi.id, type: "poi"})),
|
||||||
...reportingPoints.map(r => ({
|
...reportingPoints.map(r => ({
|
||||||
...r,
|
...r,
|
||||||
name: `${r.airport.icaoCode}:${r.name}`,
|
name: `${r.airport.icaoCode}:${r.name}`,
|
||||||
@@ -55,6 +56,7 @@ const allPlacesOptions = computed(() => {
|
|||||||
...airport,
|
...airport,
|
||||||
name: `${airport.icaoCode} - ${airport.name}`,
|
name: `${airport.icaoCode} - ${airport.name}`,
|
||||||
type: "airport",
|
type: "airport",
|
||||||
|
airportId: airport.id,
|
||||||
}))
|
}))
|
||||||
].map(i => ({...i, disabled: !i.gpsLatitude || !i.gpsLongitude}));
|
].map(i => ({...i, disabled: !i.gpsLatitude || !i.gpsLongitude}));
|
||||||
});
|
});
|
||||||
@@ -72,16 +74,16 @@ const allPlacesOptions = computed(() => {
|
|||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<div>
|
<div>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="i > 0"
|
v-if="i > 0"
|
||||||
text-color="primary"
|
text-color="primary"
|
||||||
size="16px"
|
size="16px"
|
||||||
flat dense round icon="arrow_upward"
|
flat dense round icon="arrow_upward"
|
||||||
@click="swapMarkers(i, i-1)"
|
@click="swapMarkers(i, i-1)"
|
||||||
/>
|
/>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="i < model.length - 1"
|
v-if="i < model.length - 1"
|
||||||
text-color="primary"
|
text-color="primary"
|
||||||
flat dense round icon="arrow_downward"
|
flat dense round icon="`arrow_downward`"
|
||||||
size="16px"
|
size="16px"
|
||||||
@click="swapMarkers(i, i+1)"
|
@click="swapMarkers(i, i+1)"
|
||||||
|
|
||||||
@@ -116,4 +118,5 @@ const allPlacesOptions = computed(() => {
|
|||||||
:options="allPlacesOptions"
|
:options="allPlacesOptions"
|
||||||
@select="handlePlaceSelect"
|
@select="handlePlaceSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {computed, ref, watch} from "vue";
|
import {computed, ref, watch} from "vue";
|
||||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||||
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
import {useFlightPlanCalculations, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
flightPlan: {
|
flightPlan: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
plannedSpeed: {
|
isFullscreen: {
|
||||||
type: Number,
|
|
||||||
required: false,
|
|
||||||
default: 150
|
|
||||||
},
|
|
||||||
isFullscreen: {
|
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -23,28 +18,14 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(["open", "change-insert-position", "fullscren-toggle"])
|
const emits = defineEmits(["open", "change-insert-position", "fullscreen-toggle"])
|
||||||
|
const {getDuration, getTotalDistance, getTotalDuration} = useFlightPlanCalculations();
|
||||||
|
const totalDistance = computed(() => getTotalDistance(props.flightPlan));
|
||||||
const getDuration = (distance, speed) => Math.round(distance / speed * 60)
|
|
||||||
|
|
||||||
const totalDistance = computed(() => {
|
|
||||||
if (!props.flightPlan.track.length) return null;
|
|
||||||
|
|
||||||
return Math.round(props.flightPlan.track.map(t => t.distance).reduce((a, b) => a + b));
|
|
||||||
});
|
|
||||||
const totalTime = computed(() => {
|
|
||||||
if (!props.flightPlan.track.length) return null;
|
|
||||||
|
|
||||||
return props.flightPlan.track.map(t => getDuration(t.distance, props.plannedSpeed)).reduce((a, b) => a + b)
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-list bordered class="q-ml-md-md q-pt-md">
|
<q-list bordered class="q-ml-md-md q-pt-md">
|
||||||
|
<q-item class="mobile-hide">
|
||||||
<q-item>
|
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<div style="display: flex; justify-content: space-between">
|
<div style="display: flex; justify-content: space-between">
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -59,38 +40,36 @@ const totalTime = computed(() => {
|
|||||||
<q-btn
|
<q-btn
|
||||||
@click='emits("fullscreen-toggle")'
|
@click='emits("fullscreen-toggle")'
|
||||||
size="sm"
|
size="sm"
|
||||||
|
round
|
||||||
:icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
:icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
<q-scroll-area style="height: 50vh">
|
<q-scroll-area class="mobile-hide" style="height: 55vh">
|
||||||
<template v-for="(segment, i) in props.flightPlan.track" :key="segment">
|
<template v-for="(segment, i) in props.flightPlan.track" :key="segment">
|
||||||
<q-item dense>
|
<q-item dense>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ segment.from.name }}</q-item-label>
|
<q-item-label>{{ segment.from.name }}</q-item-label>
|
||||||
<q-item-label caption class="q-py-sm" v-if="segment.distance">
|
<q-item-label caption class="q-pa-sm q-ml-sm" style="border-left: solid 1px" v-if="segment.distance">
|
||||||
|
|
||||||
{{ segment.bearing }}°,
|
{{ segment.bearing }}°,
|
||||||
{{ segment.distance }} km,
|
{{ segment.distance }} km,
|
||||||
{{ minutesToHumanReadable(getDuration(segment.distance, props.plannedSpeed)) }}
|
{{ minutesToHumanReadable(getDuration(segment.distance, props.flightPlan.plannedSpeed)) }}
|
||||||
</q-item-label>
|
|
||||||
<q-item-label>
|
|
||||||
<q-btn
|
<q-btn
|
||||||
@click="emits('change-insert-position', i+1)"
|
@click="emits('change-insert-position', i+1)"
|
||||||
icon="add"
|
icon="add"
|
||||||
size="sm"
|
size="xs"
|
||||||
class="q-ml-lg q-mb-sm"
|
round
|
||||||
:round="insertPosition !== i+1"
|
class="q-ml-sm"
|
||||||
:color="insertPosition === i+1 ? 'primary' : 'grey'"
|
:color="insertPosition === i+1 ? 'primary' : 'grey'"
|
||||||
>
|
/>
|
||||||
<template v-if="insertPosition === i+1">Přidávám sem</template>
|
|
||||||
</q-btn>
|
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
|
|
||||||
<q-item dense v-if="segment.to && i === props.flightPlan.track.length - 1">
|
<q-item dense v-if="segment.to && i === props.flightPlan.track.length - 1">
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>
|
<q-item-label>
|
||||||
@@ -99,23 +78,24 @@ const totalTime = computed(() => {
|
|||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</template>
|
</template>
|
||||||
</q-scroll-area>
|
|
||||||
<q-item>
|
|
||||||
<q-item-section>
|
|
||||||
<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>
|
|
||||||
<template v-if="totalDistance">
|
|
||||||
<q-item>
|
<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-item-section>
|
||||||
<q-separator/>
|
<q-separator/>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
@@ -124,7 +104,7 @@ const totalTime = computed(() => {
|
|||||||
<q-item-section>
|
<q-item-section>
|
||||||
Celková vzdálenost: {{ totalDistance }} km
|
Celková vzdálenost: {{ totalDistance }} km
|
||||||
<br>
|
<br>
|
||||||
Plánovaná doba letu: {{ minutesToHumanReadable(totalTime) }}
|
Plánovaná doba letu: {{ minutesToHumanReadable(getTotalDuration(props.flightPlan)) }}
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</template>
|
</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>
|
||||||
@@ -1,36 +1,48 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {formatDatetime} from "src/composables/datetimeUtils";
|
import {formatDatetime} from "src/composables/datetimeUtils";
|
||||||
|
import {useInfiniteScroll} from "src/composables/pagination";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: {
|
items: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true
|
required: true
|
||||||
}
|
},
|
||||||
})
|
|
||||||
|
...useInfiniteScroll().propsDef
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits([...useInfiniteScroll().emitsDef])
|
||||||
|
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-list bordered separator>
|
<q-infinite-scroll
|
||||||
<q-item
|
:disable="!hasMoreData"
|
||||||
clickable
|
@load="emitLoadMore"
|
||||||
:to="{name: 'editFlightPlan', params: {id: flightPlan.id }}"
|
:offset="250"
|
||||||
v-ripple
|
>
|
||||||
v-for="flightPlan of items" :key="flightPlan">
|
<q-list bordered separator>
|
||||||
<q-item-section avatar>
|
<q-item
|
||||||
<q-avatar icon="flight" color="primary" text-color="white"/>
|
clickable
|
||||||
</q-item-section>
|
:to="{name: 'editFlightPlan', params: {id: flightPlan.id }}"
|
||||||
<q-item-section>
|
v-ripple
|
||||||
<q-item-label>
|
v-for="flightPlan of items" :key="flightPlan">
|
||||||
{{ flightPlan.name || "Nepojmenovaný plán" }}
|
<q-item-section avatar>
|
||||||
</q-item-label>
|
<q-avatar icon="flight" color="primary" text-color="white"/>
|
||||||
<q-item-label caption v-if="flightPlan.plannedTakeoffDatetime">
|
</q-item-section>
|
||||||
{{ formatDatetime(flightPlan.plannedTakeoffDatetime) }}
|
<q-item-section>
|
||||||
</q-item-label>
|
<q-item-label>
|
||||||
<q-item-label caption v-if="flightPlan.aircraft">
|
{{ flightPlan.name || "Nepojmenovaný plán" }}
|
||||||
{{ flightPlan.aircraft.callSign }}
|
</q-item-label>
|
||||||
</q-item-label>
|
<q-item-label caption v-if="flightPlan.plannedTakeoffDatetime">
|
||||||
</q-item-section>
|
{{ formatDatetime(flightPlan.plannedTakeoffDatetime) }}
|
||||||
</q-item>
|
</q-item-label>
|
||||||
</q-list>
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const props = defineProps({
|
|||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
|
||||||
<q-item v-for="poi in flight.track" :key="poi.id">
|
<q-item v-for="poi in flight.turnPoints" :key="poi.id">
|
||||||
<q-item-section avatar>
|
<q-item-section avatar>
|
||||||
<q-icon name="place" v-if="poi.pointOfInterest"/>
|
<q-icon name="place" v-if="poi.pointOfInterest"/>
|
||||||
<q-icon name="local_airport" v-if="poi.airport"/>
|
<q-icon name="local_airport" v-if="poi.airport"/>
|
||||||
|
|||||||
@@ -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"> •</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>
|
||||||
@@ -2,12 +2,17 @@
|
|||||||
import MapWithMarkers from "components/map/MapWithMarkers.vue";
|
import MapWithMarkers from "components/map/MapWithMarkers.vue";
|
||||||
import {computed, reactive, ref} from "vue";
|
import {computed, reactive, ref} from "vue";
|
||||||
import {Line} from "vue-chartjs"
|
import {Line} from "vue-chartjs"
|
||||||
|
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
flight: {
|
flight: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
flightPlan: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
photos: {
|
photos: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -16,7 +21,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
|
|
||||||
const mapPhotos = computed(() => props.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
|
const mapPhotos = computed(() => props.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
|
||||||
const poisWithGPS = computed(() => props.flight.track.map(poi => poi.pointOfInterest || poi.airport));
|
const poisWithGPS = computed(() => props.flight.turnPoints.map(poi => poi.pointOfInterest || poi.airport));
|
||||||
|
|
||||||
const mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]);
|
const mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]);
|
||||||
const pointsOfInterests = computed(() => mapMarkers
|
const pointsOfInterests = computed(() => mapMarkers
|
||||||
@@ -25,10 +30,12 @@ const pointsOfInterests = computed(() => mapMarkers
|
|||||||
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
|
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
|
||||||
));
|
));
|
||||||
|
|
||||||
const gpxTrack = props.flight.gpxTrack ? reactive(props.flight.gpxTrack) : null;
|
// Tuu bylo reactive()
|
||||||
|
const flightTrack = props.flight.track ? props.flight.track.points : null;
|
||||||
|
|
||||||
const displayMap = computed(() => {
|
const displayMap = computed(() => {
|
||||||
return (
|
return (
|
||||||
!!gpxTrack ||
|
!!flightTrack ||
|
||||||
mapPhotos.value.length > 0 ||
|
mapPhotos.value.length > 0 ||
|
||||||
poisWithGPS.value.length > 0 ||
|
poisWithGPS.value.length > 0 ||
|
||||||
props.flight.landingAirport.id !== props.flight.takeoffAirport.id
|
props.flight.landingAirport.id !== props.flight.takeoffAirport.id
|
||||||
@@ -36,8 +43,14 @@ const displayMap = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let chartSpeedData, chartAltitudeData = null;
|
let chartSpeedData, chartAltitudeData = null;
|
||||||
if (gpxTrack) {
|
if (flightTrack) {
|
||||||
const chartLabels = gpxTrack.time.map(t => {
|
|
||||||
|
const timestamp = flightTrack.map(i => i.timestamp);
|
||||||
|
const speed = flightTrack.map(i => i.speed);
|
||||||
|
const altitude = flightTrack.map(i => i.altitude);
|
||||||
|
const terrainElevation = flightTrack.map(i => i.terrainElevation || 0);
|
||||||
|
|
||||||
|
const chartLabels = timestamp.map(t => {
|
||||||
const datetime = new Date(t);
|
const datetime = new Date(t);
|
||||||
return datetime.getHours() + ":" + datetime.getMinutes();
|
return datetime.getHours() + ":" + datetime.getMinutes();
|
||||||
});
|
});
|
||||||
@@ -47,7 +60,7 @@ if (gpxTrack) {
|
|||||||
backgroundColor: "#38bd3e",
|
backgroundColor: "#38bd3e",
|
||||||
borderColor: "#38bd3e",
|
borderColor: "#38bd3e",
|
||||||
label: "Rychlost (km/h)",
|
label: "Rychlost (km/h)",
|
||||||
data: gpxTrack.speed,
|
data: speed,
|
||||||
pointRadius: 1,
|
pointRadius: 1,
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
@@ -58,18 +71,18 @@ if (gpxTrack) {
|
|||||||
backgroundColor: "#9ae3ff",
|
backgroundColor: "#9ae3ff",
|
||||||
borderColor: "#9ae3ff",
|
borderColor: "#9ae3ff",
|
||||||
label: "Nadmořská výška letu (m)",
|
label: "Nadmořská výška letu (m)",
|
||||||
data: gpxTrack.altitude,
|
data: altitude,
|
||||||
pointRadius: 1,
|
pointRadius: 1,
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
if (gpxTrack.terrainElevation.length > 0) {
|
if (terrainElevation.length > 0) {
|
||||||
chartAltitudeData.datasets.push(
|
chartAltitudeData.datasets.push(
|
||||||
{
|
{
|
||||||
backgroundColor: "rgba(148,94,40,0.7)",
|
backgroundColor: "rgba(148,94,40,0.7)",
|
||||||
borderColor: "#945e28",
|
borderColor: "#945e28",
|
||||||
label: "Nadmořská výška země (m)",
|
label: "Nadmořská výška země (m)",
|
||||||
data: gpxTrack.terrainElevation,
|
data: terrainElevation,
|
||||||
fill: true,
|
fill: true,
|
||||||
pointRadius: 1,
|
pointRadius: 1,
|
||||||
});
|
});
|
||||||
@@ -85,7 +98,7 @@ const drawCurrentPositionMarker = (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const idx = res[0].index;
|
const idx = res[0].index;
|
||||||
currentPositionMarker.value = props.flight.gpxTrack.coordinates[idx];
|
currentPositionMarker.value = props.flight.track.points[idx].coordinates;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartOptions = {
|
const chartOptions = {
|
||||||
@@ -98,23 +111,38 @@ const chartOptions = {
|
|||||||
onClick: drawCurrentPositionMarker,
|
onClick: drawCurrentPositionMarker,
|
||||||
onHover: drawCurrentPositionMarker,
|
onHover: drawCurrentPositionMarker,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {getMarkersFrom} = useFlightPlanUtils();
|
||||||
|
const showPlanTrack = ref(true);
|
||||||
|
const tracks = computed(() => {
|
||||||
|
const items = [];
|
||||||
|
if (props.flight.track) {
|
||||||
|
items.push(props.flight.track.points.map(m => m.coordinates))
|
||||||
|
}
|
||||||
|
if (showPlanTrack.value && props.flightPlan) {
|
||||||
|
items.push(getMarkersFrom(props.flightPlan).map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude})));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-no-ssr>
|
<q-no-ssr>
|
||||||
<q-intersection>
|
<q-intersection>
|
||||||
|
<q-checkbox v-model="showPlanTrack" label="Zobrazovat plán?" v-if="flightPlan" />
|
||||||
|
|
||||||
<MapWithMarkers
|
<MapWithMarkers
|
||||||
v-if="displayMap"
|
v-if="displayMap"
|
||||||
style="width: 100%; height: 500px;"
|
style="width: 100%; height: 500px;"
|
||||||
:gpx-track="flight.gpxTrack?.coordinates || []"
|
:tracks="tracks"
|
||||||
:markers="pointsOfInterests"
|
:markers="pointsOfInterests"
|
||||||
:highlight-position="currentPositionMarker"
|
:highlight-position="currentPositionMarker"
|
||||||
:photos="mapPhotos"
|
:photos="mapPhotos"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
|
<Line v-if="flightTrack" :data="chartSpeedData" :options="chartOptions"/>
|
||||||
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
|
<Line v-if="flightTrack" :data="chartAltitudeData" :options="chartOptions"/>
|
||||||
</q-intersection>
|
</q-intersection>
|
||||||
</q-no-ssr>
|
</q-no-ssr>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||||
import {flightName} from "src/composables/flight";
|
import {flightName} from "src/composables/flight";
|
||||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||||
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
|
import {useInfiniteScroll} from "src/composables/pagination";
|
||||||
import {computed} from "vue";
|
|
||||||
import LoadMoreList from "components/LoadMoreList.vue";
|
|
||||||
import {pluralize} from "../../composables/utils";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: {
|
items: {
|
||||||
@@ -16,54 +13,46 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
totalItemsCount: {
|
...useInfiniteScroll().propsDef
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
itemsPerPage: {
|
|
||||||
type: Number,
|
|
||||||
required: false,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
isQueryRunning: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits([...useInfiniteScroll().emitsDef])
|
||||||
|
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||||
|
|
||||||
const routeDetail = (flight) => {
|
const routeDetail = (flight) => {
|
||||||
if (props.public)
|
if (props.public)
|
||||||
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
|
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
|
||||||
else
|
else
|
||||||
return {name: "flightDetail", params: {id: flight.id}}
|
return {name: "flightDetail", params: {id: flight.id}}
|
||||||
};
|
};
|
||||||
const loadMoreCount = computed(() => {
|
|
||||||
return Math.min(props.itemsPerPage, props.totalItemsCount - props.items.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
|
<q-infinite-scroll
|
||||||
<template #items>
|
:disable="!hasMoreData"
|
||||||
<q-list bordered separator>
|
@load="emitLoadMore"
|
||||||
<q-item
|
:offset="250"
|
||||||
v-for="flight of items" :key="flight"
|
>
|
||||||
ripple
|
<q-list bordered separator>
|
||||||
clickable
|
<q-item
|
||||||
:to="routeDetail(flight)"
|
v-for="flight of items" :key="flight"
|
||||||
>
|
ripple
|
||||||
<q-item-section avatar>
|
clickable
|
||||||
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
|
:to="routeDetail(flight)"
|
||||||
<q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
|
>
|
||||||
</q-item-section>
|
<q-item-section avatar class="gt-xs">
|
||||||
<q-item-section>
|
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
|
||||||
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
|
<q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-img v-if="flight.titlePhoto" class="lt-sm" :src="flight.titlePhoto.thumbnailUrl"/>
|
||||||
|
|
||||||
|
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between justify-sm-start list-secondary-info">
|
||||||
<div class="text-secondary" v-if="props.public">
|
<div class="text-secondary" v-if="props.public">
|
||||||
<q-icon name="support_agent"/>
|
<q-icon name="support_agent"/>
|
||||||
@{{ flight.pilot.publicUsername }}
|
{{ flight.pilot.publicUsername }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-secondary" v-if="flight.copilots">
|
<div class="text-secondary" v-if="flight.copilots">
|
||||||
@@ -85,27 +74,31 @@ const loadMoreCount = computed(() => {
|
|||||||
<q-icon name="event"/>
|
<q-icon name="event"/>
|
||||||
{{ flight.event.name }}
|
{{ flight.event.name }}
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
<q-item-section side top v-if="!props.public">
|
<q-item-section side top v-if="!props.public">
|
||||||
<!-- Zobraz jen v adminu -->
|
<!-- Zobraz jen v adminu -->
|
||||||
<q-icon name="visibility" v-if="flight.isPublic"/>
|
<q-icon name="visibility" v-if="flight.isPublic"/>
|
||||||
|
|
||||||
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
|
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
Let je sice publikovaný, nemá ale nastavenou URL adresu!
|
Let je sice publikovaný, nemá ale nastavenou URL adresu!
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
</q-icon>
|
</q-icon>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
|
<template v-slot:loading>
|
||||||
|
<div class="row justify-center q-my-md">
|
||||||
|
<q-spinner-dots color="primary" size="40px"/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #skeleton>
|
</q-infinite-scroll>
|
||||||
<FlightsListSkeleton :items="itemsPerPage"/>
|
|
||||||
</template>
|
|
||||||
<template #loadMoreText>
|
|
||||||
Načíst {{ pluralize(loadMoreCount, 'jeden let', `další ${loadMoreCount} lety`, `dalších ${loadMoreCount} letů`) }}
|
|
||||||
</template>
|
|
||||||
</LoadMoreList>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.list-secondary-info div {
|
||||||
|
padding: 0 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+19
-5
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {flightName} from "src/composables/flight";
|
import FlightHeaderInfo from "components/flights/FlightHeaderInfo.vue";
|
||||||
import LatestFlightSliderInfo from "components/flights/slider/LatestFlightSliderInfo.vue";
|
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: {
|
items: {
|
||||||
@@ -37,11 +37,25 @@ const slide = ref(0);
|
|||||||
height="400px"
|
height="400px"
|
||||||
v-if="flight.titlePhoto"
|
v-if="flight.titlePhoto"
|
||||||
>
|
>
|
||||||
<div class="absolute-bottom">
|
<div class="absolute-full">
|
||||||
<LatestFlightSliderInfo :flight="flight"/>
|
<FlightHeaderInfo
|
||||||
|
:route-detail="{name: 'publicFlightDetailNew', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
|
||||||
|
:pilot="flight.pilot"
|
||||||
|
:flight="flight"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="absolute-bottom-right">
|
||||||
|
<router-link
|
||||||
|
class="q-pa-lg"
|
||||||
|
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
|
||||||
|
>
|
||||||
|
{{ flight.pilot.publicUsername }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</FlightHeaderInfo>
|
||||||
</div>
|
</div>
|
||||||
</q-img>
|
</q-img>
|
||||||
<LatestFlightSliderInfo v-else :flight="flight" text-color="primary"/>
|
|
||||||
</q-carousel-slide>
|
</q-carousel-slide>
|
||||||
</q-carousel>
|
</q-carousel>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,43 +19,11 @@ const formData = reactive({
|
|||||||
copilots: [],
|
copilots: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const solo = ref(false);
|
|
||||||
watch(() => solo.value, (newVal) => {
|
|
||||||
if (!newVal) {
|
|
||||||
formData.copilots = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
watch(() => props.flight, (newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
|
|
||||||
solo.value = formData.copilots.length === 0;
|
|
||||||
}
|
|
||||||
}, {deep: true, immediate: true});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FormWrapper v-model="formData">
|
<FormWrapper v-model="formData">
|
||||||
<q-btn-toggle
|
|
||||||
spread
|
|
||||||
no-caps
|
|
||||||
v-model="solo"
|
|
||||||
toggle-color="primary"
|
|
||||||
:options="[{label: 'Solo', value: true}, {label: 'S někým', value: false}]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="!solo">
|
|
||||||
<ComboboxInput
|
|
||||||
v-for="i in flight.aircraft.seats - 1"
|
|
||||||
:key="i"
|
|
||||||
v-bind="commonProps"
|
|
||||||
:options="copilots"
|
|
||||||
v-model="formData.copilots[i-1]"
|
|
||||||
:clearable="true"
|
|
||||||
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</FormWrapper>
|
</FormWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
|||||||
import SlugInput from "components/inputs/SlugInput.vue";
|
import SlugInput from "components/inputs/SlugInput.vue";
|
||||||
import {storeToRefs} from "pinia";
|
import {storeToRefs} from "pinia";
|
||||||
import {useLoggedUserStore} from "stores/LoggedUser";
|
import {useLoggedUserStore} from "stores/LoggedUser";
|
||||||
|
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
flight: {
|
flight: {
|
||||||
@@ -18,6 +19,10 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
copilots: {
|
||||||
|
type: Array,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
isRequestPending: {
|
isRequestPending: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -31,7 +36,8 @@ const formData = reactive({
|
|||||||
description: "",
|
description: "",
|
||||||
debriefingNotes: "",
|
debriefingNotes: "",
|
||||||
urlSlug: "",
|
urlSlug: "",
|
||||||
isPublic: null
|
isPublic: null,
|
||||||
|
copilots: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +53,20 @@ watch(() => props.flight, (newVal) => {
|
|||||||
}
|
}
|
||||||
}, {immediate: true});
|
}, {immediate: true});
|
||||||
|
|
||||||
|
const solo = ref(false);
|
||||||
|
watch(() => solo.value, (newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
formData.copilots = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.flight, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
|
||||||
|
solo.value = formData.copilots.length === 0;
|
||||||
|
}
|
||||||
|
}, {deep: true, immediate: true});
|
||||||
|
|
||||||
const descriptionTab = ref("description");
|
const descriptionTab = ref("description");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -66,18 +86,52 @@ const descriptionTab = ref("description");
|
|||||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Letel jsi
|
||||||
|
<q-radio
|
||||||
|
v-model="solo"
|
||||||
|
:val="true"
|
||||||
|
label="Sólo"
|
||||||
|
/>
|
||||||
|
<q-radio
|
||||||
|
v-model="solo"
|
||||||
|
:val="false"
|
||||||
|
label="S někým"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!solo">
|
||||||
|
<ComboboxInput
|
||||||
|
v-for="i in flight.aircraft.seats - 1"
|
||||||
|
:key="i"
|
||||||
|
v-bind="commonProps"
|
||||||
|
:options="copilots"
|
||||||
|
v-model="formData.copilots[i-1]"
|
||||||
|
:clearable="true"
|
||||||
|
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<q-tabs v-model="descriptionTab" active-color="primary">
|
<q-tabs v-model="descriptionTab" active-color="primary">
|
||||||
<q-tab name="description">Popis</q-tab>
|
<q-tab name="description">Popis</q-tab>
|
||||||
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
|
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
<q-tab-panels v-model="descriptionTab">
|
<q-tab-panels v-model="descriptionTab">
|
||||||
<q-tab-panel name="description">
|
<q-tab-panel name="description">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
|
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|
||||||
<q-tab-panel name="debriefing">
|
<q-tab-panel name="debriefing">
|
||||||
|
<BasicInfoBanner>
|
||||||
|
Debriefing by měl obsahovat minimálně následující body:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Shrnutí letu:</strong> Trasa, čas a splnění cílů.</li>
|
||||||
|
<li><strong>Výkon:</strong> Navigace, reakce na podmínky, zvládnutí letu.</li>
|
||||||
|
<li><strong>Stav letadla:</strong> Funkčnost přístrojů, případné problémy.</li>
|
||||||
|
<li><strong>Bezpečnostní postupy:</strong> Dodržení bezpečnostních pravidel, řešení neočekávaných situací.</li>
|
||||||
|
<li><strong>Zpětná vazba:</strong> Pocity z letu, spokojenost pasažéra, návrhy na zlepšení.</li>
|
||||||
|
</ul>
|
||||||
|
</BasicInfoBanner>
|
||||||
|
|
||||||
<RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/>
|
<RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{name: "basic", icon: "flight_takeoff", label: "Základní informace"},
|
{name: "editFlightBasic", icon: "flight_takeoff", label: "Základní informace"},
|
||||||
{name: "copilot", icon: "person_add", label: "Spolucestující"},
|
{name: "editFlightTrack", icon: "route", label: "Trasa"},
|
||||||
{name: "track", icon: "route", label: "Trasa"},
|
{name: "editFlightPhotos", icon: "add_a_photo", label: "Fotky"},
|
||||||
{name: "photos", icon: "add_a_photo", label: "Fotky"},
|
{name: "editFlightNotes", icon: "edit_note", label: "Poznámky"},
|
||||||
{name: "notes", icon: "edit_note", label: "Poznámky"},
|
{name: "editFlightSetPlan", icon: "pin_drop", label: "Plán"},
|
||||||
];
|
];
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"])
|
const emit = defineEmits(["update:modelValue"])
|
||||||
@@ -37,14 +37,20 @@ watch(currentStep, (val) => emit("update:modelValue", val));
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-tabs v-if="routerEditMode" active-color="primary" class="q-mb-lg">
|
<q-tabs
|
||||||
|
v-if="routerEditMode"
|
||||||
|
active-color="primary"
|
||||||
|
class="q-mb-lg"
|
||||||
|
left-icon="chevron_left"
|
||||||
|
right-icon="chevron_right"
|
||||||
|
>
|
||||||
<q-route-tab
|
<q-route-tab
|
||||||
v-for="step in steps"
|
v-for="step in steps"
|
||||||
:key="step.name"
|
:key="step.name"
|
||||||
:icon="step.icon"
|
:icon="step.icon"
|
||||||
:label="step.label"
|
:label="step.label"
|
||||||
replace
|
replace
|
||||||
:to="{name: 'flightEdit', params: {id: flightId, step: step.name}}"
|
:to="{name: step.name, params: {id: flightId}}"
|
||||||
:disable="disabled.includes(step.name)"
|
:disable="disabled.includes(step.name)"
|
||||||
exact
|
exact
|
||||||
/>
|
/>
|
||||||
@@ -60,7 +66,6 @@ watch(currentStep, (val) => emit("update:modelValue", val));
|
|||||||
active-color="primary"
|
active-color="primary"
|
||||||
class="q-mb-lg"
|
class="q-mb-lg"
|
||||||
>
|
>
|
||||||
|
|
||||||
<q-tab v-for="step in steps" :key="step.name" v-bind="step" :disable="disabled.includes(step.name)"/>
|
<q-tab v-for="step in steps" :key="step.name" v-bind="step" :disable="disabled.includes(step.name)"/>
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ watch(poi, (newVal) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.flight.track, (newVal) => {
|
watch(() => props.flight.turnPoints, (newVal) => {
|
||||||
formData.track = [];
|
formData.track = [];
|
||||||
for (const i in newVal) {
|
for (const i in newVal) {
|
||||||
const trackInfo = newVal[i];
|
const trackInfo = newVal[i];
|
||||||
@@ -76,7 +76,7 @@ watch(() => props.flight.track, (newVal) => {
|
|||||||
<template>
|
<template>
|
||||||
<FormWrapper v-model="formData">
|
<FormWrapper v-model="formData">
|
||||||
|
|
||||||
<q-banner class="bg-green text-white" v-if="flight.gpxTrack">
|
<q-banner class="bg-green text-white" v-if="flight.track">
|
||||||
<q-icon name="check"/>
|
<q-icon name="check"/>
|
||||||
Trasa nahrána
|
Trasa nahrána
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-card class="q-my-sm">
|
||||||
|
<q-card-section>
|
||||||
|
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
|
||||||
|
<q-card class="q-mr-md-sm q-my-sm">
|
||||||
|
<q-card-section>
|
||||||
|
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<script setup></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||||
|
<q-skeleton type="rect" width="100%" height="500px"/>
|
||||||
|
</template>
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
|
||||||
import {randomInt} from "src/composables/random";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
step: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<template v-if="step === 'basic'">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<q-card class="q-my-sm">
|
|
||||||
<q-card-section>
|
|
||||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
|
|
||||||
<q-card class="q-mr-md-sm q-my-sm">
|
|
||||||
<q-card-section>
|
|
||||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<q-card v-else-if="step === 'copilot'">
|
|
||||||
<q-card class="q-mr-md-sm q-my-sm">
|
|
||||||
<q-card-section>
|
|
||||||
<q-skeleton type="QInput" class="q-my-sm"/>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<template v-else-if="step === 'track'">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="step === 'photos'">
|
|
||||||
<q-card class="q-my-lg">
|
|
||||||
<q-card-section>
|
|
||||||
<q-skeleton type="QInput" class="q-my-sm"/>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="i in randomInt(3, 10)" :key="i">
|
|
||||||
<q-card class="q-ma-md">
|
|
||||||
<q-skeleton height="300px" type="rect"/>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-else-if="step === 'notes'">
|
|
||||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
|
||||||
<q-skeleton type="rect" width="100%" height="500px"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup>
|
||||||
|
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
|
||||||
|
</template>
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import {flightName} from "src/composables/flight";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
flight: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
textColor: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: "white"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="text-h4">
|
|
||||||
<router-link
|
|
||||||
:class="`text-${props.textColor}`"
|
|
||||||
:to="{name: 'publicFlightDetail', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
|
|
||||||
>
|
|
||||||
{{ flightName(flight) }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<router-link
|
|
||||||
:class="`text-${props.textColor}`"
|
|
||||||
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
|
|
||||||
>
|
|
||||||
@{{ flight.pilot.publicUsername }}
|
|
||||||
</router-link>
|
|
||||||
</template>
|
|
||||||
@@ -3,13 +3,16 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-timeline color="primary" class="q-my-lg">
|
<q-timeline color="primary" class="q-my-lg">
|
||||||
|
<q-timeline-entry title="Naplánuj si let">
|
||||||
|
<div>
|
||||||
|
Pomocí jednoduchého plánovače si sestav trasu, označ si zajímavé body, připrav si navigační štítek a zkontroluj počasí.
|
||||||
|
</div>
|
||||||
|
</q-timeline-entry>
|
||||||
|
|
||||||
<q-timeline-entry title="Zaznamenej si let">
|
<q-timeline-entry title="Zaznamenej si let">
|
||||||
<div>
|
<div>
|
||||||
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
|
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
|
||||||
</div>
|
</div>
|
||||||
</q-timeline-entry>
|
|
||||||
|
|
||||||
<q-timeline-entry title="Ulož trasu">
|
|
||||||
<div>
|
<div>
|
||||||
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
|
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {Marker} from "vue3-google-map";
|
import {Marker} from "vue3-google-map";
|
||||||
import {onMounted, ref} from "vue";
|
import {onMounted, ref, watch} from "vue";
|
||||||
import MapWrapper from "components/map/MapWrapper.vue";
|
import MapWrapper from "components/map/MapWrapper.vue";
|
||||||
import {useGeocode} from "src/composables/geocoding";
|
import {useGeocode} from "src/composables/geocoding";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -32,22 +32,34 @@ const markerDropped = (position) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const zoom = ref(8);
|
const zoom = ref(8);
|
||||||
onMounted(() => {
|
|
||||||
if (props.gpsLatitude && props.gpsLongitude) {
|
|
||||||
center.value.lng = props.gpsLongitude;
|
watch(() => [props.label, props.gpsLongitude, props.gpsLatitude], (val, oldValue) => {
|
||||||
center.value.lat = props.gpsLatitude
|
const [newAddress, newLon, newLat] = val
|
||||||
} else if (props.label) {
|
const [previousAddress, oldLon, oldLat] = oldValue
|
||||||
searchGeocode(props.label).then(results => {
|
if (newAddress) {
|
||||||
|
if (newAddress === previousAddress) return
|
||||||
|
|
||||||
|
searchGeocode(newAddress).then(results => {
|
||||||
if (!results) return;
|
if (!results) return;
|
||||||
center.value = results[0].geometry.location;
|
|
||||||
|
const lat = results[0].geometry.location.lat()
|
||||||
|
const lng = results[0].geometry.location.lng()
|
||||||
|
|
||||||
|
center.value = {lng, lat}
|
||||||
emitModels(center.value.lat, center.value.lng);
|
emitModels(center.value.lat, center.value.lng);
|
||||||
zoom.value = 13;
|
zoom.value = 13;
|
||||||
});
|
});
|
||||||
|
} else if (newLat && newLon && (props.gpsLatitude !== newLat || props.gpsLongitude !== newLon)) {
|
||||||
|
center.value.lng = newLon;
|
||||||
|
center.value.lat = newLat
|
||||||
} else {
|
} else {
|
||||||
|
console.log(newAddress, newLon, newLat, props.label, props.gpsLongitude, props.gpsLatitude)
|
||||||
console.error("Set either label or gps coordinates.");
|
console.error("Set either label or gps coordinates.");
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
// {immediate: true}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup>
|
||||||
|
import {useAirportsStore} from "stores/Airports";
|
||||||
|
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||||
|
import {ref} from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [Object, Array]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = ref("");
|
||||||
|
|
||||||
|
const options = useAirportsStore().fetch();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComboboxInput :options="options" v-model="model"
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {InfoWindow} from "vue3-google-map";
|
import {CustomMarker, InfoWindow} from "vue3-google-map";
|
||||||
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
||||||
import {useGeocode} from "src/composables/geocoding";
|
import {useGeocode} from "src/composables/geocoding";
|
||||||
import {ref, watch} from "vue";
|
import {computed, ref, watch} from "vue";
|
||||||
import {useAirspaceColors} from "src/composables/airspaces";
|
import {useAirspaceColors} from "src/composables/airspaces";
|
||||||
import AltitudeLimit from "components/AltitudeLimit.vue";
|
import AltitudeLimit from "components/AltitudeLimit.vue";
|
||||||
|
|
||||||
@@ -16,6 +16,11 @@ const props = defineProps({
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
isInTrack: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
showActions: {
|
showActions: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -23,99 +28,202 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(["add-to-route", "highlight-airspace", "close"])
|
const emits = defineEmits(["add-to-route", "remove-from-route", "highlight-airspace", "close"])
|
||||||
|
|
||||||
const {getIcon} = useFlightPlanUtils();
|
const {getIcon} = useFlightPlanUtils();
|
||||||
const {searchPlaceName} = useGeocode()
|
const {searchPlaceName} = useGeocode()
|
||||||
|
|
||||||
const title = ref(null);
|
const title = ref(null);
|
||||||
|
const isOpened = ref(props.infoWindow.active);
|
||||||
watch(() => props.infoWindow.position, async () => {
|
watch(() => props.infoWindow.position, async () => {
|
||||||
if (props.infoWindow.title) {
|
if (props.infoWindow.title) {
|
||||||
title.value = props.infoWindow.title
|
title.value = props.infoWindow.title
|
||||||
} else {
|
} else {
|
||||||
title.value = await searchPlaceName(props.infoWindow.position.lat, props.infoWindow.position.lng);
|
searchPlaceName(props.infoWindow.position.lat, props.infoWindow.position.lng).then(data => title.value = data);
|
||||||
}
|
}
|
||||||
}, {immediate: true, deep: true});
|
}, {immediate: true, deep: true});
|
||||||
|
|
||||||
const addPoint = async () => {
|
const addPoint = () => {
|
||||||
let type = "mapPoint";
|
let type = "mapPoint";
|
||||||
if (props.infoWindow.poi) {
|
if (props.infoWindow.poi) {
|
||||||
type = "poi";
|
type = "poi";
|
||||||
} else if (props.infoWindow.airport) {
|
} else if (props.infoWindow.airport) {
|
||||||
type = "airport";
|
type = "airport";
|
||||||
}
|
}
|
||||||
|
|
||||||
const point = props.infoWindow.marker || {
|
const point = props.infoWindow.marker || {
|
||||||
name: title.value,
|
name: title.value,
|
||||||
type: type,
|
airportId: props.infoWindow.airport?.id,
|
||||||
|
pointOfInterestId: props.infoWindow.poi?.id,
|
||||||
|
type,
|
||||||
gpsLatitude: props.infoWindow.position.lat,
|
gpsLatitude: props.infoWindow.position.lat,
|
||||||
gpsLongitude: props.infoWindow.position.lng
|
gpsLongitude: props.infoWindow.position.lng
|
||||||
}
|
}
|
||||||
emits("add-to-route", point)
|
emits("add-to-route", point)
|
||||||
|
closeInfoWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeInfoWindow = () => {
|
||||||
|
emits('close');
|
||||||
|
isOpened.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePoint = () => {
|
||||||
|
emits("remove-from-route", props.infoWindow);
|
||||||
|
emits("close");
|
||||||
}
|
}
|
||||||
|
|
||||||
const {getColorByType} = useAirspaceColors();
|
const {getColorByType} = useAirspaceColors();
|
||||||
const highlightAirspace = (airspace) => {
|
const highlightAirspace = (airspace) => {
|
||||||
emits('highlight-airspace', airspace.name)
|
emits('highlight-airspace', airspace.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runways = computed(() => {
|
||||||
|
const runwayPairs = {};
|
||||||
|
|
||||||
|
for (const runway of props.infoWindow.airport.runways) {
|
||||||
|
runwayPairs[runway.designator] = runway;
|
||||||
|
}
|
||||||
|
|
||||||
|
return runwayPairs;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runwaySurfaceName = (surface) => {
|
||||||
|
const surfaceMap = {
|
||||||
|
grass: "tráva",
|
||||||
|
concrete: "beton",
|
||||||
|
asphalt: "asfalt"
|
||||||
|
}
|
||||||
|
return surfaceMap[surface] || 'jiné'
|
||||||
|
}
|
||||||
|
|
||||||
|
const openedTab = ref("info");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<InfoWindow
|
<InfoWindow
|
||||||
@close="emits('close')"
|
@close="closeInfoWindow"
|
||||||
|
v-model="isOpened"
|
||||||
:options="{position: infoWindow.position}"
|
:options="{position: infoWindow.position}"
|
||||||
>
|
>
|
||||||
<div class="text-h5">{{ title }}</div>
|
<q-tabs v-model="openedTab">
|
||||||
|
<q-tab name="info">{{ title }}</q-tab>
|
||||||
|
<q-tab name="airspaces" v-if="infoWindow.airspaces?.length">Prostory</q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
<q-tab-panels v-model="openedTab">
|
||||||
|
<q-tab-panel name="info">
|
||||||
|
<div v-if="infoWindow.airport">
|
||||||
|
Elevace: {{ infoWindow.airport.elevation }} m
|
||||||
|
|
||||||
<div v-if="infoWindow.marker">
|
<div class="row">
|
||||||
<span class="text-h6">
|
<div class="col">
|
||||||
<q-icon :name="getIcon(infoWindow.marker.type)"/>
|
<q-list dense>
|
||||||
{{ infoWindow.marker.name }}
|
<q-item dense v-for="runway in runways" :key="runway">
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="infoWindow.poi">
|
<q-item-section>
|
||||||
<q-img :src="infoWindow.poi.titlePhoto.thumbnailUrl" width="300px" v-if="infoWindow.poi.titlePhoto"/>
|
<q-item-label>
|
||||||
</div>
|
<strong>{{ runway.designator }}</strong> ({{ runwaySurfaceName(runway.surface) }})
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
{{ runway.heading }}°, {{ runway.width }} × {{ runway.length }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-list>
|
||||||
|
<q-item dense v-for="frequency in infoWindow.airport.frequencies" :key="frequency">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>
|
||||||
|
<strong>{{ frequency.name }}</strong>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
{{ frequency.frequency }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="infoWindow.airspaces?.length">
|
<div v-if="infoWindow.marker">
|
||||||
<span class="text-h6">Prostory</span>
|
<span class="text-h6">
|
||||||
<q-list>
|
<q-icon :name="getIcon(infoWindow.marker.type)"/>
|
||||||
<q-item-label v-for="airspace in infoWindow.airspaces" :key="airspace.name">
|
{{ infoWindow.marker.name }}
|
||||||
<q-avatar size="16px" class="q-mx-sm" :style="`border: solid 1px; background: ${getColorByType(airspace.type)}`"/>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<strong>
|
<div v-if="infoWindow.poi">
|
||||||
{{ airspace.name }}
|
<q-img :src="infoWindow.poi.titlePhoto.thumbnailUrl" width="300px" v-if="infoWindow.poi.titlePhoto"/>
|
||||||
</strong>
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
<q-tab-panel name="airspaces" v-if="infoWindow.airspaces?.length">
|
||||||
|
<q-list>
|
||||||
|
<q-item-label v-for="airspace in infoWindow.airspaces" :key="airspace.name">
|
||||||
|
<q-avatar size="16px" class="q-mx-sm" :style="`border: solid 1px; background: ${getColorByType(airspace.type)}`"/>
|
||||||
|
|
||||||
<AltitudeLimit
|
<strong>
|
||||||
class="q-mx-md"
|
{{ airspace.name }}
|
||||||
:upper-limit="airspace.upperLimit"
|
</strong>
|
||||||
:upper-limit-unit="airspace.upperAltUnit"
|
|
||||||
:lower-limit="airspace.lowerLimit"
|
|
||||||
:lower-limit-unit="airspace.lowerAltUnit"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<q-btn
|
<AltitudeLimit
|
||||||
v-if="airspace.type !== 'FIR'"
|
class="q-mx-md"
|
||||||
color="white"
|
:upper-limit="airspace.upperLimit"
|
||||||
:text-color="props.highlightedAirspaces.includes(airspace.name) ? 'primary' : 'grey-6'"
|
:upper-limit-unit="airspace.upperAltUnit"
|
||||||
size="x-small"
|
:lower-limit="airspace.lowerLimit"
|
||||||
round
|
:lower-limit-unit="airspace.lowerAltUnit"
|
||||||
text
|
/>
|
||||||
icon="mdi-eye"
|
|
||||||
@click="highlightAirspace(airspace)"
|
<q-btn
|
||||||
/>
|
v-if="airspace.type !== 'FIR'"
|
||||||
</q-item-label>
|
color="white"
|
||||||
</q-list>
|
:text-color="props.highlightedAirspaces.includes(airspace.name) ? 'primary' : 'grey-6'"
|
||||||
|
size="x-small"
|
||||||
|
round
|
||||||
|
text
|
||||||
|
icon="mdi-eye"
|
||||||
|
@click="highlightAirspace(airspace)"
|
||||||
|
/>
|
||||||
|
</q-item-label>
|
||||||
|
</q-list>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
|
||||||
|
<template v-if="showActions">
|
||||||
|
<q-btn
|
||||||
|
v-if="isInTrack"
|
||||||
|
class="q-mr-sm q-mt-lg"
|
||||||
|
color="warning"
|
||||||
|
@click="removePoint()"
|
||||||
|
>
|
||||||
|
Odebrat z trasy
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
:class="{'q-mt-lg': true, 'full-width': !isInTrack}"
|
||||||
|
color="secondary"
|
||||||
|
@click="addPoint()"
|
||||||
|
>
|
||||||
|
Přidat bod do trasy
|
||||||
|
</q-btn>
|
||||||
</template>
|
</template>
|
||||||
<q-btn
|
|
||||||
v-if="showActions"
|
<CustomMarker :options="{position: infoWindow.position, anchorPoint: 'CENTER'}" v-if="isOpened">
|
||||||
class="q-mt-lg"
|
<div class="info-window-position"></div>
|
||||||
color="secondary"
|
</CustomMarker>
|
||||||
@click="addPoint()">
|
|
||||||
Přidat bod do trasy
|
|
||||||
</q-btn>
|
|
||||||
</InfoWindow>
|
</InfoWindow>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-window-position {
|
||||||
|
border: solid 4px orange;
|
||||||
|
outline: solid 1px black;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 4px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ import {computed} from "vue";
|
|||||||
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
gpxTrack: {
|
|
||||||
type: Object,
|
|
||||||
required: false,
|
|
||||||
default: () => {
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tracks: {
|
tracks: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -29,6 +23,11 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: false,
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
bounds: {
|
||||||
|
type: Array,
|
||||||
|
required: false,
|
||||||
|
default: () => []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,14 +35,15 @@ const bounds = computed(() => {
|
|||||||
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
|
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
|
||||||
const coordinates = [
|
const coordinates = [
|
||||||
...[...props.markers, ...photosTransformed].filter(i => !!i.position).map(i => i.position),
|
...[...props.markers, ...photosTransformed].filter(i => !!i.position).map(i => i.position),
|
||||||
...(props.gpxTrack || []),
|
|
||||||
...(props.tracks || []).flat()
|
...(props.tracks || []).flat()
|
||||||
];
|
];
|
||||||
|
|
||||||
const minLat = Math.min(...coordinates.map(i => i.lat));
|
const latitudes = coordinates.filter(i => i && i.lat).map(i => i.lat);
|
||||||
const maxLat = Math.max(...coordinates.map(i => i.lat));
|
const longitudes = coordinates.filter(i => i && i.lng).map(i => i.lng);
|
||||||
const minLng = Math.min(...coordinates.map(i => i.lng));
|
const minLat = Math.min(...latitudes);
|
||||||
const maxLng = Math.max(...coordinates.map(i => i.lng));
|
const maxLat = Math.max(...latitudes);
|
||||||
|
const minLng = Math.min(...longitudes);
|
||||||
|
const maxLng = Math.max(...longitudes);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{lat: minLat, lng: minLng},
|
{lat: minLat, lng: minLng},
|
||||||
@@ -84,10 +84,6 @@ const getColor = (i) => {
|
|||||||
</InfoWindow>
|
</InfoWindow>
|
||||||
</Marker>
|
</Marker>
|
||||||
|
|
||||||
<template v-if="gpxTrack && gpxTrack.length > 0">
|
|
||||||
<Polyline :options="{path: gpxTrack, geodesic: true, strokeColor: getColor(0), strokeOpacity: 0.5, strokeWeight: 5}"/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="tracks && tracks.length > 0">
|
<template v-if="tracks && tracks.length > 0">
|
||||||
<template v-for="(track, i) in tracks" :key="track">
|
<template v-for="(track, i) in tracks" :key="track">
|
||||||
<Polyline :options="{path: track, geodesic: true, strokeColor: getColor(i), strokeOpacity: 0.5, strokeWeight: 5}"/>
|
<Polyline :options="{path: track, geodesic: true, strokeColor: getColor(i), strokeOpacity: 0.5, strokeWeight: 5}"/>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {GoogleMap, CustomControl, CustomMarker, Polygon} from "vue3-google-map";
|
import {CustomControl, CustomMarker, GoogleMap, Polygon, Polyline} from "vue3-google-map";
|
||||||
import {computed, ref, watch} from "vue";
|
import {computed, ref, watch} from "vue";
|
||||||
import {useVectorUtilities} from "src/graphql/fragments/vectorUtilities";
|
import {useVectorUtilities} from "src/graphql/fragments/vectorUtilities";
|
||||||
import {useAirspaceColors} from "src/composables/airspaces";
|
import {useAirspaceColors, useAltitudeUtils} from "src/composables/airspaces";
|
||||||
import AirspaceInfoWindow from "components/map/AirspaceInfoWindow.vue";
|
import AirspaceInfoWindow from "components/map/AirspaceInfoWindow.vue";
|
||||||
import {useFlightPlanObjects} from "src/composables/flightPlan";
|
import {useFlightPlanObjects} from "src/composables/flightPlan";
|
||||||
|
import {debounce} from "quasar";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
bounds: {
|
bounds: {
|
||||||
@@ -21,6 +22,15 @@ const props = defineProps({
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
pointsOfInterest: {
|
||||||
|
type: Array,
|
||||||
|
required: false,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
showInfoWindow: {
|
showInfoWindow: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -38,12 +48,13 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(["map-click", "add-to-route"]);
|
const emits = defineEmits(["map-click", "add-to-route", "remove-from-route"]);
|
||||||
|
|
||||||
const key = process.env.GOOGLE_MAPS_API_KEY;
|
const key = process.env.GOOGLE_MAPS_API_KEY;
|
||||||
|
|
||||||
const zoomLevel = ref(null);
|
|
||||||
const mapRef = ref(null);
|
const mapRef = ref(null);
|
||||||
|
const zoomLevel = ref(null);
|
||||||
|
const visibleBoundsObject = ref(null);
|
||||||
watch(
|
watch(
|
||||||
() => mapRef.value?.ready,
|
() => mapRef.value?.ready,
|
||||||
(ready) => {
|
(ready) => {
|
||||||
@@ -58,48 +69,70 @@ watch(
|
|||||||
}
|
}
|
||||||
zoomLevel.value = mapRef.value.map.getZoom();
|
zoomLevel.value = mapRef.value.map.getZoom();
|
||||||
|
|
||||||
mapRef.value.map.addListener("zoom_changed", () => {
|
mapRef.value.map.addListener("zoom_changed", debounce(() => {
|
||||||
console.log("zoom changed");
|
|
||||||
zoomLevel.value = mapRef.value.map.getZoom();
|
zoomLevel.value = mapRef.value.map.getZoom();
|
||||||
})
|
}, 100));
|
||||||
|
|
||||||
|
mapRef.value.map.addListener("bounds_changed", debounce(() => {
|
||||||
|
visibleBoundsObject.value = mapRef.value.map.getBounds();
|
||||||
|
}, 100));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const airspaceLayers = ref({
|
||||||
|
CTR: {displayed: true, color: "", minZoom: 7},
|
||||||
|
TMA: {displayed: true, color: "", minZoom: 7},
|
||||||
|
RMZ: {displayed: false, color: "", minZoom: 7},
|
||||||
|
DANGER: {label: "Nebezpečné prostory", displayed: false, color: "", minZoom: 7},
|
||||||
|
PROHIBIT: {label: "Zakázané prostory", displayed: true, color: "", minZoom: 7},
|
||||||
|
RESTRICT: {label: "TRA", displayed: true, color: "", minZoom: 7},
|
||||||
|
TRAGA: {displayed: true, color: "", minZoom: 8},
|
||||||
|
ATZ: {displayed: true, color: "", minZoom: 8},
|
||||||
|
AIRPORTS: {label: "Letiště", displayed: true, color: "", minZoom: 10},
|
||||||
|
FIELDSTRIP: {label: "SLZ plochy", displayed: false, color: "", minZoom: 11},
|
||||||
|
})
|
||||||
|
|
||||||
const displayAirspaceLayers = ref({
|
const isAirspaceVisible = (airspace, bounds) => {
|
||||||
CTR: true,
|
if (!bounds) return false;
|
||||||
TMA: true,
|
|
||||||
RMZ: false,
|
if (airspace.mapBounds) {
|
||||||
ATZ: true,
|
return airspace.mapBounds.some(b => bounds.contains(b));
|
||||||
PARA: false,
|
}
|
||||||
DANGER: false,
|
|
||||||
PROHIBIT: true,
|
if (airspace.gpsLatitude && airspace.gpsLongitude) {
|
||||||
RESTRICT: true,
|
return bounds.contains({lat: airspace.gpsLatitude, lng: airspace.gpsLongitude});
|
||||||
TRAGA: true,
|
}
|
||||||
AIRPORTS: true,
|
|
||||||
FIELDSTRIP: false,
|
return true;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const maxAltitude = ref(5000);
|
||||||
|
const {altitudeLimitFormatted, altitudeToFt} = useAltitudeUtils();
|
||||||
|
|
||||||
const airspaces = computed(() => {
|
const airspaces = computed(() => {
|
||||||
const colorsMap = useAirspaceColors().colors;
|
const colorsMap = useAirspaceColors().colors;
|
||||||
|
|
||||||
return props.airspacesData
|
return props.airspacesData
|
||||||
.filter(airspace => !!colorsMap[airspace.type])
|
.filter(airspace => airspaceLayers.value[airspace.type] && airspaceLayers.value[airspace.type].displayed)
|
||||||
.filter(airspace => displayAirspaceLayers.value[airspace.type])
|
.filter(airspace => airspaceLayers.value[airspace.type].minZoom <= zoomLevel.value)
|
||||||
|
.filter(airspace => isAirspaceVisible(airspace, visibleBoundsObject.value))
|
||||||
|
.filter(airspace => altitudeToFt(airspace.lowerLimit, airspace.lowerAltUnit) < maxAltitude.value)
|
||||||
.map(airspace => {
|
.map(airspace => {
|
||||||
|
|
||||||
let fillOpacity = 0;
|
let fillOpacity = 0;
|
||||||
|
let strokeWeight = 1;
|
||||||
if (["PROHIBIT", "RESTRICT"].includes(airspace.type))
|
if (["PROHIBIT", "RESTRICT"].includes(airspace.type))
|
||||||
fillOpacity = 0.1;
|
fillOpacity = 0.1;
|
||||||
if (highlightedAirspaces.value.includes(airspace.name))
|
if (highlightedAirspaces.value.includes(airspace.name)) {
|
||||||
fillOpacity = 0.2;
|
strokeWeight = 2;
|
||||||
|
fillOpacity = 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: airspace.name,
|
name: airspace.name,
|
||||||
strokeColor: colorsMap[airspace.type],
|
strokeColor: colorsMap[airspace.type],
|
||||||
fillColor: colorsMap[airspace.type],
|
fillColor: colorsMap[airspace.type],
|
||||||
strokeOpacity: 0.5,
|
strokeOpacity: 0.5,
|
||||||
strokeWeight: 1,
|
strokeWeight,
|
||||||
fillOpacity,
|
fillOpacity,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,9 +145,15 @@ const airspaces = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const airportsOnMap = computed(() => {
|
const airportsOnMap = computed(() => {
|
||||||
if (!displayAirspaceLayers.value.AIRPORTS && !displayAirspaceLayers.value.FIELDSTRIP) return [];
|
const displayAirport = airspaceLayers.value.AIRPORTS.displayed;
|
||||||
|
const displayFieldstrip = airspaceLayers.value.FIELDSTRIP.displayed;
|
||||||
|
if (!displayAirport && !displayFieldstrip) return [];
|
||||||
|
|
||||||
return props.airports.filter(a => a.gpsLatitude && a.gpsLongitude).filter(a => a.icaoCode.length === 6 ? displayAirspaceLayers.value.FIELDSTRIP : displayAirspaceLayers.value.AIRPORTS);
|
return props.airports
|
||||||
|
.filter(a => a.gpsLatitude && a.gpsLongitude)
|
||||||
|
.filter(a => isAirspaceVisible(a, visibleBoundsObject.value))
|
||||||
|
.filter(a => zoomLevel.value >= (a.icaoCode.length === 6 ? airspaceLayers.value.FIELDSTRIP.minZoom : airspaceLayers.value.AIRPORTS.minZoom))
|
||||||
|
.filter(a => a.icaoCode.length === 6 ? displayFieldstrip : displayAirport)
|
||||||
});
|
});
|
||||||
|
|
||||||
const {isPointInPolygon} = useVectorUtilities();
|
const {isPointInPolygon} = useVectorUtilities();
|
||||||
@@ -130,7 +169,7 @@ const getAirspacesAtCoordinates = (clickCoordinates) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {infoWindowObject} = useFlightPlanObjects();
|
const {infoWindowObject} = useFlightPlanObjects();
|
||||||
const infoWindow = ref(infoWindowObject({}));
|
const infoWindow = ref({...infoWindowObject({})});
|
||||||
|
|
||||||
const openInfoWindow = (data) => {
|
const openInfoWindow = (data) => {
|
||||||
infoWindow.value = infoWindowObject({active: true, ...data});
|
infoWindow.value = infoWindowObject({active: true, ...data});
|
||||||
@@ -141,11 +180,20 @@ defineExpose({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const emitMapClick = (data) => {
|
const emitMapClick = (data) => {
|
||||||
|
isControlsOpened.value = false;
|
||||||
openInfoWindow(data);
|
openInfoWindow(data);
|
||||||
emits("map-click", data);
|
emits("map-click", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightedAirspaces = ref([]);
|
const highlightedAirspaces = ref([]);
|
||||||
|
const highlightAirspace = (airspaceName) => {
|
||||||
|
const airspace = props.airspacesData.find(a => a.name === airspaceName);
|
||||||
|
|
||||||
|
if (airspace) {
|
||||||
|
highlightedAirspaces.value = [airspaceName];
|
||||||
|
airspaceLayers.value[airspace.type].displayed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleMapClick = (e) => {
|
const handleMapClick = (e) => {
|
||||||
const position = {lat: e.latLng.lat(), lng: e.latLng.lng()};
|
const position = {lat: e.latLng.lat(), lng: e.latLng.lng()};
|
||||||
@@ -173,26 +221,52 @@ const handleAirportClick = (airport) => {
|
|||||||
|
|
||||||
emitMapClick({position, airport, airspaces: airspacesUnderClick})
|
emitMapClick({position, airport, airspaces: airspacesUnderClick})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePoiClick = (poi) => {
|
||||||
|
const position = {lat: poi.gpsLatitude, lng: poi.gpsLongitude};
|
||||||
|
|
||||||
|
const airspacesUnderClick = getAirspacesAtCoordinates(position);
|
||||||
|
highlightedAirspaces.value = [];
|
||||||
|
|
||||||
|
emitMapClick({poi, position, airspaces: airspacesUnderClick});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDetailInTrack = computed(() => {
|
||||||
|
if (!props.track) return false;
|
||||||
|
|
||||||
|
return !!props.track.path.find(i => i.lat === infoWindow.value.position.lat && i.lng === infoWindow.value.position.lng);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isControlsOpened = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
|
<!-- <q-banner>-->
|
||||||
|
<!-- https://dev.to/quasar/how-to-use-google-maps-in-a-quasar-project-1c13-->
|
||||||
|
<!-- </q-banner>-->
|
||||||
|
|
||||||
<GoogleMap
|
<GoogleMap
|
||||||
:api-key="key"
|
:api-key="key"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:street-view-control="false"
|
:street-view-control="false"
|
||||||
@click="handleMapClick"
|
@click="handleMapClick"
|
||||||
map-type-id="roadmap"
|
map-type-id="roadmap"
|
||||||
|
gesture-handling="greedy"
|
||||||
:max-zoom="13"
|
:max-zoom="13"
|
||||||
ref="mapRef"
|
ref="mapRef"
|
||||||
>
|
>
|
||||||
|
<!-- TODO: pridavani a odebirani z trasy resit pres slot-->
|
||||||
<AirspaceInfoWindow
|
<AirspaceInfoWindow
|
||||||
v-if="showInfoWindow && infoWindow.active"
|
v-if="showInfoWindow && infoWindow.active"
|
||||||
:info-window="infoWindow"
|
:info-window="infoWindow"
|
||||||
:show-actions="allowAddMarker"
|
:show-actions="allowAddMarker"
|
||||||
:highlighted-airspaces="highlightedAirspaces"
|
:highlighted-airspaces="highlightedAirspaces"
|
||||||
|
:is-in-track="isDetailInTrack"
|
||||||
@close="infoWindow.active=false"
|
@close="infoWindow.active=false"
|
||||||
@add-to-route="emits('add-to-route', $event)"
|
@add-to-route="emits('add-to-route', $event)"
|
||||||
@highlight-airspace="highlightedAirspaces = [$event]"
|
@remove-from-route="emits('remove-from-route', $event)"
|
||||||
|
@highlight-airspace="highlightAirspace"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-for="airspace in airspaces" :key="airspace.name">
|
<template v-for="airspace in airspaces" :key="airspace.name">
|
||||||
@@ -203,40 +277,115 @@ const handleAirportClick = (airport) => {
|
|||||||
|
|
||||||
<template v-for="airport in airportsOnMap" :key="airport.icaoCode">
|
<template v-for="airport in airportsOnMap" :key="airport.icaoCode">
|
||||||
<CustomMarker
|
<CustomMarker
|
||||||
|
style="position: relative; z-index: 100"
|
||||||
:options="{position: {lat: airport.gpsLatitude, lng: airport.gpsLongitude}, anchorPoint: 'CENTER'}"
|
:options="{position: {lat: airport.gpsLatitude, lng: airport.gpsLongitude}, anchorPoint: 'CENTER'}"
|
||||||
@click.stop="handleAirportClick(airport)"
|
@click.stop="handleAirportClick(airport)"
|
||||||
>
|
>
|
||||||
<q-chip size="xs">{{ airport.icaoCode }}</q-chip>
|
<img
|
||||||
|
:title="airport.icaoCode"
|
||||||
|
:src="airport.iconUrl"
|
||||||
|
:alt="airport.icaoCode"
|
||||||
|
/>
|
||||||
</CustomMarker>
|
</CustomMarker>
|
||||||
|
|
||||||
<template v-if="displayAirspaceLayers.CTR">
|
<template v-if="airspaceLayers.CTR.displayed">
|
||||||
<CustomMarker
|
<CustomMarker
|
||||||
v-for="reportingPoint in airport.reportingPoints" :key="reportingPoint.id"
|
v-for="reportingPoint in airport.reportingPoints" :key="reportingPoint.id"
|
||||||
:options="{position: {lat: reportingPoint.gpsLatitude, lng: reportingPoint.gpsLongitude, anchorPoint: 'CENTER'}}"
|
:options="{position: {lat: reportingPoint.gpsLatitude, lng: reportingPoint.gpsLongitude, anchorPoint: 'CENTER'}}"
|
||||||
@click.stop="handleReportingPointClick(airport, reportingPoint)"
|
@click.stop="handleReportingPointClick(airport, reportingPoint)"
|
||||||
:title="`${airport.icaoCode}: ${reportingPoint.name}`"
|
:title="`${airport.icaoCode}: ${reportingPoint.name}`"
|
||||||
|
style="font-size: 20px"
|
||||||
>
|
>
|
||||||
<q-icon name="mdi-triangle"/>
|
▲
|
||||||
</CustomMarker>
|
</CustomMarker>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-for="poi in pointsOfInterest" :key="poi.name">
|
||||||
|
<CustomMarker
|
||||||
|
v-if="poi.gpsLatitude && poi.gpsLongitude"
|
||||||
|
:options="{position: {lat: poi.gpsLatitude, lng: poi.gpsLongitude}, anchorPoint: 'BOTTOM_CENTER'}"
|
||||||
|
@click.stop="handlePoiClick(poi)"
|
||||||
|
>
|
||||||
|
<div class="poi-avatar" :title="poi.name" v-if="!poi.titlePhoto">
|
||||||
|
<img src="/mapIcons/photo-32x32.png" alt="">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:title="poi.name"
|
||||||
|
v-else
|
||||||
|
class="poi-img"
|
||||||
|
:style="`background-image: url(${poi.titlePhoto.thumbnailUrl})`"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</CustomMarker>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Polyline v-if="track" :options="track"/>
|
||||||
|
|
||||||
|
<CustomControl position="TOP_LEFT" v-if="showLayerControls" style="width: auto; max-width: 200px">
|
||||||
|
<q-btn
|
||||||
|
@click="isControlsOpened = !isControlsOpened"
|
||||||
|
:icon="isControlsOpened ? 'layers_clear' : 'layers'"
|
||||||
|
color="grey-2"
|
||||||
|
text-color="grey-9"
|
||||||
|
round
|
||||||
|
size="sm"
|
||||||
|
class="q-my-md"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
<q-card
|
||||||
|
v-if="isControlsOpened"
|
||||||
|
class="q-px-lg"
|
||||||
|
style="max-width: 30%; min-width: 200px;"
|
||||||
|
>
|
||||||
|
<template v-for="(airspaceLayer, key) in airspaceLayers" :key="airspaceLayer">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="airspaceLayers[key].displayed"
|
||||||
|
v-if="airspaceLayers[key].minZoom <= zoomLevel"
|
||||||
|
:label="airspaceLayer.label || key"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="q-my-lg">
|
||||||
|
Zobrazit prostory od GND do výšky {{ altitudeLimitFormatted(maxAltitude, 'ft') }}
|
||||||
|
<q-slider
|
||||||
|
v-model.number="maxAltitude"
|
||||||
|
:min="1000"
|
||||||
|
:max="15000"
|
||||||
|
label
|
||||||
|
:label-value="altitudeLimitFormatted(maxAltitude, 'ft')"
|
||||||
|
:step="1000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot name="layersControlsEnd"></slot>
|
||||||
|
|
||||||
<CustomControl position="BOTTOM_CENTER" class="text-center" v-if="showLayerControls">
|
|
||||||
<q-card class="q-my-md q-mx-auto q-px-lg" style="max-width: 98%;">
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.CTR" label="CTR"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.TMA" label="TMA"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.RMZ" label="RMZ"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.ATZ" label="ATZ"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.DANGER" label="Nebezpečné prostory"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.PROHIBIT" label="Zakázané prostory"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.RESTRICT" label="TRA"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.TRAGA" label="TRAGA"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.PARA" label="PARA"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.AIRPORTS" label="Letiště"/>
|
|
||||||
<q-checkbox v-model="displayAirspaceLayers.FIELDSTRIP" label="SLZ plochy"/>
|
|
||||||
</q-card>
|
</q-card>
|
||||||
</CustomControl>
|
</CustomControl>
|
||||||
|
|
||||||
</GoogleMap>
|
</GoogleMap>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.poi-img {
|
||||||
|
border: solid 3px #8bdfff;
|
||||||
|
outline: solid 1px black;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poi-avatar {
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: #8bdfff;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const props = defineProps({
|
|||||||
copilots: {
|
copilots: {
|
||||||
type: Array
|
type: Array
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
@@ -44,7 +47,12 @@ const setup = () => {
|
|||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center">
|
||||||
<q-img :src="photo.thumbnailUrl" width="80px" class="q-ma-sm" v-for="photo in photos" :key="photo.thumbnailUrl"/>
|
<q-img :src="photo.thumbnailUrl" width="80px" class="q-ma-sm" v-for="photo in photos" :key="photo.thumbnailUrl"/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
|
||||||
|
<q-card-section v-if="loading">
|
||||||
|
<q-spinner />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-else>
|
||||||
<FormWrapper :save-button="false">
|
<FormWrapper :save-button="false">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
:options="locations"
|
:options="locations"
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
copilots: {
|
copilots: {
|
||||||
type: Array
|
type: Array
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +41,10 @@ const setup = (() => {
|
|||||||
<template>
|
<template>
|
||||||
<DialogWrapper :formData="formData" :object-id="photoId" @show="setup">
|
<DialogWrapper :formData="formData" :object-id="photoId" @show="setup">
|
||||||
<q-img :src="photo.thumbnailUrl"/>
|
<q-img :src="photo.thumbnailUrl"/>
|
||||||
<q-card-section>
|
<q-card-section v-if="loading">
|
||||||
|
<q-spinner />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section v-else>
|
||||||
<FormWrapper :save-button="false">
|
<FormWrapper :save-button="false">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
:options="locations"
|
:options="locations"
|
||||||
@@ -67,7 +73,7 @@ const setup = (() => {
|
|||||||
option-value="id"
|
option-value="id"
|
||||||
emit-value
|
emit-value
|
||||||
map-options
|
map-options
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<q-input v-model="formData.name" label="Název" v-bind="commonProps"/>
|
<q-input v-model="formData.name" label="Název" v-bind="commonProps"/>
|
||||||
<q-input type="textarea" v-model="formData.description" label="Popis" v-bind="commonProps"/>
|
<q-input type="textarea" v-model="formData.description" label="Popis" v-bind="commonProps"/>
|
||||||
|
|||||||
@@ -46,17 +46,6 @@ const initLightbox = () => {
|
|||||||
$lightbox.value.init();
|
$lightbox.value.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCollapsed = ref(true);
|
|
||||||
const photosCollapseLimit = computed(() => {
|
|
||||||
if ($q.screen.width <= $q.screen.sizes.sm) return 2;
|
|
||||||
else if ($q.screen.width <= $q.screen.sizes.md) return 3;
|
|
||||||
else return 4;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
isCollapsed.value = props.photos.length >= photosCollapseLimit.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if ($lightbox.value) {
|
if ($lightbox.value) {
|
||||||
$lightbox.value.destroy();
|
$lightbox.value.destroy();
|
||||||
@@ -81,12 +70,12 @@ watch(() => [openedPhoto.value, $lightbox.value], () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="{'photogallery--wrapper': true, collapsed: isCollapsed}" :id="`${galleryId}-wrapper`">
|
<div :class="{'photogallery--wrapper': true}" :id="`${galleryId}-wrapper`">
|
||||||
<div :id="galleryId" class="row photogallery--content">
|
<div :id="galleryId" class="row photogallery--content">
|
||||||
<div
|
<div
|
||||||
v-for="photo in photos" :key="photo.id"
|
v-for="photo in photos" :key="photo.id"
|
||||||
style="min-height: 100px"
|
style="min-height: 100px"
|
||||||
class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm"
|
class="col-6 col-sm-4 col-md-4 col-lg-2 q-pa-sm"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
:data-pswp-src="photo.url"
|
:data-pswp-src="photo.url"
|
||||||
@@ -111,9 +100,6 @@ watch(() => [openedPhoto.value, $lightbox.value], () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="photogallery--collapse" v-if="isCollapsed" @click="isCollapsed = false">
|
|
||||||
<q-btn class="q-mt-lg q-mb-md" color="secondary">Rozbalit</q-btn>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const showLarge = (displayedPhoto) => {
|
|||||||
</div>
|
</div>
|
||||||
<q-btn flat icon="edit" color="blue" @click="openEdit(photo)"/>
|
<q-btn flat icon="edit" color="blue" @click="openEdit(photo)"/>
|
||||||
<q-btn flat icon="tune" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/>
|
<q-btn flat icon="tune" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/>
|
||||||
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"/>
|
<q-btn flat icon="delete" color="red" :disabled="titlePhoto?.id === photo.id" @click="confirmPhotoDelete(photo.id)"/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,8 +74,18 @@ const handlePositionNotFound = () => {
|
|||||||
<template #append v-if="showResetMapButton">
|
<template #append v-if="showResetMapButton">
|
||||||
<q-btn @click="resetMap" icon="gps_fixed" round flat text-color="green" title="Nastav pozici na mapě"/>
|
<q-btn @click="resetMap" icon="gps_fixed" round flat text-color="green" title="Nastav pozici na mapě"/>
|
||||||
</template>
|
</template>
|
||||||
|
<template #after>
|
||||||
|
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||||
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
|
<MapGPSInput
|
||||||
|
:label="poiNameForMap"
|
||||||
|
:zoom="zoom"
|
||||||
|
v-model:gpsLatitude="formData.gpsLatitude"
|
||||||
|
v-model:gpsLongitude="formData.gpsLongitude"
|
||||||
|
@not-found="handlePositionNotFound"
|
||||||
|
/>
|
||||||
<SlugInput
|
<SlugInput
|
||||||
v-if="formData.isPublic"
|
v-if="formData.isPublic"
|
||||||
v-bind="commonProps"
|
v-bind="commonProps"
|
||||||
@@ -85,21 +95,11 @@ const handlePositionNotFound = () => {
|
|||||||
:slug-source="formData.name"
|
:slug-source="formData.name"
|
||||||
:rules="rules.urlSlug"
|
:rules="rules.urlSlug"
|
||||||
/>
|
/>
|
||||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
|
||||||
|
|
||||||
|
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
|
||||||
|
|
||||||
<MapGPSInput
|
|
||||||
v-if="poiNameForMap"
|
|
||||||
:key="poiNameForMap"
|
|
||||||
:label="poiNameForMap"
|
|
||||||
:zoom="zoom"
|
|
||||||
v-model:gpsLatitude="formData.gpsLatitude"
|
|
||||||
v-model:gpsLongitude="formData.gpsLongitude"
|
|
||||||
@not-found="handlePositionNotFound"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis"/>
|
<q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis"/>
|
||||||
|
|
||||||
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
|
|
||||||
</FormWrapper>
|
</FormWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import LoadMoreList from "components/LoadMoreList.vue";
|
import {useInfiniteScroll} from "src/composables/pagination";
|
||||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: {
|
items: {
|
||||||
@@ -11,20 +10,7 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
totalItemsCount: {
|
...useInfiniteScroll().propsDef,
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
itemsPerPage: {
|
|
||||||
type: Number,
|
|
||||||
required: false,
|
|
||||||
default: 5,
|
|
||||||
},
|
|
||||||
isQueryRunning: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getRoute = (item) => {
|
const getRoute = (item) => {
|
||||||
@@ -33,35 +19,35 @@ const getRoute = (item) => {
|
|||||||
else
|
else
|
||||||
return {name: "poiDetail", params: {id: item.id}}
|
return {name: "poiDetail", params: {id: item.id}}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emits = defineEmits(useInfiniteScroll().emitsDef)
|
||||||
|
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
|
<q-infinite-scroll
|
||||||
<template #items>
|
:disable="!hasMoreData"
|
||||||
<q-list bordered separator>
|
@load="emitLoadMore"
|
||||||
<q-item clickable v-ripple v-for="poi of items" :key="poi" :to="getRoute(poi)">
|
:offset="250"
|
||||||
<q-item-section avatar>
|
>
|
||||||
<q-avatar v-if="poi.titlePhoto">
|
<q-list bordered separator>
|
||||||
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
|
<q-item clickable v-for="poi of items" :key="poi" :to="getRoute(poi)">
|
||||||
</q-avatar>
|
<q-item-section avatar>
|
||||||
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
|
<q-avatar v-if="poi.titlePhoto">
|
||||||
</q-item-section>
|
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
|
||||||
<q-item-section>
|
</q-avatar>
|
||||||
{{ poi.name }}
|
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
|
||||||
<div class="text-secondary" v-if="poi.type">
|
</q-item-section>
|
||||||
{{ poi.type.name }}
|
<q-item-section>
|
||||||
</div>
|
{{ poi.name }}
|
||||||
</q-item-section>
|
<div class="text-secondary" v-if="poi.type">
|
||||||
<q-item-section side top v-if="!props.public && poi.isPublic">
|
{{ poi.type.name }}
|
||||||
<q-icon name="visibility"></q-icon>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
<q-item-section side top v-if="!props.public && poi.isPublic">
|
||||||
</q-list>
|
<q-icon name="visibility"></q-icon>
|
||||||
</template>
|
</q-item-section>
|
||||||
<template #skeleton>
|
</q-item>
|
||||||
<BasicListSkeleton :items="itemsPerPage"/>
|
</q-list>
|
||||||
</template>
|
</q-infinite-scroll>
|
||||||
<template #loadMoreText>
|
|
||||||
Další
|
|
||||||
</template>
|
|
||||||
</LoadMoreList>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
|
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
|
||||||
|
import {useUnitsConvertors} from "src/composables/units";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
weather: {
|
weather: {
|
||||||
@@ -9,9 +10,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const kmhToKnots = (speedKmh) => {
|
const {kmhToKnots} = useUnitsConvertors();
|
||||||
return Math.round(speedKmh * 0.53996);
|
|
||||||
}
|
|
||||||
|
|
||||||
const openDetail = ref(false);
|
const openDetail = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,3 +18,21 @@ export const useAirspaceColors = () => {
|
|||||||
|
|
||||||
return {colors, getColorByType}
|
return {colors, getColorByType}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const useAltitudeUtils = () => {
|
||||||
|
const altitudeToFt = (limit, unit) => {
|
||||||
|
if (unit === 'FL') return parseInt(limit) * 100;
|
||||||
|
return parseInt(limit);
|
||||||
|
}
|
||||||
|
const altitudeLimitFormatted = (limit, unit) => {
|
||||||
|
if (parseInt(limit) === 0) return 'GND';
|
||||||
|
else if (unit === 'FL') return `FL${limit}`;
|
||||||
|
else {
|
||||||
|
if (parseInt(limit) >= 5000) return `FL${limit / 100}`;
|
||||||
|
return `${limit} ft`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {altitudeLimitFormatted, altitudeToFt}
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export const publicCopilotBreadcrumbs = (pilot, flight, event, copilot) => {
|
|||||||
export const publicPOIBaseBreadcrumbs = () => {
|
export const publicPOIBaseBreadcrumbs = () => {
|
||||||
return [
|
return [
|
||||||
...homepageBreadcrumbs(),
|
...homepageBreadcrumbs(),
|
||||||
{label: "Místa"},
|
{
|
||||||
|
to: {name: 'publicPOIsList'},
|
||||||
|
label: "Místa"
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const flightName = (flight) => {
|
|||||||
if (flight.name !== '') {
|
if (flight.name !== '') {
|
||||||
return `${flight.name} (${date})`
|
return `${flight.name} (${date})`
|
||||||
} else {
|
} else {
|
||||||
const track = flight.track ? flight.track.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : [];
|
const track = flight.turnPoints ? flight.turnPoints.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : [];
|
||||||
const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - ';
|
const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - ';
|
||||||
|
|
||||||
return `${flight.takeoffAirport?.icaoCode || '???'} ${trackStr} ${flight.landingAirport?.icaoCode || "???"} (${date})`;
|
return `${flight.takeoffAirport?.icaoCode || '???'} ${trackStr} ${flight.landingAirport?.icaoCode || "???"} (${date})`;
|
||||||
|
|||||||
@@ -23,14 +23,33 @@ export const useFlightPlanUtils = () => {
|
|||||||
return {getIcon, getMarkersFrom}
|
return {getIcon, getMarkersFrom}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useFlightPlanCalculations = () => {
|
||||||
|
const getDuration = (distance, speed) => Math.round(distance / speed * 60)
|
||||||
|
|
||||||
|
const getTotalDistance = (flightPlan) => {
|
||||||
|
if (!flightPlan.track.length) return null;
|
||||||
|
|
||||||
|
return Math.round(flightPlan.track.map(t => t.distance).reduce((a, b) => a + b));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTotalDuration = (flightPlan, plannedSpeed = undefined) => {
|
||||||
|
if (!plannedSpeed) plannedSpeed = flightPlan.plannedSpeed;
|
||||||
|
if (!flightPlan.track.length) return null;
|
||||||
|
|
||||||
|
return flightPlan.track.map(t => getDuration(t.distance, plannedSpeed)).reduce((a, b) => a + b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {getDuration, getTotalDistance, getTotalDuration}
|
||||||
|
}
|
||||||
|
|
||||||
export const useFlightPlanObjects = () => {
|
export const useFlightPlanObjects = () => {
|
||||||
const editableNameTypes = ["geocode", "mapPoint"];
|
const editableNameTypes = ["geocode", "mapPoint"];
|
||||||
|
|
||||||
const markerObject = ({name, type, gpsLatitude, gpsLongitude}) => ({
|
const markerObject = ({name, type, airportId, pointOfInterestId, gpsLatitude, gpsLongitude}) => ({
|
||||||
name, type, gpsLatitude, gpsLongitude
|
name, type, gpsLatitude, gpsLongitude, pointOfInterestId, airportId
|
||||||
});
|
});
|
||||||
|
|
||||||
const googleMapsCustomMarkerObject = (marker) => {
|
const googleMapsCustomMarkerObject = (marker, overrideAnchorPoint = undefined) => {
|
||||||
const anchorPoints = {
|
const anchorPoints = {
|
||||||
airport: "CENTER",
|
airport: "CENTER",
|
||||||
geocode: "BOTTOM_CENTER",
|
geocode: "BOTTOM_CENTER",
|
||||||
@@ -39,7 +58,7 @@ export const useFlightPlanObjects = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
anchorPoint: anchorPoints[marker.type],
|
anchorPoint: overrideAnchorPoint || anchorPoints[marker.type],
|
||||||
position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}
|
position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +71,7 @@ export const useFlightPlanObjects = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoWindowObject = ({active, position, marker, poi, airport, reportingPoint, airspaces}) => {
|
const infoWindowObject = ({active, position, marker, poi, airport, reportingPoint, airspaces, isInTrack}) => {
|
||||||
if (!position) position = {};
|
if (!position) position = {};
|
||||||
if (!position.lat) position.lat = 0;
|
if (!position.lat) position.lat = 0;
|
||||||
if (!position.lng) position.lng = 0;
|
if (!position.lng) position.lng = 0;
|
||||||
@@ -83,7 +102,7 @@ export const useFlightPlanObjects = () => {
|
|||||||
position.lng = poi.gpsLongitude;
|
position.lng = poi.gpsLongitude;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {title, active, position, airspaces, poi, marker, airport, reportingPoint};
|
return {title, active, position, airspaces, poi, marker, airport, reportingPoint, isInTrack};
|
||||||
};
|
};
|
||||||
|
|
||||||
return {markerObject, googleMapsMarkerObject, googleMapsCustomMarkerObject, infoWindowObject, editableNameTypes}
|
return {markerObject, googleMapsMarkerObject, googleMapsCustomMarkerObject, infoWindowObject, editableNameTypes}
|
||||||
|
|||||||
@@ -1,43 +1,73 @@
|
|||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
export const useGeocode = () => {
|
export const useGeocode = () => {
|
||||||
|
|
||||||
const center = ref({lat: 50.1314, lng: 14.5256});
|
const center = ref({lat: 50.1314, lng: 14.5256});
|
||||||
|
|
||||||
|
const getGeocoder = (limitMs = 5000) => {
|
||||||
|
let interval = null
|
||||||
|
const poolingWindowMs = 100
|
||||||
|
|
||||||
const searchGeocode = (address) => {
|
const tries = limitMs / poolingWindowMs
|
||||||
return axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURI(address)}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => {
|
let currentTry = 0
|
||||||
if (response.data.status !== 'OK') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data.results;
|
return new Promise((resolve, reject) => {
|
||||||
});
|
let instance = null
|
||||||
}
|
interval = setInterval(() => {
|
||||||
|
try {
|
||||||
|
instance = new google.maps.Geocoder()
|
||||||
|
|
||||||
const searchPlaceName = (lat, lng) => {
|
clearInterval(interval)
|
||||||
return axios.get(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => {
|
return resolve(instance)
|
||||||
if (response.data.status !== 'OK' || !response.data.results?.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = response.data.results;
|
} catch (e) {
|
||||||
const components = {};
|
if (++currentTry >= tries) {
|
||||||
for (const addressComponent of results[0].address_components) {
|
clearInterval(interval)
|
||||||
for (const namePartType of ["locality", "postal_town", 'administrative_area_level_2']) {
|
return reject(instance)
|
||||||
if (addressComponent.types.includes(namePartType)) {
|
|
||||||
components[namePartType] = addressComponent.long_name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, poolingWindowMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.keys(components).length) {
|
const searchGeocode = (address) => new Promise((resolve, reject) => {
|
||||||
return "Bod na mapě";
|
getGeocoder().then(geocoder => geocoder
|
||||||
}
|
.geocode({address})
|
||||||
|
.then(({results}) => resolve(results))
|
||||||
|
.catch((e) => reject(e))).catch(() => {
|
||||||
|
console.log("Cannot get geocoder")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return Object.values(components).join(", ");
|
|
||||||
});
|
const searchPlaceName = (lat, lng) => {
|
||||||
|
const latlng = {
|
||||||
|
lat: parseFloat(lat), lng: parseFloat(lng),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
getGeocoder().then((geocoder) => geocoder
|
||||||
|
.geocode({location: latlng})
|
||||||
|
.then((response) => {
|
||||||
|
const results = response.results;
|
||||||
|
const components = {};
|
||||||
|
for (const addressComponent of results[0].address_components) {
|
||||||
|
for (const namePartType of ["locality", "postal_town", 'administrative_area_level_2']) {
|
||||||
|
if (addressComponent.types.includes(namePartType)) {
|
||||||
|
components[namePartType] = addressComponent.long_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.keys(components).length) {
|
||||||
|
return resolve("Bod na mapě");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(Object.values(components).join(", "));
|
||||||
|
})
|
||||||
|
.catch((e) => reject(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {center, searchGeocode, searchPlaceName}
|
return {center, searchGeocode, searchPlaceName}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const commonKeywords = [
|
|||||||
|
|
||||||
|
|
||||||
export const aircraftKeywords = (aircraft) => {
|
export const aircraftKeywords = (aircraft) => {
|
||||||
return [aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
|
return ["letadlo", "letoun", aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const flightEventKeywords = (event) => {
|
export const flightEventKeywords = (event) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ref} from "vue";
|
import {computed, ref} from "vue";
|
||||||
|
|
||||||
|
|
||||||
export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
|
export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
|
||||||
@@ -9,19 +9,72 @@ export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
|
|||||||
const paginationLimit = ref(itemsPerPage);
|
const paginationLimit = ref(itemsPerPage);
|
||||||
const paginationOffset = ref(0);
|
const paginationOffset = ref(0);
|
||||||
const filters = ref({});
|
const filters = ref({});
|
||||||
|
const hasMoreData = ref(true)
|
||||||
|
const isQueryRunning = ref(false)
|
||||||
|
|
||||||
const loadPage = (appendItems) => {
|
|
||||||
|
const loadFirstPage = () => {
|
||||||
|
paginationOffset.value = 0
|
||||||
|
isQueryRunning.value = true
|
||||||
return fetchFunction({
|
return fetchFunction({
|
||||||
...filters.value,
|
...filters.value, limit: paginationLimit.value, offset: 0, appendItems: false
|
||||||
limit: paginationLimit.value,
|
}).then((data) => {
|
||||||
offset: paginationOffset.value,
|
|
||||||
appendItems
|
|
||||||
}).then(() => {
|
|
||||||
paginationOffset.value += paginationLimit.value;
|
paginationOffset.value += paginationLimit.value;
|
||||||
|
hasMoreData.value = data.length === paginationLimit.value;
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isQueryRunning.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPage = (index, done) => {
|
||||||
|
if (isQueryRunning.value) return
|
||||||
|
|
||||||
|
isQueryRunning.value = true
|
||||||
|
return fetchFunction({
|
||||||
|
...filters.value, appendItems: true, limit: paginationLimit.value, offset: paginationOffset.value,
|
||||||
|
}).then((data) => {
|
||||||
|
paginationOffset.value += paginationLimit.value;
|
||||||
|
hasMoreData.value = data.length === paginationLimit.value;
|
||||||
|
if (done) done()
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isQueryRunning.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFiltersChanged = (newFilters) => {
|
||||||
|
filters.value = newFilters;
|
||||||
|
loadFirstPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paginationOffset, paginationLimit, filters, loadPage
|
paginationOffset, paginationLimit, handleFiltersChanged, filters, loadPage, loadFirstPage, hasMoreData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useLoading = (promiseGetter) => {
|
||||||
|
const loading = ref(false)
|
||||||
|
const load = () => {
|
||||||
|
loading.value = true
|
||||||
|
return promiseGetter().finally(() => loading.value = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {loading, load}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const useInfiniteScroll = (emits, props) => {
|
||||||
|
const emitsDef = ["load-more"]
|
||||||
|
const propsDef = {
|
||||||
|
hasMoreData: {
|
||||||
|
type: Boolean, required: false, default: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMoreData = computed(() => props.hasMoreData)
|
||||||
|
const emitLoadMore = (index, done) => emits("load-more", {index, done});
|
||||||
|
|
||||||
|
return {emitsDef, propsDef, hasMoreData, emitLoadMore}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
export const ktToKph = (speedInKt) => {
|
export const useUnitsConvertors = () => {
|
||||||
return speedInKt;
|
|
||||||
|
const kmhToKnots = (speedKmh) => {
|
||||||
|
return Math.round(speedKmh * 0.53996);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ftToM = (distanceInFt) => {
|
||||||
|
return Math.round(distanceInFt * 0.3048 * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {kmhToKnots, ftToM}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,18 @@ export const pluralize = (count, singular, plural, pluralMore) => {
|
|||||||
else if (count < 5) return plural;
|
else if (count < 5) return plural;
|
||||||
else return pluralMore;
|
else return pluralMore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const textEllipsis = (text, maxLength) => {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
let words = text.split(' ');
|
||||||
|
|
||||||
|
const maxWords = 2;
|
||||||
|
if (words.length <= maxWords + 1) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return words[0] + ' ... ' + words[words.length - 1];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
|
|
||||||
export const FRAGMENT_AIRCRAFT_BASIC_INFO = gql`
|
export const FRAGMENT_AIRCRAFT_BASIC_INFO = gql`
|
||||||
fragment aircraftBasicInfo on Aircraft {
|
fragment aircraftBasicInfo on Aircraft {
|
||||||
id
|
|
||||||
callSign
|
|
||||||
manufacturer
|
|
||||||
|
|
||||||
organization {
|
|
||||||
id
|
id
|
||||||
name
|
callSign
|
||||||
|
manufacturer
|
||||||
|
|
||||||
|
organization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
|
|
||||||
|
export const FRAGMENT_AIRPORT_LIST_INFO = gql`
|
||||||
|
fragment airportListInfo on Airport {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
icaoCode
|
||||||
|
gpsLatitude
|
||||||
|
gpsLongitude
|
||||||
|
elevation
|
||||||
|
iconUrl
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const FRAGMENT_AIRPORT_INFO = gql`
|
export const FRAGMENT_AIRPORT_INFO = gql`
|
||||||
fragment airportInfo on Airport {
|
fragment airportInfo on Airport {
|
||||||
id
|
id
|
||||||
@@ -7,11 +19,24 @@ export const FRAGMENT_AIRPORT_INFO = gql`
|
|||||||
icaoCode
|
icaoCode
|
||||||
gpsLatitude
|
gpsLatitude
|
||||||
gpsLongitude
|
gpsLongitude
|
||||||
|
elevation
|
||||||
|
iconUrl
|
||||||
reportingPoints {
|
reportingPoints {
|
||||||
id
|
id
|
||||||
gpsLatitude
|
gpsLatitude
|
||||||
gpsLongitude
|
gpsLongitude
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
frequencies {
|
||||||
|
frequency
|
||||||
|
name
|
||||||
|
}
|
||||||
|
runways {
|
||||||
|
designator
|
||||||
|
heading
|
||||||
|
surface
|
||||||
|
length
|
||||||
|
width
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
|
|||||||
|
|
||||||
export const FRAGMENT_WEATHER_FULL_INFO = gql`
|
export const FRAGMENT_WEATHER_FULL_INFO = gql`
|
||||||
fragment weatherFullInfo on WeatherInfo {
|
fragment weatherFullInfo on WeatherInfo {
|
||||||
datetime
|
dateTime
|
||||||
qnh
|
qnh
|
||||||
temperatureSurface
|
temperatureSurface
|
||||||
dewpointSurface
|
dewpointSurface
|
||||||
@@ -29,7 +29,8 @@ fragment flightFullInfo on Flight {
|
|||||||
landings
|
landings
|
||||||
durationMinCalculated
|
durationMinCalculated
|
||||||
isPublic
|
isPublic
|
||||||
|
flightPlanId
|
||||||
|
socialImageUrl
|
||||||
titlePhoto {
|
titlePhoto {
|
||||||
thumbnailUrl
|
thumbnailUrl
|
||||||
url
|
url
|
||||||
@@ -59,23 +60,30 @@ fragment flightFullInfo on Flight {
|
|||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
|
||||||
gpxTrack {
|
track {
|
||||||
coordinates {
|
points {
|
||||||
|
coordinates {
|
||||||
|
lat
|
||||||
|
lng
|
||||||
|
}
|
||||||
|
timestamp
|
||||||
|
magneticVariation
|
||||||
|
speed
|
||||||
|
altitude
|
||||||
|
terrainElevation
|
||||||
|
}
|
||||||
|
mapBounds {
|
||||||
lat
|
lat
|
||||||
lng
|
lng
|
||||||
}
|
}
|
||||||
time
|
|
||||||
magneticVariation
|
|
||||||
speed
|
|
||||||
maxSpeed
|
maxSpeed
|
||||||
avgSpeed
|
avgSpeed
|
||||||
altitude
|
|
||||||
terrainElevation
|
|
||||||
maxAltitude
|
maxAltitude
|
||||||
avgAltitude
|
avgAltitude
|
||||||
}
|
}
|
||||||
|
|
||||||
track {
|
turnPoints {
|
||||||
pointOfInterest {
|
pointOfInterest {
|
||||||
...pointOfInterestFullInfo
|
...pointOfInterestFullInfo
|
||||||
}
|
}
|
||||||
@@ -99,6 +107,7 @@ ${FRAGMENT_WEATHER_FULL_INFO}
|
|||||||
export const FRAGMENT_FLIGHT_FULL_PRIVATE_INFO = gql`
|
export const FRAGMENT_FLIGHT_FULL_PRIVATE_INFO = gql`
|
||||||
fragment flightFullPrivateInfo on Flight {
|
fragment flightFullPrivateInfo on Flight {
|
||||||
...flightFullInfo
|
...flightFullInfo
|
||||||
|
flightPlanId
|
||||||
debriefingNotes
|
debriefingNotes
|
||||||
|
|
||||||
copilots {
|
copilots {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
|
import {FRAGMENT_WEATHER_FULL_INFO} from "src/graphql/fragments/flight";
|
||||||
|
|
||||||
export const FRAGMENT_FLIGHT_PLAN_MARKER = gql`
|
export const FRAGMENT_FLIGHT_PLAN_MARKER = gql`
|
||||||
fragment flightPlanMarker on FlightPlanMarker {
|
fragment flightPlanMarker on FlightPlanMarker {
|
||||||
@@ -7,7 +8,15 @@ fragment flightPlanMarker on FlightPlanMarker {
|
|||||||
type
|
type
|
||||||
gpsLatitude
|
gpsLatitude
|
||||||
gpsLongitude
|
gpsLongitude
|
||||||
|
pointOfInterestId
|
||||||
|
airportId
|
||||||
|
|
||||||
|
weatherInfo {
|
||||||
|
...weatherFullInfo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
${FRAGMENT_WEATHER_FULL_INFO}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const FRAGMENT_FLIGHT_PLAN_FULL_INFO = gql`
|
export const FRAGMENT_FLIGHT_PLAN_FULL_INFO = gql`
|
||||||
@@ -19,11 +28,16 @@ fragment flightPlanFullInfo on FlightPlan {
|
|||||||
isDefaultName
|
isDefaultName
|
||||||
isPublic
|
isPublic
|
||||||
isEditableByCopilot
|
isEditableByCopilot
|
||||||
|
description
|
||||||
|
|
||||||
aircraft {
|
aircraft {
|
||||||
id
|
id
|
||||||
callSign
|
callSign
|
||||||
seats
|
seats
|
||||||
|
isPublic
|
||||||
|
titlePhoto {
|
||||||
|
thumbnailUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
track {
|
track {
|
||||||
bearing
|
bearing
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
import {FRAGMENT_AIRPORT_INFO} from "src/graphql/fragments/airport";
|
import {FRAGMENT_AIRPORT_LIST_INFO} from "src/graphql/fragments/airport";
|
||||||
import {FRAGMENT_AIRCRAFT_BASIC_INFO} from "src/graphql/fragments/aircraft_basic";
|
import {FRAGMENT_AIRCRAFT_BASIC_INFO} from "src/graphql/fragments/aircraft_basic";
|
||||||
|
|
||||||
export const FRAGMENT_FLIGHT_LIST_INFO = gql`
|
export const FRAGMENT_FLIGHT_LIST_INFO = gql`
|
||||||
@@ -17,7 +17,7 @@ fragment flightListInfo on Flight {
|
|||||||
url
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
track {
|
turnPoints {
|
||||||
pointOfInterest {
|
pointOfInterest {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -33,11 +33,11 @@ fragment flightListInfo on Flight {
|
|||||||
}
|
}
|
||||||
|
|
||||||
takeoffAirport {
|
takeoffAirport {
|
||||||
...airportInfo
|
...airportListInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
landingAirport {
|
landingAirport {
|
||||||
...airportInfo
|
...airportListInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
aircraft {
|
aircraft {
|
||||||
@@ -45,7 +45,7 @@ fragment flightListInfo on Flight {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${FRAGMENT_AIRPORT_INFO}
|
${FRAGMENT_AIRPORT_LIST_INFO}
|
||||||
${FRAGMENT_AIRCRAFT_BASIC_INFO}
|
${FRAGMENT_AIRCRAFT_BASIC_INFO}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const FRAGMENT_USER_FULL_INFO = gql`
|
|||||||
email
|
email
|
||||||
avatarImageUrl
|
avatarImageUrl
|
||||||
titleImageUrl
|
titleImageUrl
|
||||||
|
socialImageUrl
|
||||||
publicUsername
|
publicUsername
|
||||||
description
|
description
|
||||||
createdAt
|
createdAt
|
||||||
|
|||||||
@@ -2,19 +2,17 @@ import gql from "graphql-tag";
|
|||||||
import {FRAGMENT_AIRCRAFT_FULL_INFO, FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO, FRAGMENT_AIRCRAFT_LIST_INFO} from "src/graphql/fragments/aircraft";
|
import {FRAGMENT_AIRCRAFT_FULL_INFO, FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO, FRAGMENT_AIRCRAFT_LIST_INFO} from "src/graphql/fragments/aircraft";
|
||||||
|
|
||||||
export const AIRCRAFTS_LIST = gql`
|
export const AIRCRAFTS_LIST = gql`
|
||||||
query AircraftsList ($limit: Int!, $offset: Int) {
|
query AircraftsList ($search: String, $limit: Int!, $offset: Int) {
|
||||||
aircrafts(limit: $limit, offset: $offset) {
|
aircrafts(search: $search, limit: $limit, offset: $offset) {
|
||||||
totalItemsCount
|
totalItemsCount
|
||||||
items {
|
items {
|
||||||
...aircraftListInfo
|
...aircraftListInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${FRAGMENT_AIRCRAFT_LIST_INFO}
|
${FRAGMENT_AIRCRAFT_LIST_INFO}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
export const AIRCRAFT_DETAIL = gql`
|
export const AIRCRAFT_DETAIL = gql`
|
||||||
query AircraftDetail($id: Int!) {
|
query AircraftDetail($id: Int!) {
|
||||||
aircraft(id: $id) {
|
aircraft(id: $id) {
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ query eventsFlightsTracks($eventId: Int, $eventUrlSlug: String, $onlyPublic: Boo
|
|||||||
event(id: $eventId, urlSlug: $eventUrlSlug, public: $onlyPublic) {
|
event(id: $eventId, urlSlug: $eventUrlSlug, public: $onlyPublic) {
|
||||||
flights(onlyPublic: $onlyPublic) {
|
flights(onlyPublic: $onlyPublic) {
|
||||||
durationMinCalculated
|
durationMinCalculated
|
||||||
gpxTrack {
|
track {
|
||||||
coordinates {
|
points {
|
||||||
lat
|
coordinates {
|
||||||
lng
|
lat
|
||||||
|
lng
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import gql from "graphql-tag";
|
|||||||
import {FRAGMENT_FLIGHT_PLAN_FULL_INFO, FRAGMENT_FLIGHT_PLAN_LIST_INFO} from "src/graphql/fragments/flightPlan";
|
import {FRAGMENT_FLIGHT_PLAN_FULL_INFO, FRAGMENT_FLIGHT_PLAN_LIST_INFO} from "src/graphql/fragments/flightPlan";
|
||||||
|
|
||||||
export const FLIGHT_PLANS_LIST = gql`
|
export const FLIGHT_PLANS_LIST = gql`
|
||||||
query flightPlansList {
|
query flightPlansList($search: String, $limit: Int!, $offset: Int) {
|
||||||
flightPlans {
|
flightPlans(search: $search, limit: $limit, offset: $offset) {
|
||||||
...flightPlanListInfo
|
totalItemsCount
|
||||||
|
items {
|
||||||
|
...flightPlanListInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
${FRAGMENT_FLIGHT_PLAN_LIST_INFO}
|
${FRAGMENT_FLIGHT_PLAN_LIST_INFO}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FLIGHT_PLAN_DETAIL = gql`
|
export const FLIGHT_PLAN_DETAIL = gql`
|
||||||
query flightPlansDetail($id: Int!) {
|
query flightPlansDetail($id: Int!, $username: String, $public: Boolean) {
|
||||||
flightPlan(id: $id) {
|
flightPlan(id: $id, username: $username, public: $public) {
|
||||||
...flightPlanFullInfo
|
...flightPlanFullInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
|
|||||||
import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
|
import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
|
||||||
|
|
||||||
export const FLIGHTS_LIST = gql`
|
export const FLIGHTS_LIST = gql`
|
||||||
query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $limit: Int!, $offset: Int) {
|
query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $search: String, $limit: Int!, $offset: Int) {
|
||||||
flights(
|
flights(
|
||||||
eventId: $eventId,
|
eventId: $eventId,
|
||||||
aircraftId: $aircraftId,
|
aircraftId: $aircraftId,
|
||||||
copilotId: $copilotId,
|
copilotId: $copilotId,
|
||||||
pointOfInterestId: $pointOfInterestId,
|
pointOfInterestId: $pointOfInterestId,
|
||||||
|
search: $search,
|
||||||
limit: $limit,
|
limit: $limit,
|
||||||
offset: $offset
|
offset: $offset
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
||||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||||
filters.value = {aircraftId: aircraftId};
|
filters.value = {aircraftId: aircraftId};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPage(false);
|
loadFirstPage();
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,34 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import AircraftsList from "components/aircrafts/AircraftsList.vue";
|
import AircraftsList from "components/aircrafts/AircraftsList.vue";
|
||||||
import FAB from "components/FAB.vue";
|
|
||||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||||
import {useAircraftsStore} from "stores/Aircrafts";
|
import {useAircraftsStore} from "stores/Aircrafts";
|
||||||
import {storeToRefs} from "pinia";
|
import {storeToRefs} from "pinia";
|
||||||
import {useAppStore} from "stores/App";
|
import {useAppStore} from "stores/App";
|
||||||
import {usePagination} from "src/composables/pagination";
|
import {usePagination} from "src/composables/pagination";
|
||||||
import {onMounted} from "vue";
|
import ListFilters from "components/ListFilters.vue";
|
||||||
|
import PrimaryAction from "components/PrimaryAction.vue";
|
||||||
|
|
||||||
useAppStore().setPageTitle("Letadla");
|
useAppStore().setPageTitle("Letadla");
|
||||||
useAppStore().setBreadcrumbs([])
|
useAppStore().setBreadcrumbs([])
|
||||||
|
|
||||||
|
const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({fetchFunction: useAircraftsStore().fetch});
|
||||||
const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsStore());
|
const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsStore());
|
||||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useAircraftsStore().fetch});
|
|
||||||
onMounted(() => {
|
|
||||||
loadPage(false);
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BasicListSkeleton :items="paginationLimit" v-if="isQueryRunning && !totalItemsCount"/>
|
<ListFilters @filters-changed="handleFiltersChanged">
|
||||||
|
<template #append>
|
||||||
|
<PrimaryAction mode="add" :router-link="{name: 'addAircraft'}"/>
|
||||||
|
</template>
|
||||||
|
</ListFilters>
|
||||||
|
|
||||||
|
<BasicListSkeleton :items="5" v-if="isQueryRunning && !totalItemsCount"/>
|
||||||
|
|
||||||
<BasicInfoBanner
|
<BasicInfoBanner
|
||||||
v-if="!isQueryRunning && totalItemsCount === 0"
|
v-if="!isQueryRunning && totalItemsCount === 0"
|
||||||
:route-add="{name: 'addAircraft'}"
|
:route-add="{name: 'addAircraft'}"
|
||||||
label-add="Vytvořit letadlo"
|
label-add="Přidat letadlo"
|
||||||
>
|
>
|
||||||
Aktuálně nemáš přidané žádné letadlo. Přidej se k existující organizaci nebo si sem ulož vlastní letadlo.
|
Aktuálně nemáš přidané žádné letadlo. Přidej se k existující organizaci nebo si sem ulož vlastní letadlo.
|
||||||
<br>
|
<br>
|
||||||
@@ -35,13 +38,8 @@ onMounted(() => {
|
|||||||
<AircraftsList
|
<AircraftsList
|
||||||
v-else
|
v-else
|
||||||
:items="aircrafts"
|
:items="aircrafts"
|
||||||
:total-items-count="totalItemsCount"
|
|
||||||
:is-query-running="isQueryRunning"
|
|
||||||
:items-per-page="paginationLimit"
|
|
||||||
:public="true"
|
:public="true"
|
||||||
class="q-mb-xl"
|
:has-more-data="hasMoreData"
|
||||||
@load-more="loadPage(true)"
|
@load-more="loadPage($event.index, $event.done)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FAB mode="add" :router-link="{name: 'addAircraft'}"/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
|||||||
$q, store: useCopilotsStore(), object: copilot.value, variables
|
$q, store: useCopilotsStore(), object: copilot.value, variables
|
||||||
});
|
});
|
||||||
|
|
||||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||||
filters.value = {copilotId: copilotId};
|
filters.value = {copilotId: copilotId};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPage(false);
|
loadFirstPage();
|
||||||
});
|
});
|
||||||
|
|
||||||
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ onMounted(() => {
|
|||||||
{label: event.value.name}
|
{label: event.value.name}
|
||||||
])
|
])
|
||||||
});
|
});
|
||||||
loadPage(false);
|
loadFirstPage();
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
|||||||
|
|
||||||
const {isQueryRunning, event} = storeToRefs(useEventsStore());
|
const {isQueryRunning, event} = storeToRefs(useEventsStore());
|
||||||
|
|
||||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||||
filters.value = {eventId: eventId}
|
filters.value = {eventId: eventId}
|
||||||
|
|
||||||
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
||||||
|
|||||||
@@ -7,52 +7,19 @@ export default {
|
|||||||
return useFlightsStore().fetchDetail(flightId);
|
return useFlightsStore().fetchDetail(flightId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
|
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
|
||||||
import {onMounted, ref, toRefs, watch} from "vue";
|
|
||||||
import {useRoute} from "vue-router";
|
import {useRoute} from "vue-router";
|
||||||
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
|
|
||||||
import {useAircraftsStore} from "stores/Aircrafts";
|
|
||||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
|
||||||
import {useAirportsStore} from "stores/Airports";
|
|
||||||
import {useQuasar} from "quasar";
|
import {useQuasar} from "quasar";
|
||||||
import {storeToRefs} from "pinia";
|
import {storeToRefs} from "pinia";
|
||||||
import FlightFormCopilot from "components/flights/form/FlightFormCopilot.vue";
|
import {successObject} from "src/composables/dialog";
|
||||||
import {useCopilotsStore} from "stores/Copilots";
|
|
||||||
import FlightFormNotes from "components/flights/form/FlightFormNotes.vue";
|
|
||||||
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
|
|
||||||
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
|
|
||||||
import {errorObject, successObject, warningObject} from "src/composables/dialog";
|
|
||||||
import PhotoFormDialog from "components/photos/PhotoFormDialog.vue";
|
|
||||||
import FlightFormTrack from "components/flights/form/FlightFormTrack.vue";
|
|
||||||
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
|
|
||||||
import BatchPhotoFormDialog from "components/photos/BatchPhotoFormDialog.vue";
|
|
||||||
import {useEventsStore} from "stores/Events";
|
|
||||||
import FlightEditCopilotSkeleton from "components/flights/skeletons/FlightEditCopilotSkeleton.vue";
|
|
||||||
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
|
|
||||||
import {usePhotosStore} from "stores/Photos";
|
|
||||||
import {flightName} from "src/composables/flight";
|
import {flightName} from "src/composables/flight";
|
||||||
import {useAppStore} from "stores/App";
|
import {useAppStore} from "stores/App";
|
||||||
|
|
||||||
const $q = useQuasar();
|
|
||||||
const $route = useRoute();
|
|
||||||
const flightId = parseInt($route.params.id);
|
|
||||||
|
|
||||||
const {aircrafts} = storeToRefs(useAircraftsStore());
|
|
||||||
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
|
||||||
const {airports} = storeToRefs(useAirportsStore());
|
|
||||||
const {copilots} = storeToRefs(useCopilotsStore());
|
|
||||||
const {events} = storeToRefs(useEventsStore());
|
|
||||||
const {photos} = storeToRefs(usePhotosStore());
|
|
||||||
const {flight, isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
|
const {flight, isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
|
||||||
|
|
||||||
|
|
||||||
const formStep = ref("basic");
|
|
||||||
watch(() => $route.params.step, (val) => formStep.value = val);
|
|
||||||
|
|
||||||
const flightNameFormatted = flightName(flight.value);
|
const flightNameFormatted = flightName(flight.value);
|
||||||
useAppStore().setPageTitle(`Editace - ${flightNameFormatted}`);
|
useAppStore().setPageTitle(`Editace - ${flightNameFormatted}`);
|
||||||
useAppStore().setBreadcrumbs([
|
useAppStore().setBreadcrumbs([
|
||||||
@@ -61,177 +28,18 @@ useAppStore().setBreadcrumbs([
|
|||||||
{label: "Editace"}
|
{label: "Editace"}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
onMounted(() => {
|
const $route = useRoute();
|
||||||
photos.value = flight.value.photos;
|
|
||||||
|
|
||||||
formStep.value = $route.params.step || "basic";
|
|
||||||
useAircraftsStore().fetch({});
|
|
||||||
usePointsOfInterestStore().fetch({});
|
|
||||||
useAirportsStore().fetch();
|
|
||||||
useCopilotsStore().fetch();
|
|
||||||
useEventsStore().fetch({});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFlightEdit = (data) => {
|
const handleFlightEdit = (data) => {
|
||||||
|
const flightId = parseInt($route.params.id);
|
||||||
useFlightsStore().update(flightId, data).then((data) => {
|
useFlightsStore().update(flightId, data).then((data) => {
|
||||||
$q.notify(successObject("Úspěšně upraveno"));
|
$q.notify(successObject("Úspěšně upraveno"));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const {
|
|
||||||
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
|
|
||||||
} = useBasicPhotoOperations({});
|
|
||||||
|
|
||||||
const setTitlePhoto = (photo) => {
|
|
||||||
useFlightsStore().update(flight.value.id, {titlePhotoId: photo.id}).then(f => {
|
|
||||||
$q.notify(successObject("Úvodní fotografie úspěšně nastavena"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePhotoUpdate = (flightId, formData) => {
|
|
||||||
const photoId = formData.id;
|
|
||||||
delete formData.id;
|
|
||||||
|
|
||||||
return updateSinglePhoto(photoId, formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = ref(false);
|
|
||||||
const editPhoto = ref(null);
|
|
||||||
|
|
||||||
const openPhotoEdit = (photo) => {
|
|
||||||
openEdit.value = true;
|
|
||||||
editPhoto.value = photo;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// batch edit
|
|
||||||
const openBatchEdit = ref(false);
|
|
||||||
const editBatchPhotos = ref([]);
|
|
||||||
const openBatchPhotoForm = (photos) => {
|
|
||||||
openBatchEdit.value = true;
|
|
||||||
editBatchPhotos.value = photos;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBatchPhotoEdit = async (flightId, data) => {
|
|
||||||
const photoIds = data.photoIds;
|
|
||||||
delete data.photoIds;
|
|
||||||
delete data.id;
|
|
||||||
|
|
||||||
if (data.pointOfInterest && !data.pointOfInterest.id) {
|
|
||||||
const poiData = {
|
|
||||||
...data.pointOfInterest,
|
|
||||||
isPublic: false,
|
|
||||||
description: "",
|
|
||||||
}
|
|
||||||
await usePointsOfInterestStore().create(poiData).then(poi => data.pointOfInterest = {id: poi.id, name: poi.name});
|
|
||||||
}
|
|
||||||
|
|
||||||
const promises = [];
|
|
||||||
for (const id of photoIds) {
|
|
||||||
const updateData = {
|
|
||||||
isAircraft: data.isAircraft,
|
|
||||||
copilots: data.copilots.map(c => ({id: c.id, name: c.name}))
|
|
||||||
};
|
|
||||||
if (data.name !== '') updateData.name = data.name;
|
|
||||||
if (data.description !== '') updateData.description = data.description;
|
|
||||||
if (data.pointOfInterest) updateData.pointOfInterest = data.pointOfInterest;
|
|
||||||
|
|
||||||
promises.push(updateSinglePhoto(id, data));
|
|
||||||
}
|
|
||||||
|
|
||||||
let success = 0;
|
|
||||||
Promise.allSettled(promises).then(result => {
|
|
||||||
for (const item of result) {
|
|
||||||
if (item.status === 'fulfilled') success++;
|
|
||||||
}
|
|
||||||
const error = result.length - success;
|
|
||||||
|
|
||||||
if (success > 0) $q.notify(successObject("Upraveno " + success + " fotografií"));
|
|
||||||
if (error > 0) $q.notify(errorObject(`Nepovedlo se uložit ${error} fotografií`))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FlightFormSteps :router-edit-mode="true" :flightId="flightId"/>
|
<FlightFormSteps :router-edit-mode="true" :flightId="flight.id"/>
|
||||||
|
|
||||||
<q-tab-panels v-model="formStep">
|
<router-view :flight="flight" @save="handleFlightEdit"/>
|
||||||
<q-tab-panel name="basic">
|
|
||||||
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
|
|
||||||
<FlightFormBasic
|
|
||||||
v-else
|
|
||||||
@save="handleFlightEdit"
|
|
||||||
:flight="flight"
|
|
||||||
:locations="pointsOfInterest"
|
|
||||||
:airports="airports"
|
|
||||||
:aircrafts="aircrafts"
|
|
||||||
/>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
<q-tab-panel name="copilot">
|
|
||||||
<FlightEditCopilotSkeleton v-if="isQueryRunning || !flight"/>
|
|
||||||
<FlightFormCopilot
|
|
||||||
v-else
|
|
||||||
@save="handleFlightEdit"
|
|
||||||
:flight="flight"
|
|
||||||
:copilots="copilots"/>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
<q-tab-panel name="track">
|
|
||||||
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
|
|
||||||
<FlightFormTrack
|
|
||||||
v-if="flight && pointsOfInterest"
|
|
||||||
:flight="flight"
|
|
||||||
:locations="pointsOfInterest"
|
|
||||||
:airports="airports"
|
|
||||||
@save="handleFlightEdit"
|
|
||||||
/>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
<q-tab-panel name="photos">
|
|
||||||
<FlightFormPhotos
|
|
||||||
:flight="flight"
|
|
||||||
:is-request-pending="uploadInProgress"
|
|
||||||
@save="handlePhotosUpload"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="flight">
|
|
||||||
<PhotosEditorList
|
|
||||||
:title-photo="flight.titlePhoto"
|
|
||||||
:photos="photos"
|
|
||||||
:show-actions="true"
|
|
||||||
@edit="openPhotoEdit"
|
|
||||||
@batch-edit="openBatchPhotoForm"
|
|
||||||
@batch-rotate="batchRotatePhotos"
|
|
||||||
@delete="deletePhoto"
|
|
||||||
@set-title-photo="setTitlePhoto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Dialogs for photo edit -->
|
|
||||||
<PhotoFormDialog
|
|
||||||
v-model="openEdit"
|
|
||||||
:copilots="flight.copilots"
|
|
||||||
:aircrafts="aircrafts"
|
|
||||||
:locations="pointsOfInterest"
|
|
||||||
:photo="editPhoto"
|
|
||||||
@save="handlePhotoUpdate(flightId, $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BatchPhotoFormDialog
|
|
||||||
v-model="openBatchEdit"
|
|
||||||
:locations="pointsOfInterest"
|
|
||||||
:photos="editBatchPhotos"
|
|
||||||
:copilots="flight.copilots"
|
|
||||||
:aircrafts="aircrafts"
|
|
||||||
@save="handleBatchPhotoEdit(flightId, $event)"
|
|
||||||
@delete="deletePhoto"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
<q-tab-panel name="notes">
|
|
||||||
<FlightFormNotes @save="handleFlightEdit" :events="events" :flight="flight"/>
|
|
||||||
</q-tab-panel>
|
|
||||||
</q-tab-panels>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default {
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {useRoute, useRouter} from "vue-router";
|
import {useRoute, useRouter} from "vue-router";
|
||||||
import {onMounted, watch} from "vue";
|
import {onMounted} from "vue";
|
||||||
import FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue";
|
import FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue";
|
||||||
import {flightName} from "src/composables/flight";
|
import {flightName} from "src/composables/flight";
|
||||||
import {useAppStore} from "stores/App";
|
import {useAppStore} from "stores/App";
|
||||||
@@ -30,7 +30,7 @@ import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
|
|||||||
import {TOGGLE_FLIGHT_PUBLISH} from "src/graphql/mutations/flight";
|
import {TOGGLE_FLIGHT_PUBLISH} from "src/graphql/mutations/flight";
|
||||||
import {useAirspacesStore} from "stores/Airspaces";
|
import {useAirspacesStore} from "stores/Airspaces";
|
||||||
import {useAirportsStore} from "stores/Airports";
|
import {useAirportsStore} from "stores/Airports";
|
||||||
import MapWrapper from "components/map/MapWrapper.vue";
|
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||||
|
|
||||||
const flightId = parseInt(useRoute().params.id);
|
const flightId = parseInt(useRoute().params.id);
|
||||||
const loggedUser = storeToRefs(useLoggedUserStore()).user;
|
const loggedUser = storeToRefs(useLoggedUserStore()).user;
|
||||||
@@ -50,16 +50,22 @@ const handleDeleteAndRedirect = () => {
|
|||||||
.onOk(() => {
|
.onOk(() => {
|
||||||
useFlightsStore().destroy(flightId).then(() => {
|
useFlightsStore().destroy(flightId).then(() => {
|
||||||
$router.push({name: "myFlights"});
|
$router.push({name: "myFlights"});
|
||||||
$q.notify(successObject(`Úspěšně smazáno`));
|
$q.notify(successObject('Úspěšně smazáno'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const {airspaces} = storeToRefs(useAirspacesStore());
|
const {airspaces} = storeToRefs(useAirspacesStore());
|
||||||
const {airports} = storeToRefs(useAirportsStore());
|
const {airports} = storeToRefs(useAirportsStore());
|
||||||
|
const {flightPlan} = storeToRefs(useFlightPlansStore());
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
useAirspacesStore().fetch();
|
useAirspacesStore().fetch();
|
||||||
useAirportsStore().fetch();
|
useAirportsStore().fetch();
|
||||||
|
|
||||||
|
if (flight.value.flightPlanId) {
|
||||||
|
useFlightPlansStore().fetchDetail(flight.value.flightPlanId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const togglePublicVisibility = (variables) => toggleVisibility({
|
const togglePublicVisibility = (variables) => toggleVisibility({
|
||||||
@@ -95,7 +101,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
|||||||
<template #content>
|
<template #content>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<ContentCardEditButton
|
<ContentCardEditButton
|
||||||
:to="{name: 'flightEdit', params: {id: flightId, step: 'basic'}}"
|
:to="{name: 'editFlightBasic', params: {id: flightId}}"
|
||||||
label="Upravit informace"
|
label="Upravit informace"
|
||||||
/>
|
/>
|
||||||
<FlightBasicInfo
|
<FlightBasicInfo
|
||||||
@@ -108,7 +114,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ContentCardEditButton
|
<ContentCardEditButton
|
||||||
:to="{name: 'flightEdit', params: {id: flightId, step: 'notes'}}"
|
:to="{name: 'editFlightNotes', params: {id: flightId}}"
|
||||||
label="Upravit text"
|
label="Upravit text"
|
||||||
/>
|
/>
|
||||||
<article class="q-my-lg" v-html="flight.description"/>
|
<article class="q-my-lg" v-html="flight.description"/>
|
||||||
@@ -123,12 +129,12 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
|||||||
<q-separator/>
|
<q-separator/>
|
||||||
|
|
||||||
<ContentCardEditButton
|
<ContentCardEditButton
|
||||||
:to="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
|
:to="{name: 'editFlightPhotos', params: {id: flightId}}"
|
||||||
label="Upravit fotografie"
|
label="Upravit fotografie"
|
||||||
/>
|
/>
|
||||||
<BasicInfoBanner
|
<BasicInfoBanner
|
||||||
v-if="flight.photos.length === 0"
|
v-if="flight.photos.length === 0"
|
||||||
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
|
:route-add="{name: 'editFlightPhotos', params: {id: flightId,}}"
|
||||||
>
|
>
|
||||||
U tohoto letu nemáš nahrané žádné fotografie.
|
U tohoto letu nemáš nahrané žádné fotografie.
|
||||||
</BasicInfoBanner>
|
</BasicInfoBanner>
|
||||||
@@ -139,12 +145,12 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
|||||||
<q-separator/>
|
<q-separator/>
|
||||||
|
|
||||||
<ContentCardEditButton
|
<ContentCardEditButton
|
||||||
:to="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
|
:to="{name: 'editFlightTrack', params: {id: flightId}}"
|
||||||
label="Upravit trasu"
|
label="Upravit trasu"
|
||||||
/>
|
/>
|
||||||
<BasicInfoBanner
|
<BasicInfoBanner
|
||||||
v-if="flight.track.length === 0 && !flight.gpxTrack"
|
v-if="flight.turnPoints.length === 0 && !flight.track"
|
||||||
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
|
:route-add="{name: 'editFlightTrack', params: {id: flightId}}"
|
||||||
>
|
>
|
||||||
Nemáš nastavenou žádnou trasu.
|
Nemáš nastavenou žádnou trasu.
|
||||||
</BasicInfoBanner>
|
</BasicInfoBanner>
|
||||||
@@ -156,6 +162,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
|||||||
:show-info-window="true"
|
:show-info-window="true"
|
||||||
:show-layer-controls="true"
|
:show-layer-controls="true"
|
||||||
:flight="flight"
|
:flight="flight"
|
||||||
|
:flight-plan="flightPlan"
|
||||||
:photos="flight.photos"
|
:photos="flight.photos"
|
||||||
/>
|
/>
|
||||||
</q-no-ssr>
|
</q-no-ssr>
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import FlightsList from "components/flights/FlightsList.vue";
|
import FlightsList from "components/flights/FlightsList.vue";
|
||||||
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
|
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
|
||||||
import FAB from "components/FAB.vue";
|
|
||||||
import {useAppStore} from "stores/App";
|
import {useAppStore} from "stores/App";
|
||||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||||
import {useFlightsStore} from "stores/Flights";
|
import {useFlightsStore} from "stores/Flights";
|
||||||
import {storeToRefs} from "pinia";
|
import {storeToRefs} from "pinia";
|
||||||
import {usePagination} from "src/composables/pagination";
|
import {usePagination} from "src/composables/pagination";
|
||||||
import {onMounted} from "vue";
|
import PrimaryAction from "components/PrimaryAction.vue";
|
||||||
|
import ListFilters from "components/ListFilters.vue";
|
||||||
|
|
||||||
useAppStore().setPageTitle("Moje lety");
|
useAppStore().setPageTitle("Moje lety");
|
||||||
useAppStore().setBreadcrumbs([]);
|
useAppStore().setBreadcrumbs([]);
|
||||||
|
|
||||||
const {loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||||
onMounted(() => {
|
const {flights, isQueryRunning} = storeToRefs(useFlightsStore());
|
||||||
loadPage(false);
|
|
||||||
});
|
|
||||||
const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<ListFilters @filters-changed="handleFiltersChanged">
|
||||||
|
<template #append>
|
||||||
|
<PrimaryAction mode="add" :router-link="{name: 'addFlight'}"/>
|
||||||
|
</template>
|
||||||
|
</ListFilters>
|
||||||
|
|
||||||
<FlightsListSkeleton v-if="isQueryRunning && flights.length === 0"/>
|
<FlightsListSkeleton v-if="isQueryRunning && flights.length === 0"/>
|
||||||
|
|
||||||
<BasicInfoBanner
|
<BasicInfoBanner
|
||||||
v-else-if="!isQueryRunning && totalItemsCount === 0"
|
v-else-if="!isQueryRunning && flights.length === 0"
|
||||||
:route-add="{name: 'addFlight'}"
|
:route-add="{name: 'addFlight'}"
|
||||||
label-add="Přidat let"
|
label-add="Přidat let"
|
||||||
>
|
>
|
||||||
@@ -34,11 +37,7 @@ const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore()
|
|||||||
v-else
|
v-else
|
||||||
:items="flights"
|
:items="flights"
|
||||||
:public="false"
|
:public="false"
|
||||||
:is-query-running="isQueryRunning"
|
:has-more-data="hasMoreData"
|
||||||
:items-per-page="paginationLimit"
|
@load-more="loadPage($event.index, $event.done)"
|
||||||
:total-items-count="totalItemsCount"
|
|
||||||
@load-more="loadPage(true)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FAB mode="add" :router-link="{name: 'addFlight'}"/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {useAirportsStore} from "stores/Airports";
|
|||||||
import {useAppStore} from "stores/App";
|
import {useAppStore} from "stores/App";
|
||||||
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
|
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
|
||||||
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
|
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
|
||||||
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
|
import FlightEditBasicInfoSkeleton from "components/flights/skeletons/FlightEditBasicInfoSkeleton.vue";
|
||||||
|
|
||||||
useAppStore().setPageTitle("Nový let");
|
useAppStore().setPageTitle("Nový let");
|
||||||
useAppStore().setBreadcrumbs([
|
useAppStore().setBreadcrumbs([
|
||||||
@@ -32,10 +32,10 @@ const loading = computed(() => [
|
|||||||
useAircraftsStore().isQueryRunning, usePointsOfInterestStore().isQueryRunning, useAirportsStore().isQueryRunning
|
useAircraftsStore().isQueryRunning, usePointsOfInterestStore().isQueryRunning, useAirportsStore().isQueryRunning
|
||||||
].some(i => i.value))
|
].some(i => i.value))
|
||||||
|
|
||||||
const router = useRouter();
|
const $router = useRouter();
|
||||||
const handleSave = (formData) => {
|
const handleSave = (formData) => {
|
||||||
useFlightsStore().create(formData).then((flight) => {
|
useFlightsStore().create(formData).then((flight) => {
|
||||||
router.push({name: "flightEdit", params: {id: flight.id, step: "copilot"}});
|
$router.push({name: "editFlightPhotos", params: {id: flight.id}});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -43,14 +43,13 @@ const handleSave = (formData) => {
|
|||||||
<template>
|
<template>
|
||||||
<FlightFormSteps :disabled="['copilot', 'track', 'photos', 'notes']"/>
|
<FlightFormSteps :disabled="['copilot', 'track', 'photos', 'notes']"/>
|
||||||
|
|
||||||
<FlightEditSkeleton step="basic" v-if="loading"/>
|
<FlightEditBasicInfoSkeleton v-if="loading"/>
|
||||||
<template v-else>
|
<FlightFormBasic
|
||||||
<FlightFormBasic
|
v-else
|
||||||
@save="handleSave"
|
:is-request-pending="isMutationRunning"
|
||||||
:is-request-pending="isMutationRunning"
|
:aircrafts="aircrafts"
|
||||||
:aircrafts="aircrafts"
|
:airports="airports"
|
||||||
:airports="airports"
|
:locations="pointsOfInterest"
|
||||||
:locations="pointsOfInterest"
|
@save="handleSave"
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup>
|
||||||
|
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
|
||||||
|
import {onMounted} from "vue";
|
||||||
|
import {useAircraftsStore} from "stores/Aircrafts";
|
||||||
|
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||||
|
import {useAirportsStore} from "stores/Airports";
|
||||||
|
import {storeToRefs} from "pinia";
|
||||||
|
import {useFlightsStore} from "stores/Flights";
|
||||||
|
import FlightEditBasicInfoSkeleton from "components/flights/skeletons/FlightEditBasicInfoSkeleton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
flight: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onMounted(() => {
|
||||||
|
useAircraftsStore().fetch({});
|
||||||
|
usePointsOfInterestStore().fetch({});
|
||||||
|
useAirportsStore().fetch();
|
||||||
|
});
|
||||||
|
const emits = defineEmits(["save"]);
|
||||||
|
|
||||||
|
const {aircrafts} = storeToRefs(useAircraftsStore());
|
||||||
|
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
||||||
|
const {airports} = storeToRefs(useAirportsStore());
|
||||||
|
const {isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlightEditBasicInfoSkeleton v-if="isQueryRunning || !flight"/>
|
||||||
|
<FlightFormBasic
|
||||||
|
v-else
|
||||||
|
:flight="flight"
|
||||||
|
:locations="pointsOfInterest"
|
||||||
|
:airports="airports"
|
||||||
|
:aircrafts="aircrafts"
|
||||||
|
@save="emits('save', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import FlightFormNotes from "components/flights/form/FlightFormNotes.vue";
|
||||||
|
import {computed, onMounted} from "vue";
|
||||||
|
import {useEventsStore} from "stores/Events";
|
||||||
|
import {storeToRefs} from "pinia";
|
||||||
|
import {useCopilotsStore} from "stores/Copilots";
|
||||||
|
import FlightEditExtraInfoSkeleton from "components/flights/skeletons/FlightEditExtraInfoSkeleton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
flight: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(["save"]);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useEventsStore().fetch({});
|
||||||
|
useCopilotsStore().fetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const {events, isQueryRunning: loadingEvents} = storeToRefs(useEventsStore());
|
||||||
|
const {copilots, isQueryRunning: loadingCopilots} = storeToRefs(useCopilotsStore())
|
||||||
|
|
||||||
|
const isLoading = computed(() => loadingCopilots.value || loadingEvents.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlightEditExtraInfoSkeleton v-if="isLoading" />
|
||||||
|
<FlightFormNotes
|
||||||
|
v-else
|
||||||
|
:events="events"
|
||||||
|
:flight="flight"
|
||||||
|
:copilots="copilots"
|
||||||
|
@save="emits('save', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup>
|
||||||
|
import BatchPhotoFormDialog from "components/photos/BatchPhotoFormDialog.vue";
|
||||||
|
import PhotoFormDialog from "components/photos/PhotoFormDialog.vue";
|
||||||
|
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
|
||||||
|
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
|
||||||
|
import {errorObject, successObject} from "src/composables/dialog";
|
||||||
|
import {computed, onMounted, ref, watch} from "vue";
|
||||||
|
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||||
|
import {useFlightsStore} from "stores/Flights";
|
||||||
|
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
|
||||||
|
import {storeToRefs} from "pinia";
|
||||||
|
import {useAircraftsStore} from "stores/Aircrafts";
|
||||||
|
import {usePhotosStore} from "stores/Photos";
|
||||||
|
import {useQuasar} from "quasar";
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
flight: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(["save"]);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
photos.value = props.flight.photos;
|
||||||
|
})
|
||||||
|
|
||||||
|
const {aircrafts, isQueryRunning: aircraftsLoading} = storeToRefs(useAircraftsStore());
|
||||||
|
const {pointsOfInterest, isQueryRunning: poisLoading} = storeToRefs(usePointsOfInterestStore());
|
||||||
|
const {photos, isQueryRunning: photosLoading} = storeToRefs(usePhotosStore());
|
||||||
|
|
||||||
|
const isLoading = computed(() => aircraftsLoading.value || poisLoading.value || photosLoading.value);
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const setTitlePhoto = (photo) => {
|
||||||
|
useFlightsStore().update(props.flight.id, {titlePhotoId: photo.id}).then(f => {
|
||||||
|
$q.notify(successObject("Úvodní fotografie úspěšně nastavena"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePhotoUpdate = (flightId, formData) => {
|
||||||
|
const photoId = formData.id;
|
||||||
|
delete formData.id;
|
||||||
|
|
||||||
|
return updateSinglePhoto(photoId, formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = ref(false);
|
||||||
|
const editPhoto = ref(null);
|
||||||
|
|
||||||
|
const openPhotoEdit = (photo) => {
|
||||||
|
openEdit.value = true;
|
||||||
|
editPhoto.value = photo;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
|
||||||
|
} = useBasicPhotoOperations({});
|
||||||
|
|
||||||
|
|
||||||
|
// batch edit
|
||||||
|
const openBatchEdit = ref(false);
|
||||||
|
const editBatchPhotos = ref([]);
|
||||||
|
const openBatchPhotoForm = (photos) => {
|
||||||
|
openBatchEdit.value = true;
|
||||||
|
editBatchPhotos.value = photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchPhotoEdit = async (data) => {
|
||||||
|
const photoIds = data.photoIds;
|
||||||
|
delete data.photoIds;
|
||||||
|
delete data.id;
|
||||||
|
|
||||||
|
if (data.pointOfInterest && !data.pointOfInterest.id) {
|
||||||
|
const poiData = {
|
||||||
|
...data.pointOfInterest,
|
||||||
|
isPublic: false,
|
||||||
|
description: "",
|
||||||
|
urlSlug: "",
|
||||||
|
}
|
||||||
|
await usePointsOfInterestStore().create(poiData).then(poi => data.pointOfInterest = {id: poi.id, name: poi.name});
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
for (const id of photoIds) {
|
||||||
|
const updateData = {
|
||||||
|
isAircraft: data.isAircraft,
|
||||||
|
copilots: data.copilots.map(c => ({id: c.id, name: c.name}))
|
||||||
|
};
|
||||||
|
if (data.name !== '') updateData.name = data.name;
|
||||||
|
if (data.description !== '') updateData.description = data.description;
|
||||||
|
if (data.pointOfInterest) updateData.pointOfInterest = data.pointOfInterest;
|
||||||
|
|
||||||
|
promises.push(updateSinglePhoto(id, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
Promise.allSettled(promises).then(result => {
|
||||||
|
for (const item of result) {
|
||||||
|
if (item.status === 'fulfilled') success++;
|
||||||
|
}
|
||||||
|
const error = result.length - success;
|
||||||
|
|
||||||
|
if (success > 0) $q.notify(successObject("Upraveno " + success + " fotografií"));
|
||||||
|
if (error > 0) $q.notify(errorObject(`Nepovedlo se uložit ${error} fotografií`))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [openEdit.value, openBatchEdit.value], (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
// load new data on each dialog open
|
||||||
|
useAircraftsStore().fetch({});
|
||||||
|
usePointsOfInterestStore().fetch({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlightFormPhotos
|
||||||
|
:flight="flight"
|
||||||
|
:is-request-pending="uploadInProgress"
|
||||||
|
@save="handlePhotosUpload"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PhotosEditorList
|
||||||
|
:title-photo="flight.titlePhoto"
|
||||||
|
:photos="flight.photos"
|
||||||
|
:show-actions="true"
|
||||||
|
@edit="openPhotoEdit"
|
||||||
|
@batch-edit="openBatchPhotoForm"
|
||||||
|
@batch-rotate="batchRotatePhotos"
|
||||||
|
@delete="deletePhoto"
|
||||||
|
@set-title-photo="setTitlePhoto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PhotoFormDialog
|
||||||
|
v-model="openEdit"
|
||||||
|
:copilots="flight.copilots"
|
||||||
|
:loading="isLoading"
|
||||||
|
:aircrafts="aircrafts"
|
||||||
|
:locations="pointsOfInterest"
|
||||||
|
:photo="editPhoto"
|
||||||
|
@save="handlePhotoUpdate(flight.id, $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BatchPhotoFormDialog
|
||||||
|
v-model="openBatchEdit"
|
||||||
|
:loading="isLoading"
|
||||||
|
:locations="pointsOfInterest"
|
||||||
|
:photos="editBatchPhotos"
|
||||||
|
:copilots="flight.copilots"
|
||||||
|
:aircrafts="aircrafts"
|
||||||
|
@save="handleBatchPhotoEdit"
|
||||||
|
@delete="deletePhoto"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup>
|
||||||
|
import {onMounted} from "vue";
|
||||||
|
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||||
|
import {storeToRefs} from "pinia";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
flight: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(["save"]);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.flight.flightPlanId) {
|
||||||
|
useFlightPlansStore().fetchDetail(props.flight.flightPlanId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {flightPlan} = storeToRefs(useFlightPlansStore());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="flight.flightPlanId">
|
||||||
|
{{ flightPlan.name }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Bez planu
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup>
|
||||||
|
import FlightFormTrack from "components/flights/form/FlightFormTrack.vue";
|
||||||
|
import {storeToRefs} from "pinia";
|
||||||
|
import {computed, onMounted} from "vue";
|
||||||
|
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||||
|
import {useAirportsStore} from "stores/Airports";
|
||||||
|
import FlightEditTrackSkeleton from "components/flights/skeletons/FlightEditTrackSkeleton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
flight: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(["save"]);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
usePointsOfInterestStore().fetch({});
|
||||||
|
useAirportsStore().fetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const {pointsOfInterest, isQueryRunning: poisLoading} = storeToRefs(usePointsOfInterestStore());
|
||||||
|
const {airports, isQueryRunning: airportsLoading} = storeToRefs(useAirportsStore());
|
||||||
|
const isLoading = computed(() => poisLoading.value || airportsLoading.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlightEditTrackSkeleton v-if="isLoading"/>
|
||||||
|
<FlightFormTrack
|
||||||
|
v-else
|
||||||
|
:flight="flight"
|
||||||
|
:locations="pointsOfInterest"
|
||||||
|
:airports="airports"
|
||||||
|
@save="emits('save', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -9,191 +9,34 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {computed, onMounted, ref} from "vue";
|
import {computed} from "vue";
|
||||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
|
||||||
import {storeToRefs} from "pinia";
|
import {storeToRefs} from "pinia";
|
||||||
import {useAirspacesStore} from "stores/Airspaces";
|
|
||||||
import {useAirportsStore} from "stores/Airports";
|
|
||||||
import {commonProps} from "src/composables/inputs";
|
|
||||||
import {useAppStore} from "stores/App";
|
|
||||||
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
|
|
||||||
import FlightPlanMap from "components/flightPlans/FlightPlanMap.vue";
|
|
||||||
import FlightPlanRoute from "components/flightPlans/FlightPlanRoute.vue";
|
|
||||||
import FlightPlanSideControls from "components/flightPlans/FlightPlanSideControls.vue";
|
|
||||||
import DatetimePicker from "components/inputs/DatetimePicker.vue";
|
|
||||||
import {useAircraftsStore} from "stores/Aircrafts";
|
|
||||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
|
||||||
import FormWrapper from "components/FormWrapper.vue";
|
|
||||||
import {successObject} from "src/composables/dialog";
|
|
||||||
import {useQuasar} from "quasar";
|
|
||||||
import {useCopilotsStore} from "stores/Copilots";
|
|
||||||
|
|
||||||
const {flightPlan} = storeToRefs(useFlightPlansStore());
|
const {flightPlan} = storeToRefs(useFlightPlansStore());
|
||||||
|
|
||||||
const flightPlanName = computed(() => flightPlan.value.name || "Nepojmenovaný plán");
|
const flightPlanName = computed(() => flightPlan.value.name || "Nepojmenovaný plán");
|
||||||
useAppStore().setPageTitle(`${flightPlanName.value} - plánovnání`);
|
|
||||||
useAppStore().setBreadcrumbs([
|
|
||||||
{to: {name: "flightPlans"}, label: "Moje plány"},
|
|
||||||
{label: flightPlanName.value},
|
|
||||||
{label: "Editace"}
|
|
||||||
]);
|
|
||||||
|
|
||||||
onMounted(() => {
|
const displayedTabs = computed(() => {
|
||||||
useAirspacesStore().fetch();
|
const tabs = [
|
||||||
useAircraftsStore().fetch({});
|
{label: "Trasa", route: "editFlightPlan", icon: "route"},
|
||||||
usePointsOfInterestStore().fetch({limit: 10000});
|
{label: "Informace", route: "editFlightPlanInfo", icon: "info"},
|
||||||
useAirportsStore().fetch();
|
{label: "Počasí", route: "editFlightPlanWeather", icon: "mdi-weather-windy"}, // TODO: jen pokud se povedlo nejake pocasi stahnout
|
||||||
|
{label: "Navigační štítek", route: "editFlightPlanNavlog", icon: "navigation"},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (flightPlan.value.aircraft) {
|
||||||
|
tabs.push({label: "Let", route: "flightPlanFlight", icon: "flight"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs;
|
||||||
});
|
});
|
||||||
|
|
||||||
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
|
||||||
const {airspaces: airspacesData, areAirspacesLoading: isQueryRunning} = storeToRefs(useAirspacesStore())
|
|
||||||
const {airports} = storeToRefs(useAirportsStore());
|
|
||||||
const {aircrafts: aircraftsData} = storeToRefs(useAircraftsStore());
|
|
||||||
const aircrafts = computed(() => aircraftsData.value.map(a => ({id: a.id, name: a.callSign})));
|
|
||||||
|
|
||||||
|
|
||||||
const {getMarkersFrom} = useFlightPlanUtils();
|
|
||||||
const {markerObject} = useFlightPlanObjects();
|
|
||||||
|
|
||||||
const formData = ref({
|
|
||||||
name: flightPlan.value?.name || null,
|
|
||||||
isDefaultName: flightPlan.value ? flightPlan.value.isDefaultName : true,
|
|
||||||
isEditableByCopilot: flightPlan.value?.isEditableByCopilot || false,
|
|
||||||
isPublic: flightPlan.value?.isPublic || false,
|
|
||||||
plannedTakeoffDatetime: flightPlan.value?.plannedTakeoffDatetime || null,
|
|
||||||
aircraft: flightPlan.value && flightPlan.value.aircraft ? {id: flightPlan.value.aircraft.id, name: flightPlan.value.aircraft.callSign} : null,
|
|
||||||
plannedSpeed: flightPlan.value?.plannedSpeed || 150,
|
|
||||||
})
|
|
||||||
|
|
||||||
const openedTab = ref("route");
|
|
||||||
const flightPlannerDialogOpen = ref(false);
|
|
||||||
const flightPlanMarkers = ref(getMarkersFrom(flightPlan.value));
|
|
||||||
|
|
||||||
const handleMarkersChanges = (markers) => {
|
|
||||||
flightPlanMarkers.value = markers;
|
|
||||||
|
|
||||||
useFlightPlansStore().update(flightPlan.value.id, {
|
|
||||||
name: formData.value.name,
|
|
||||||
markers: markers.map(m => markerObject({
|
|
||||||
name: m.name,
|
|
||||||
type: m.type,
|
|
||||||
gpsLatitude: m.gpsLatitude,
|
|
||||||
gpsLongitude: m.gpsLongitude,
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const $q = useQuasar();
|
|
||||||
const handleFlightPlanInfoSave = () => {
|
|
||||||
useFlightPlansStore().update(flightPlan.value.id, formData.value).then(() => {
|
|
||||||
$q.notify(successObject("Ulozeno"));
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const insertAtPosition = ref(flightPlanMarkers.value.length);
|
|
||||||
const fullscreen = ref(false);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<q-tabs v-model="openedTab">
|
<q-tabs>
|
||||||
<q-tab name="route">
|
<q-route-tab v-for="tab in displayedTabs" :key="tab.route" replace exact :to="{name: tab.route, params: {id: flightPlan.id}}">
|
||||||
<q-icon name="route"/>
|
<q-icon :name="tab.icon"/>
|
||||||
Trasa
|
{{ tab.label }}
|
||||||
</q-tab>
|
</q-route-tab>
|
||||||
|
|
||||||
<q-tab name="info">
|
|
||||||
<q-icon name="info"/>
|
|
||||||
Informace
|
|
||||||
</q-tab>
|
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
|
<router-view :flight-plan-name="flightPlanName" :flight-plan="flightPlan"/>
|
||||||
<q-tab-panels v-model="openedTab">
|
|
||||||
<q-tab-panel name="route">
|
|
||||||
<div :class="{row: true, fullscreen}">
|
|
||||||
<div class="col" style="min-width: 1px">
|
|
||||||
<FlightPlanMap
|
|
||||||
:insert-position="insertAtPosition"
|
|
||||||
:airspace-data="airspacesData"
|
|
||||||
:airports="airports"
|
|
||||||
:points-of-interest="pointsOfInterest"
|
|
||||||
:flight-plan="flightPlan"
|
|
||||||
@change-insert-position="insertAtPosition = $event"
|
|
||||||
@markers-changed="handleMarkersChanges"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-3 col-lg-2 markers-control">
|
|
||||||
<FlightPlanSideControls
|
|
||||||
:is-fullscreen="fullscreen"
|
|
||||||
:insert-position="insertAtPosition"
|
|
||||||
:flight-plan="flightPlan"
|
|
||||||
@change-insert-position="insertAtPosition = $event"
|
|
||||||
@open="flightPlannerDialogOpen=true"
|
|
||||||
@fullscreen-toggle="fullscreen = !fullscreen"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
<q-tab-panel name="info">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
|
|
||||||
<FormWrapper v-model="formData" @save="handleFlightPlanInfoSave">
|
|
||||||
<q-input v-model="formData.name" label="Jméno letu" v-if="!formData.isDefaultName" v-bind="commonProps"/>
|
|
||||||
|
|
||||||
<q-checkbox v-model="formData.isDefaultName" label="Použít automatické jméno"/>
|
|
||||||
|
|
||||||
<!-- <q-checkbox v-model="formData.isPublic" label="Zveřejnit"/>-->
|
|
||||||
<!-- <q-checkbox v-model="formData.isEditableByCopilot" label="Kopilot může upravovat"/>-->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- <q-btn @click="handleFlightPlanInfoSave" icon="save">Ulozit</q-btn>-->
|
|
||||||
</FormWrapper>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-tab-panel>
|
|
||||||
</q-tab-panels>
|
|
||||||
|
|
||||||
|
|
||||||
<q-dialog full-width v-model="flightPlannerDialogOpen">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h6">Přidat otočný bod</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-card-section class="q-pt-none">
|
|
||||||
<FlightPlanRoute
|
|
||||||
:markers="flightPlanMarkers"
|
|
||||||
:points-of-interest="pointsOfInterest"
|
|
||||||
:airports="airports"
|
|
||||||
@markers-changed="handleMarkersChanges"
|
|
||||||
/>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-card-actions align="right" class="text-primary">
|
|
||||||
<q-btn flat label="Zavřít" v-close-popup/>
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.fullscreen {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 99999;
|
|
||||||
|
|
||||||
.markers-control {
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup>
|
||||||
|
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||||
|
import FlightFromPlanForm from "components/flightPlans/FlightFromPlanForm.vue";
|
||||||
|
import {computed} from "vue";
|
||||||
|
import {useRouter} from "vue-router";
|
||||||
|
import {errorObject, successObject} from "src/composables/dialog";
|
||||||
|
import {useFlightsStore} from "stores/Flights";
|
||||||
|
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
||||||
|
import {useQuasar} from "quasar";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
flightPlan: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const {getMarkersFrom} = useFlightPlanUtils();
|
||||||
|
|
||||||
|
const takeoffAirport = computed(() => {
|
||||||
|
const markers = getMarkersFrom(props.flightPlan)
|
||||||
|
return markers[0].type === 'airport' ? markers[0] : null;
|
||||||
|
})
|
||||||
|
|
||||||
|
const landingAirport = computed(() => {
|
||||||
|
const markers = getMarkersFrom(props.flightPlan);
|
||||||
|
const lastMarker = markers[markers.length - 1];
|
||||||
|
|
||||||
|
return lastMarker.type === 'airport' ? lastMarker : null;
|
||||||
|
})
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const $router = useRouter();
|
||||||
|
const createNewFlightFromPlan = (formData) => {
|
||||||
|
const markers = getMarkersFrom(formData.flightPlan);
|
||||||
|
const takeoff = markers.shift();
|
||||||
|
const landing = markers.pop();
|
||||||
|
|
||||||
|
if ((takeoff.type !== 'airport' || landing.type !== 'airport') && !formData.gpxTrackFile) {
|
||||||
|
$q.notify(errorObject("Zkontroluj letiště vzletu a přistání. Aktuálně zvolené hodnoty se jako letiště nezdají. Pro pokračování uprav buď plán, nebo zkus nahrát trasu v GPX."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = [];
|
||||||
|
if (formData.copyTrack) {
|
||||||
|
|
||||||
|
// TODO: po ulozeni mapPoint/geocode??? upravit i trasu v planu - predelat ten mapPoint na POI s IDckem
|
||||||
|
|
||||||
|
for (const i in markers) {
|
||||||
|
const marker = markers[i];
|
||||||
|
if (i === 0 || i === markers.length - 1 || marker.type === 'mapPoint') continue;
|
||||||
|
|
||||||
|
const obj = {
|
||||||
|
[marker.type === 'airport' ? 'airport' : 'pointOfInterest']: {
|
||||||
|
id: marker.airportId || marker.pointOfInterestId || null,
|
||||||
|
name: marker.name,
|
||||||
|
},
|
||||||
|
gpsLatitude: marker.gpsLatitude,
|
||||||
|
gpsLongitude: marker.gpsLongitude
|
||||||
|
};
|
||||||
|
track.push(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useFlightsStore().create({
|
||||||
|
name: formData.flightPlan.name,
|
||||||
|
aircraft: {id: formData.flightPlan.aircraft.id, name: formData.flightPlan.aircraft.callSign},
|
||||||
|
flightPlanId: formData.flightPlan.id,
|
||||||
|
takeoffAirport: takeoff ? {id: takeoff.airportId, name: takeoff.name} : null,
|
||||||
|
landingAirport: landing ? {id: landing.airportId, name: landing.name} : null,
|
||||||
|
gpxTrackFile: formData.gpxTrackFile,
|
||||||
|
takeoffDatetime: formData.takeoffDatetime,
|
||||||
|
landingDatetime: formData.landingDatetime,
|
||||||
|
track
|
||||||
|
}).then(res => {
|
||||||
|
$q.notify(successObject("Let úspěšně vytvořen. Nyní můžeš nastavit detaily."))
|
||||||
|
$router.push({name: 'flightEdit', params: {id: res.id, step: 'copilot'}});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BasicInfoBanner v-if="!takeoffAirport || !landingAirport" color="error">
|
||||||
|
V plánu nemáš žádné letiště vzletu nebo přistání. Pro import letu musíš nahrát trasu.
|
||||||
|
</BasicInfoBanner>
|
||||||
|
<FlightFromPlanForm
|
||||||
|
v-bind="$attrs"
|
||||||
|
:flight-plan="flightPlan"
|
||||||
|
@save="createNewFlightFromPlan"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup>
|
||||||
|
import FlightPlanForm from "components/flightPlans/FlightPlanForm.vue";
|
||||||
|
import {useQuasar} from "quasar";
|
||||||
|
import {successObject} from "src/composables/dialog";
|
||||||
|
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||||
|
import {storeToRefs} from "pinia";
|
||||||
|
import {useAircraftsStore} from "stores/Aircrafts";
|
||||||
|
import {onMounted} from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
flightPlan: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
useAircraftsStore().fetch({});
|
||||||
|
});
|
||||||
|
|
||||||
|
const {aircrafts} = storeToRefs(useAircraftsStore());
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const handleFlightPlanInfoSave = (formData) => {
|
||||||
|
useFlightPlansStore().update(props.flightPlan.id, formData).then(() => {
|
||||||
|
$q.notify(successObject("Informace úspěšně uloženy"));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlightPlanForm
|
||||||
|
:aircrafts="aircrafts"
|
||||||
|
:flight-plan="flightPlan"
|
||||||
|
@save="handleFlightPlanInfoSave"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user