Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9daea5974b | ||
|
|
a56c6828b1 | ||
|
|
670e80d998 | ||
|
|
be8966b5c1 | ||
|
|
8dd0a16918 | ||
|
|
0760e6fb1c | ||
|
|
bc0b5e2f15 | ||
|
|
de0b75163d | ||
|
|
cee979fec8 | ||
|
|
338ec13db2 | ||
|
|
9af80cda06 | ||
|
|
02b839674e | ||
|
|
275c48986e | ||
|
|
691bd4c7c5 | ||
|
|
c0cfd7115c | ||
|
|
9379d3bfd6 | ||
|
|
48f57cde0d | ||
|
|
5cff3580b0 | ||
|
|
883b4fa97f | ||
|
|
722e832f1d | ||
|
|
415ff15ab9 | ||
|
|
cd9c7351e9 | ||
|
|
f0a4c439dc | ||
|
|
7a45a0133e | ||
|
|
92678ec829 | ||
|
|
1a5c8f09bf | ||
|
|
9c9bbc9baa | ||
|
|
fe47c1f7d6 | ||
|
|
b22773b2ae | ||
|
|
d69ceac14b | ||
|
|
418469d81c | ||
|
|
c28feade90 | ||
|
|
5b94f2c4b3 | ||
|
|
08a3cbf3d9 | ||
|
|
b0aa6b5628 | ||
|
|
0c89884b23 | ||
|
|
5a5c7cb645 | ||
|
|
e7cde2f425 | ||
|
|
966d582b63 | ||
|
|
9aebc352cd | ||
|
|
c8ea17c1ec | ||
|
|
8c7e237053 | ||
|
|
3216de4cb7 | ||
|
|
9b38abd0de | ||
|
|
71389caecc | ||
|
|
68659ca924 | ||
|
|
3bd4d7ba52 | ||
|
|
11cc6ad107 | ||
|
|
86605ad53e | ||
|
|
9a14fdfcfb | ||
|
|
00d4de08e4 | ||
|
|
9181118e69 | ||
|
|
4aeef2e8a6 | ||
|
|
af74801693 | ||
|
|
f9d70a5d57 | ||
|
|
331ebbc4e4 | ||
|
|
e65491e00b | ||
|
|
8beb7bb9d6 | ||
|
|
284f8d2c53 | ||
|
|
6b1e220d42 | ||
|
|
40f21d159e |
@@ -28,3 +28,5 @@ yarn-error.log*
|
||||
*.njsproj
|
||||
*.sln
|
||||
.vscode
|
||||
|
||||
quasar.config.js.temporary.*
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
# develop stage
|
||||
FROM node:20-alpine as develop-stage
|
||||
FROM node:22-alpine AS develop-stage
|
||||
|
||||
ENV TZ=Europe/Prague
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN yarn global add @quasar/cli && yarn --production=false
|
||||
RUN yarn global add @quasar/cli
|
||||
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn --production=false
|
||||
RUN npx update-browserslist-db@latest
|
||||
COPY . .
|
||||
|
||||
# build stage
|
||||
FROM develop-stage as build-stage
|
||||
FROM develop-stage AS build-stage
|
||||
ENV NODE_ENV production
|
||||
ENV API_URL "https://api.poletani.cz"
|
||||
ENV GOOGLE_MAPS_API_KEY "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
|
||||
|
||||
#ARG API_URL
|
||||
#ENV VUE_API_URL $API_URL
|
||||
RUN quasar build -m ssr
|
||||
RUN cp /app/dist/ssr/server/server-entry.mjs /app/dist/ssr/server/server-entry.js
|
||||
|
||||
RUN yarn --production=false
|
||||
RUN quasar build
|
||||
FROM node:22-alpine AS production-stage
|
||||
|
||||
# production stage
|
||||
FROM nginx:1.25-alpine as production-stage
|
||||
COPY conf/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build-stage /app/dist/spa /app/html
|
||||
EXPOSE 80
|
||||
ENV NODE_ENV production
|
||||
ENV TZ=Europe/Prague
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
EXPOSE 3000
|
||||
WORKDIR /app
|
||||
|
||||
COPY process.yml /app
|
||||
COPY --from=build-stage /app/dist/ssr/package.json /app
|
||||
RUN yarn global add pm2 && yarn && yarn cache clean
|
||||
|
||||
COPY --from=build-stage /app/dist/ssr /app
|
||||
|
||||
CMD ["pm2-runtime", "/app/process.yml"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
version: "3.3"
|
||||
version: "3.4"
|
||||
services:
|
||||
poletani-app:
|
||||
image: "docker.kvacek.cz/poletani/app:latest"
|
||||
restart: always
|
||||
ports:
|
||||
- 9000:80
|
||||
- 9000:3000
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
|
||||
services:
|
||||
#
|
||||
# db:
|
||||
# image: mariadb:10.11
|
||||
# ports:
|
||||
# - 3306:3306
|
||||
# environment:
|
||||
# - MYSQL_USER=root
|
||||
# - MYSQL_DATABASE=ull_tracker
|
||||
# - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=true
|
||||
# volumes:
|
||||
# - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
# - db-data:/var/lib/mysql:rw
|
||||
# api:
|
||||
# build:
|
||||
# context: ../api
|
||||
# ports:
|
||||
# - 8000:8000
|
||||
# volumes:
|
||||
# - ../api:/app
|
||||
# environment:
|
||||
# TZ: "Europe/Prague"
|
||||
## command: ls -l /app
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: "develop-stage"
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9100"
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./public:/app/public
|
||||
- ./node_modules:/app/node_modules:delegated
|
||||
command: quasar dev
|
||||
- ./:/app
|
||||
command: quasar dev # -m ssr
|
||||
environment:
|
||||
API_URL: "http://localhost:8000"
|
||||
|
||||
# nginx:
|
||||
# image:
|
||||
#
|
||||
#
|
||||
|
||||
API_URL: "http://127.0.0.1:8000"
|
||||
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
|
||||
|
||||
volumes:
|
||||
db-data: { }
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= productName %></title>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="<%= productDescription %>">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="msapplication-tap-highlight" content="no">
|
||||
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
|
||||
@@ -14,8 +11,24 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
||||
<link rel="icon" type="image/ico" href="favicon.ico">
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
<script v-if="process.env.NODE_ENV === 'production'">
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-WC8BTPJ4');
|
||||
</script>
|
||||
<!-- End Google Tag Manager -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript v-if="process.env.NODE_ENV === 'production'">
|
||||
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WC8BTPJ4" height="0" width="0" style="display:none;visibility:hidden"></iframe>
|
||||
</noscript>
|
||||
<!-- End Google Tag Manager (noscript) -->
|
||||
|
||||
<!-- quasar:entry-point -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,36 +10,49 @@
|
||||
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.14",
|
||||
"@quasar/extras": "^1.15.11",
|
||||
"@vue/apollo-composable": "4.0.0-beta.2",
|
||||
"@vue/apollo-option": "^4.0.0-beta.5",
|
||||
"apollo-upload-client": "^17.0.0",
|
||||
"axios": "^1.4.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"graphql": "16.6.0",
|
||||
"@apollo/client": "^4.0.5",
|
||||
"@quasar/extras": "^1.16.16",
|
||||
"@sentry/browser": "^10.14.0",
|
||||
"@sentry/integrations": "^7.114.0",
|
||||
"@sentry/vue": "^10.14.0",
|
||||
"@vue/apollo-composable": "4.2.2",
|
||||
"@vue/apollo-option": "^4.2.0",
|
||||
"apollo-upload-client": "^19.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"chart.js": "^4.4.7",
|
||||
"graphql": "16.11.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"photoswipe": "^5.4.2",
|
||||
"photoswipe": "^5.4.4",
|
||||
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
||||
"pinia": "^2.0.33",
|
||||
"quasar": "^2.12.0",
|
||||
"vue": "^3.2.47",
|
||||
"vue-chartjs": "^5.2.0",
|
||||
"vue-inner-image-zoom": "^2.0.0",
|
||||
"pinia": "^3.0.3",
|
||||
"quasar": "2.18.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"vue": "3.5.21",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-router": "^4.1.6",
|
||||
"vue3-google-map": "^0.15.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-google-map": "^0.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/app-vite": "^1.3.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"postcss": "^8.4.14",
|
||||
"prettier": "^2.5.1",
|
||||
"vite-plugin-rewrite-all": "^1.0.1"
|
||||
"@quasar/app-vite": "2.4.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 10 Chrome versions",
|
||||
"last 10 Firefox versions",
|
||||
"last 4 Edge versions",
|
||||
"last 7 Safari versions",
|
||||
"last 8 Android versions",
|
||||
"last 8 ChromeAndroid versions",
|
||||
"last 8 FirefoxAndroid versions",
|
||||
"last 10 iOS versions",
|
||||
"last 5 Opera versions"
|
||||
],
|
||||
"engines": {
|
||||
"npm": ">= 6.13.4",
|
||||
"yarn": ">= 1.21.1"
|
||||
|
||||
@@ -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')
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
apps:
|
||||
- name: poletani
|
||||
script: /app/index.js
|
||||
exec_mode: cluster
|
||||
instances: 3
|
||||
# Pojistka proti bodu 1 (a podobným leakům): pokud paměť workeru
|
||||
# přeroste limit, PM2 ho sám restartuje dřív, než dojde k OOM na
|
||||
# celém VPS. Hodnotu je potřeba doladit dle reálné spotřeby jednoho
|
||||
# SSR workeru po opravě bodu 1 (pm2 monit / pm2 list) - viz
|
||||
# handoffs/05-pm2-docker-memory-limits.md.
|
||||
max_memory_restart: '400M'
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 947 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 859 B After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 797 B |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 610 B After Width: | Height: | Size: 589 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,3 @@
|
||||
User-agent: *
|
||||
Disallow: /login/
|
||||
Disallow: /moje/
|
||||
@@ -8,8 +8,7 @@
|
||||
// Configuration for your app
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||
|
||||
const {configure} = require('quasar/wrappers');
|
||||
|
||||
import { configure } from 'quasar/wrappers'
|
||||
|
||||
module.exports = configure(function (ctx) {
|
||||
return {
|
||||
@@ -23,15 +22,22 @@ module.exports = configure(function (ctx) {
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli/prefetch-feature
|
||||
// preFetch: true,
|
||||
preFetch: true,
|
||||
|
||||
// app boot file (/poletani/boot)
|
||||
// --> boot files are part of "main.js"
|
||||
// https://v2.quasar.dev/quasar-cli/boot-files
|
||||
boot: [
|
||||
// client-only: Sentry browser SDK (browserTracingIntegration,
|
||||
// replayIntegration) is DOM-only and has no business running per SSR
|
||||
// request - server: false means Quasar excludes this boot file from
|
||||
// the server bundle entirely (not just a runtime guard).
|
||||
// See handoffs/04-sentry-ssr-guard.md.
|
||||
{path: "sentry", server: false},
|
||||
"cookies",
|
||||
"axios",
|
||||
"vueInnerImageZoom",
|
||||
"chartJs",
|
||||
"apollo",
|
||||
"chartJs"
|
||||
],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
|
||||
@@ -55,13 +61,13 @@ module.exports = configure(function (ctx) {
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
||||
build: {
|
||||
target: {
|
||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||
node: 'node18'
|
||||
browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
|
||||
node: 'node20'
|
||||
},
|
||||
|
||||
env: {
|
||||
API_URL: ctx.dev ? 'http://localhost:8000' : 'https://api.poletani.cz',
|
||||
GOOGLE_MAPS_API_KEY: "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
|
||||
API_URL: process.env.API_URL,
|
||||
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY
|
||||
},
|
||||
|
||||
vueRouterMode: 'history', // available values: 'hash', 'history'
|
||||
@@ -76,17 +82,13 @@ module.exports = configure(function (ctx) {
|
||||
// env: {},
|
||||
// rawDefine: {}
|
||||
// ignorePublicFolder: true,
|
||||
// minify: false,
|
||||
minify: true,
|
||||
// polyfillModulePreload: true,
|
||||
// distDir
|
||||
|
||||
// extendViteConf (viteConf) {},
|
||||
// extendViteConf(viteConf, {isServer}) {},
|
||||
// viteVuePluginOptions: {},
|
||||
|
||||
|
||||
vitePlugins: [
|
||||
[ require("vite-plugin-rewrite-all").default ] // slouzi k podpore tecky v params v routeru
|
||||
]
|
||||
// vitePlugins: []
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
|
||||
@@ -112,6 +114,7 @@ module.exports = configure(function (ctx) {
|
||||
|
||||
// Quasar plugins
|
||||
plugins: [
|
||||
'Cookies',
|
||||
'Dialog',
|
||||
'Meta',
|
||||
'Notify'
|
||||
@@ -120,7 +123,9 @@ module.exports = configure(function (ctx) {
|
||||
|
||||
// animations: 'all', // --- includes all animations
|
||||
// https://v2.quasar.dev/options/animations
|
||||
animations: [],
|
||||
animations: [
|
||||
"fadeIn", "fadeOut"
|
||||
],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
|
||||
sourceFiles: {
|
||||
@@ -136,10 +141,11 @@ module.exports = configure(function (ctx) {
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli/developing-ssr/configuring-ssr
|
||||
ssr: {
|
||||
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
|
||||
ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
|
||||
// will mess up SSR
|
||||
|
||||
// extendSSRWebserverConf (esbuildConf) {},
|
||||
extendSSRWebserverConf(esbuildConf) {
|
||||
},
|
||||
// extendPackageJson (json) {},
|
||||
|
||||
pwa: false,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineSsrMiddleware } from '#q-app/wrappers'
|
||||
|
||||
// This middleware should execute as last one
|
||||
// since it captures everything and tries to
|
||||
// render the page with Vue
|
||||
|
||||
export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
|
||||
// we capture any other Express route and hand it
|
||||
// over to Vue and Vue Router to render our page
|
||||
app.get(resolve.urlPath('*'), (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
|
||||
render(/* the ssrContext: */ { req, res })
|
||||
.then(html => {
|
||||
// now let's send the rendered html to the client
|
||||
res.send(html)
|
||||
})
|
||||
.catch(err => {
|
||||
// oops, we had an error while rendering the page
|
||||
|
||||
// we were told to redirect to another URL
|
||||
if (err.url) {
|
||||
if (err.code) {
|
||||
res.redirect(err.code, err.url)
|
||||
} else {
|
||||
res.redirect(err.url)
|
||||
}
|
||||
} else if (err.code === 404) {
|
||||
// hmm, Vue Router could not find the requested route
|
||||
|
||||
// Should reach here only if no "catch-all" route
|
||||
// is defined in /src/routes
|
||||
res.status(404).send('404 | Page Not Found')
|
||||
} else if (process.env.DEV) {
|
||||
// well, we treat any other code as error;
|
||||
// if we're in dev mode, then we can use Quasar CLI
|
||||
// to display a nice error page that contains the stack
|
||||
// and other useful information
|
||||
|
||||
// serve.error is available on dev only
|
||||
serve.error({ err, req, res })
|
||||
} else {
|
||||
// we're in production, so we should have another method
|
||||
// to display something to the client when we encounter an error
|
||||
// (for security reasons, it's not ok to display the same wealth
|
||||
// of information as we do in development)
|
||||
|
||||
// Render Error Page on production or
|
||||
// create a route (/src/routes) for an error page and redirect to it
|
||||
res.status(500).send('500 | Internal Server Error')
|
||||
|
||||
if (process.env.DEBUGGING) {
|
||||
console.error(err.stack)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* More info about this file:
|
||||
* https://v2.quasar.dev/quasar-cli-vite/developing-ssr/ssr-webserver
|
||||
*
|
||||
* Runs in Node context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make sure to yarn add / npm install (in your project root)
|
||||
* anything you import here (except for express and compression).
|
||||
*/
|
||||
import express from 'express'
|
||||
import compression from 'compression'
|
||||
import {
|
||||
defineSsrCreate,
|
||||
defineSsrInjectDevMiddleware,
|
||||
defineSsrListen,
|
||||
defineSsrClose,
|
||||
defineSsrServeStaticContent,
|
||||
defineSsrRenderPreloadTag
|
||||
} from '#q-app/wrappers'
|
||||
|
||||
/**
|
||||
* Create your webserver and return its instance.
|
||||
* If needed, prepare your webserver to receive
|
||||
* connect-like middlewares.
|
||||
*
|
||||
* Can be async: defineSsrCreate(async ({ ... }) => { ... })
|
||||
*/
|
||||
export const create = defineSsrCreate((/* { ... } */) => {
|
||||
const app = express()
|
||||
|
||||
// attackers can use this header to detect apps running Express
|
||||
// and then launch specifically-targeted attacks
|
||||
app.disable('x-powered-by')
|
||||
|
||||
// place here any middlewares that
|
||||
// absolutely need to run before anything else
|
||||
if (process.env.PROD) {
|
||||
app.use(compression())
|
||||
}
|
||||
|
||||
return app
|
||||
})
|
||||
|
||||
/**
|
||||
* Used by Quasar SSR dev server to inject middleware into the webserver.
|
||||
* It uses it to handle Vite dev server, handle public paths, etc.
|
||||
* The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
|
||||
*
|
||||
* Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
|
||||
*/
|
||||
export const injectDevMiddleware = defineSsrInjectDevMiddleware(({ app }) => {
|
||||
return (middleware) => {
|
||||
app.use(middleware)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* You need to make the server listen to the indicated port
|
||||
* and return the listening instance or whatever you need to
|
||||
* close the server with.
|
||||
*
|
||||
* The "listenResult" param for the "close()" definition below
|
||||
* is what you return here.
|
||||
*
|
||||
* For production, you can instead export your
|
||||
* handler for serverless use or whatever else fits your needs.
|
||||
*
|
||||
* Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
|
||||
*/
|
||||
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
|
||||
const server = devHttpsApp || app
|
||||
return server.listen(port, () => {
|
||||
if (process.env.PROD) {
|
||||
console.log('Server listening at port ' + port)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Should close the server and free up any resources.
|
||||
* Will be used on development only when the server needs
|
||||
* to be rebooted.
|
||||
*
|
||||
* Should you need the result of the "listen()" call above,
|
||||
* you can use the "listenResult" param.
|
||||
*
|
||||
* Can be async: defineSsrClose(async ({ listenResult }) => { ... })
|
||||
*/
|
||||
export const close = defineSsrClose(({ listenResult }) => {
|
||||
return listenResult.close()
|
||||
})
|
||||
|
||||
const maxAge = process.env.DEV
|
||||
? 0
|
||||
: 1000 * 60 * 60 * 24 * 30
|
||||
|
||||
/**
|
||||
* Should return a function that will be used to configure the webserver
|
||||
* to serve static content at "urlPath" from "pathToServe" folder/file.
|
||||
*
|
||||
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
|
||||
*
|
||||
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
|
||||
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
|
||||
*/
|
||||
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
|
||||
return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
|
||||
const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
|
||||
app.use(resolve.urlPath(urlPath), serveFn)
|
||||
}
|
||||
})
|
||||
|
||||
const jsRE = /\.js$/
|
||||
const cssRE = /\.css$/
|
||||
const woffRE = /\.woff$/
|
||||
const woff2RE = /\.woff2$/
|
||||
const gifRE = /\.gif$/
|
||||
const jpgRE = /\.jpe?g$/
|
||||
const pngRE = /\.png$/
|
||||
|
||||
/**
|
||||
* Should return a String with HTML output
|
||||
* (if any) for preloading indicated file
|
||||
*/
|
||||
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
|
||||
if (jsRE.test(file) === true) {
|
||||
return `<link rel="modulepreload" href="${file}" crossorigin>`
|
||||
}
|
||||
|
||||
if (cssRE.test(file) === true) {
|
||||
return `<link rel="stylesheet" href="${file}" crossorigin>`
|
||||
}
|
||||
|
||||
if (woffRE.test(file) === true) {
|
||||
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
|
||||
}
|
||||
|
||||
if (woff2RE.test(file) === true) {
|
||||
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
|
||||
}
|
||||
|
||||
if (gifRE.test(file) === true) {
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
|
||||
}
|
||||
|
||||
if (jpgRE.test(file) === true) {
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
|
||||
}
|
||||
|
||||
if (pngRE.test(file) === true) {
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
@@ -1,19 +1,24 @@
|
||||
<script setup>
|
||||
import {useRoute} from "vue-router";
|
||||
import {computed} from "vue";
|
||||
import ErrorNotFound from "pages/ErrorNotFound.vue";
|
||||
import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useMeta} from "quasar";
|
||||
import {metaData} from "src/composables/meta";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
|
||||
const layoutComponent = computed(() => {
|
||||
if (!route.meta) return null;
|
||||
return route.meta.layout;
|
||||
});
|
||||
|
||||
const appStatus = storeToRefs(useAppStore()).appStatus;
|
||||
|
||||
useMeta(() => metaData({
|
||||
title: useAppStore().pageTitle,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 841 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,149 @@
|
||||
import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core'
|
||||
import {ErrorLink} from "@apollo/client/link/error";
|
||||
import {defineStore} from "pinia";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {Notify} from "quasar";
|
||||
import {errorObject, warningObject} from "src/composables/dialog";
|
||||
import {useAuthStore} from "stores/Auth";
|
||||
import UploadHttpLink from "apollo-upload-client/UploadHttpLink.mjs";
|
||||
|
||||
const handleRefreshToken = (store, router, forward, operation) => {
|
||||
if (!operation.query?._refreshUnauthorizedUser) return;
|
||||
|
||||
console.log("refreshing token, ", operation.query);
|
||||
return useAuthStore(store).refreshAccessToken()
|
||||
.catch((error) => {
|
||||
Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu."));
|
||||
router.push({name: "login"})
|
||||
})
|
||||
.then(value => value.flatMap((accessToken) => {
|
||||
const oldHeaders = operation.getContext().headers;
|
||||
operation.setContext({
|
||||
headers: {
|
||||
...oldHeaders,
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// retry the request, returning the new observable
|
||||
return forward(operation);
|
||||
}))
|
||||
}
|
||||
|
||||
// SSR: boot() runs again for every request. The client/query/mutate below
|
||||
// used to live on a shared module-level `apolloWrapper` object that every
|
||||
// request's boot() reassigned in place - under concurrent SSR requests, one
|
||||
// request could end up calling into another request's (still-being-set-up
|
||||
// or already-overwritten) apolloClient/store, i.e. querying/mutating with
|
||||
// the wrong user's auth context. See
|
||||
// handoffs/02-apollo-wrapper-race-condition.md for the full analysis.
|
||||
//
|
||||
// Fix: make this a Pinia store instead of a plain shared object. `init()`
|
||||
// is called from boot() with the request's own `store`/`router` explicitly
|
||||
// (same proven pattern as useAuthStore(store) elsewhere in this file), so
|
||||
// the closures below always close over the correct request. `query`/
|
||||
// `mutate` are then read back through Pinia's own SSR-safe per-request
|
||||
// resolution (useApolloStore() with no arg, called from preFetch/
|
||||
// onServerPrefetch-triggered store actions - the same mechanism every
|
||||
// other useXStore() call in this codebase already relies on), instead of
|
||||
// through a shared mutable object.
|
||||
export const useApolloStore = defineStore('Apollo', () => {
|
||||
let client = null
|
||||
let queryImpl = null
|
||||
let mutateImpl = null
|
||||
|
||||
const init = ({store, router}) => {
|
||||
if (client) return // boot() already initialized this request's store
|
||||
|
||||
const uploadLink = new UploadHttpLink({
|
||||
uri: process.env.API_URL + '/graphql',
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
const authLink = new ApolloLink((operation, forward) => {
|
||||
const accessToken = useAuthStore(store).getAccessToken();
|
||||
if (accessToken) {
|
||||
operation.setContext({
|
||||
headers: {"Authorization": `Bearer ${accessToken}`},
|
||||
});
|
||||
}
|
||||
return forward(operation);
|
||||
});
|
||||
|
||||
const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => {
|
||||
if (networkError?.statusCode === 401) {
|
||||
return handleRefreshToken(store, router, forward, operation);
|
||||
}
|
||||
|
||||
if (graphQLErrors) {
|
||||
for (const error of graphQLErrors) {
|
||||
if (error.message.includes("401") || error.message.includes("Not authorized")) {
|
||||
return handleRefreshToken(store, router, forward, operation);
|
||||
} else if (error.message.includes("404") || error.message.includes("Not found")) {
|
||||
useAppStore(store).appStatus = 404;
|
||||
} else {
|
||||
Notify.create(errorObject("API error: " + error.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
client = new ApolloClient({
|
||||
link: ApolloLink.from([authLink, errorLink, uploadLink]),
|
||||
cache: new InMemoryCache(),
|
||||
connectToDevTools: true,
|
||||
ssrMode: true,
|
||||
});
|
||||
|
||||
queryImpl = (query, variables = {}) => {
|
||||
return client.query({
|
||||
query: query,
|
||||
fetchPolicy: 'no-cache',
|
||||
variables: variables
|
||||
})
|
||||
.then((response) => response)
|
||||
.catch(err => {
|
||||
const errStr = err.toString();
|
||||
if (errStr.includes('Not found') || errStr.includes("No row was found")) {
|
||||
useAppStore(store).appStatus = 404;
|
||||
return Promise.resolve();
|
||||
} else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
|
||||
console.error("Error during query: ", err)
|
||||
}
|
||||
return Promise.reject(err);
|
||||
})
|
||||
};
|
||||
|
||||
mutateImpl = (mutation, variables) => {
|
||||
return client
|
||||
.mutate({
|
||||
mutation: mutation,
|
||||
fetchPolicy: 'no-cache',
|
||||
variables: variables,
|
||||
})
|
||||
.then((response) => response)
|
||||
.catch(err => {
|
||||
console.error("Error during mutation: ", err);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const query = (query, variables) => queryImpl(query, variables)
|
||||
const mutate = (mutation, variables) => mutateImpl(mutation, variables)
|
||||
|
||||
return {init, query, mutate}
|
||||
})
|
||||
|
||||
// Backward-compatible shape so existing call sites
|
||||
// (`apolloWrapper.query(...)`, `apolloWrapper.mutate(...)` in
|
||||
// stores/helpers.js and stores/Organizations.js) keep working unchanged,
|
||||
// now delegating to the request-scoped store above instead of a shared
|
||||
// mutable singleton.
|
||||
export const apolloWrapper = {
|
||||
query: (...args) => useApolloStore().query(...args),
|
||||
mutate: (...args) => useApolloStore().mutate(...args),
|
||||
}
|
||||
|
||||
export default (({store, router}) => {
|
||||
useApolloStore(store).init({store, router})
|
||||
});
|
||||
@@ -1,57 +1,62 @@
|
||||
import {boot} from 'quasar/wrappers'
|
||||
import axios from 'axios'
|
||||
import {useUserStore} from "stores/User";
|
||||
import {useAuthStore} from "stores/Auth";
|
||||
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: process.env.API_URL,
|
||||
withCredentials: true,
|
||||
xsrfHeaderName: "X-CSRF-TOKEN",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
},
|
||||
baseURL: process.env.API_URL,
|
||||
withCredentials: true,
|
||||
xsrfHeaderName: "X-CSRF-TOKEN",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useUserStore().getAccessToken();
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
// SSR: boot() runs again for every request, but `api` above is a
|
||||
// module-level singleton created once. Without ejecting the previous
|
||||
// request's interceptors before adding new ones, api.interceptors.*
|
||||
// would grow without bound for the lifetime of the process, each
|
||||
// handler closing over that request's (by-then-stale) store - see
|
||||
// handoffs/01-axios-interceptors-leak.md for the full analysis.
|
||||
let requestInterceptorId
|
||||
let responseInterceptorId
|
||||
|
||||
if (['/refresh', '/login'].includes(config.url)) {
|
||||
delete config.headers['Authorization'];
|
||||
}
|
||||
export default boot(({store}) => {
|
||||
if (requestInterceptorId !== undefined) {
|
||||
api.interceptors.request.eject(requestInterceptorId)
|
||||
}
|
||||
if (responseInterceptorId !== undefined) {
|
||||
api.interceptors.response.eject(responseInterceptorId)
|
||||
}
|
||||
|
||||
return config;
|
||||
})
|
||||
requestInterceptorId = api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore(store).getAccessToken();
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Response interceptor for API calls
|
||||
api.interceptors.response.use((response) => {
|
||||
return response
|
||||
}, async function (error) {
|
||||
const originalRequest = error.config;
|
||||
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
|
||||
const newToken = await useUserStore().refreshAccessToken().catch(err => {
|
||||
console.error(err);
|
||||
return Promise.reject("Logged out");
|
||||
if (['/refresh', '/login'].includes(config.url)) {
|
||||
delete config.headers['Authorization'];
|
||||
}
|
||||
|
||||
return config;
|
||||
})
|
||||
api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
|
||||
return api(originalRequest);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
|
||||
export default boot(({app}) => {
|
||||
// for use inside Vue files (Options API) through this.$axios and this.$api
|
||||
|
||||
// app.config.globalProperties.$axios = axios
|
||||
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
|
||||
// so you won't necessarily have to import axios in each vue file
|
||||
|
||||
app.config.globalProperties.$api = api
|
||||
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
|
||||
// so you can easily perform requests against your app's API
|
||||
// Response interceptor for API calls
|
||||
responseInterceptorId = api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async function (error) {
|
||||
const originalRequest = error.config;
|
||||
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
|
||||
const newToken = await useAuthStore(store).refreshAccessToken().catch(err => {
|
||||
return Promise.reject("Logged out");
|
||||
})
|
||||
api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
|
||||
return api(originalRequest);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
})
|
||||
|
||||
export {axios, api}
|
||||
|
||||
@@ -2,5 +2,5 @@ import {boot} from "quasar/wrappers";
|
||||
import {BarElement, CategoryScale, Chart as ChartJS, Filler, Legend, LinearScale, LineElement, PointElement, Title, Tooltip} from "chart.js";
|
||||
|
||||
export default boot(({app}) => {
|
||||
ChartJS.register(Title, Tooltip, Legend, PointElement, LineElement, Filler, BarElement, CategoryScale, LinearScale)
|
||||
})
|
||||
ChartJS.register(Title, Tooltip, Legend, PointElement, LineElement, Filler, BarElement, CategoryScale, LinearScale);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {Cookies} from 'quasar'
|
||||
import {boot} from "quasar/wrappers";
|
||||
import {defineStore} from 'pinia'
|
||||
|
||||
// SSR: boot() runs again for every request. `cookies` used to be a single
|
||||
// shared module-level variable reassigned in place by every request's
|
||||
// boot() - under concurrent SSR requests, one request's
|
||||
// Cookies.parseSSR(ssrContext) result (which carries that request's own
|
||||
// accessToken) could overwrite another still-in-flight request's
|
||||
// `cookies` before it finished reading it, leaking one user's access
|
||||
// token to another. See handoffs/03-cookies-shared-state.md.
|
||||
//
|
||||
// Fix: make this a Pinia store, initialized explicitly per request from
|
||||
// boot() with that request's own `store` (same proven pattern as
|
||||
// boot/apollo.js), instead of a shared mutable variable.
|
||||
export const useCookiesStore = defineStore('Cookies', () => {
|
||||
let instance = null
|
||||
|
||||
const init = (ssrContext) => {
|
||||
instance = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
|
||||
}
|
||||
|
||||
const get = (...args) => instance.get(...args)
|
||||
const set = (...args) => instance.set(...args)
|
||||
const remove = (...args) => instance.remove(...args)
|
||||
|
||||
return {init, get, set, remove}
|
||||
})
|
||||
|
||||
export default boot(({store, ssrContext}) => {
|
||||
useCookiesStore(store).init(ssrContext)
|
||||
});
|
||||
@@ -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,6 +0,0 @@
|
||||
import {boot} from "quasar/wrappers";
|
||||
import InnerImageZoom from 'vue-inner-image-zoom';
|
||||
|
||||
export default boot(({app}) => {
|
||||
app.component("inner-image-zoom", InnerImageZoom);
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import {useAltitudeUtils} from "src/composables/airspaces";
|
||||
|
||||
const props = defineProps({
|
||||
lowerLimit: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
lowerLimitUnit: {
|
||||
type: String,
|
||||
required: false // GND nema jednotky
|
||||
},
|
||||
upperLimit: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
upperLimitUnit: {
|
||||
type: String,
|
||||
required: false // GND nema jednotky
|
||||
}
|
||||
})
|
||||
|
||||
const { altitudeLimitFormatted } = useAltitudeUtils();
|
||||
|
||||
const upper = computed(() => altitudeLimitFormatted(props.upperLimit, props.upperLimitUnit));
|
||||
const lower = computed(() => altitudeLimitFormatted(props.lowerLimit, props.lowerLimitUnit));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="altitude-limit">
|
||||
<span class="upper">
|
||||
{{ upper}}
|
||||
</span>
|
||||
<span class="lower">
|
||||
{{ lower }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.altitude-limit {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
font-size: 12px;
|
||||
|
||||
.upper, .lower {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upper {
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import {ref} from "vue";
|
||||
|
||||
const date = new Date();
|
||||
const year = ref(date.getFullYear());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-footer class="q-pa-lg q-mt-xl">
|
||||
<div class="row justify-center">
|
||||
|
||||
<div class="col-xs-12 col-md-4 text-center q-py-lg" style="font-size: 130%">
|
||||
<q-img fit="contain" width="32px" src="/icons/favicon-32x32.png"/>
|
||||
|
||||
<div class="text-h5">Polétání.cz</div>
|
||||
Vytvořeno s <q-icon name="favorite" color="red"/> nejen pro létání.
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-2">
|
||||
<q-list>
|
||||
<q-item>
|
||||
<router-link :to="{name: 'about'}">O projektu</router-link>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<router-link :to="{name: 'contact'}">Kontakt</router-link>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<router-link :to="{name: 'features'}">Funkce</router-link>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
<div class="col-xs-6 col-md-2">
|
||||
<q-list>
|
||||
<q-item>
|
||||
<router-link :to="{name: 'login'}">Login</router-link>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<router-link :to="{name: 'registration'}">Registrace</router-link>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<router-link :to="{name: 'forgottenPassword'}">Zapomenuté heslo</router-link>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-2">
|
||||
<q-list>
|
||||
<q-item>
|
||||
<router-link :to="{name: 'termsAndConditions'}">Podmínky použití</router-link>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<router-link :to="{name: 'personalInformation'}">Správa osobních údajů</router-link>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<div class="col text-center">
|
||||
© {{ year }} Polétání.cz
|
||||
</div>
|
||||
</div>
|
||||
</q-footer>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
a {
|
||||
color: white;
|
||||
font-size: 110%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import TitlePhotoWithGallery from "components/photos/TitlePhotoWithGallery.vue";
|
||||
|
||||
const props = defineProps({
|
||||
titlePhoto: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card class="flight-detail-card q-mb-lg">
|
||||
<TitlePhotoWithGallery
|
||||
v-if="titlePhoto"
|
||||
:photo="titlePhoto"
|
||||
:title="title"
|
||||
/>
|
||||
<q-card-section v-else-if="title && title !== ''">
|
||||
<h3>{{ title }}</h3>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions>
|
||||
<slot name="actions"/>
|
||||
</q-card-actions>
|
||||
|
||||
<slot name="content"/>
|
||||
</q-card>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
to: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="text-right">
|
||||
<q-btn flat class="q-my-sm" :to="props.to" icon="edit" color="primary">
|
||||
{{ props.label }}
|
||||
</q-btn>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,7 +13,6 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const config = computed(() => {
|
||||
|
||||
if (props.mode === "add") {
|
||||
return {icon: "add", label: "Přidat"};
|
||||
} else if (props.mode === "edit") {
|
||||
@@ -26,7 +25,11 @@ const config = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page-sticky location="bottom" :offset="[30, 30]">
|
||||
<q-page-sticky
|
||||
location="bottom"
|
||||
:offset="[30, 30]"
|
||||
v-if="$q.screen.lt.md"
|
||||
>
|
||||
<q-fab :icon="config.icon" color="primary" :label="config.label" @click="$router.push(props.routerLink)"/>
|
||||
</q-page-sticky>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import {useQuasar} from "quasar";
|
||||
|
||||
const props = defineProps({
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
saveButton: {
|
||||
type: [Object, Boolean],
|
||||
required: false,
|
||||
default: () => ({
|
||||
label: "Uložit",
|
||||
icon: "save",
|
||||
align: "left"
|
||||
})
|
||||
}
|
||||
})
|
||||
const $q = useQuasar();
|
||||
const fullwidthBtn = computed(() => $q.screen.width <= $q.screen.sizes.md);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-right">
|
||||
<q-btn
|
||||
color="primary"
|
||||
type="submit"
|
||||
size="lg"
|
||||
:class="{'q-my-lg': true, 'btn--fullwidth': fullwidthBtn}"
|
||||
:icon="props.saveButton.icon"
|
||||
:loading="props.isRequestPending"
|
||||
>
|
||||
{{ props.saveButton.label }}
|
||||
|
||||
<template v-slot:loading>
|
||||
<q-spinner/>
|
||||
</template>
|
||||
</q-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.btn--fullwidth {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup>
|
||||
import {isRequestPending} from "src/composables/form/props";
|
||||
import {computed, useSlots} from "vue";
|
||||
import {useQuasar} from "quasar";
|
||||
import FormSubmitButton from "components/FormSubmitButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
},
|
||||
isRequestPending,
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
saveButton: {
|
||||
type: [Object, Boolean],
|
||||
required: false,
|
||||
@@ -23,38 +25,11 @@ const emits = defineEmits(["save"]);
|
||||
const saveForm = () => {
|
||||
emits("save", props.modelValue);
|
||||
}
|
||||
|
||||
const $q = useQuasar();
|
||||
const fullwidthBtn = computed(() => $q.screen.width <= $q.screen.sizes.md);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-form @submit.prevent="saveForm" ref="form">
|
||||
<slot name="default"/>
|
||||
|
||||
<div class="text-right">
|
||||
<q-btn
|
||||
color="primary"
|
||||
type="submit"
|
||||
size="lg"
|
||||
:class="{'q-my-lg': true, 'btn--fullwidth': fullwidthBtn}"
|
||||
v-if="saveButton"
|
||||
:icon="props.saveButton.icon"
|
||||
:loading="props.isRequestPending"
|
||||
>
|
||||
{{ props.saveButton.label }}
|
||||
|
||||
<template v-slot:loading>
|
||||
<q-spinner />
|
||||
</template>
|
||||
</q-btn>
|
||||
</div>
|
||||
<FormSubmitButton v-if="saveButton" :save-button="saveButton" :is-request-pending="isRequestPending" />
|
||||
</q-form>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.btn--fullwidth {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import {onMounted, ref, watch} from "vue";
|
||||
import {debounce} from "quasar";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
|
||||
const props = defineProps({
|
||||
emitDebounce: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 200
|
||||
}
|
||||
})
|
||||
const emits = defineEmits(["filtersChanged"]);
|
||||
|
||||
const filters = ref({
|
||||
search: ""
|
||||
});
|
||||
|
||||
const $route = useRoute();
|
||||
const $router = useRouter();
|
||||
watch((() => filters.value), debounce(() => {
|
||||
emits("filtersChanged", filters.value);
|
||||
|
||||
const query = {...$route.query};
|
||||
for (const key in filters.value) {
|
||||
if (filters.value[key] === "" || filters.value['key'] === null) {
|
||||
delete query[key];
|
||||
} else {
|
||||
query[key] = filters.value[key];
|
||||
}
|
||||
}
|
||||
$router.replace({query});
|
||||
}, props.emitDebounce), {deep: true});
|
||||
|
||||
onMounted(() => {
|
||||
const search = $route.query.search;
|
||||
|
||||
if (search) {
|
||||
filters.value.search = search;
|
||||
} else {
|
||||
filters.value = {};
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="col-grow">
|
||||
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
|
||||
<template #prepend>
|
||||
<q-icon name="search"/>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<slot name="append" class="full-height"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 +0,0 @@
|
||||
<script setup>
|
||||
import {aircraft} from "src/composables/objects/aircraft";
|
||||
import {confirmObject} from "src/composables/dialog";
|
||||
import {useQuasar} from "quasar";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useUserStore} from "stores/User";
|
||||
|
||||
const props = defineProps({
|
||||
aircraft: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["delete"]);
|
||||
const $q = useQuasar();
|
||||
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
|
||||
|
||||
const confirmDelete = (aircraftId) => {
|
||||
$q.dialog(confirmObject('Opravdu chceš smazat toto letadlo?'))
|
||||
.onOk(() => {
|
||||
emits("delete", aircraftId);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card class="q-mx-md q-my-md flight-detail-card">
|
||||
<q-img :src="aircraft.photoUrl" v-if="aircraft.photoUrl"/>
|
||||
|
||||
<q-card-actions class="q-my-md" v-if="aircraft.createdById === loggedUser.id">
|
||||
<div class="col-grow">
|
||||
<q-btn
|
||||
flat
|
||||
:to='{name: "aircraftEdit", params: {id: aircraft.id}}'
|
||||
icon="edit"
|
||||
text-color="primary"
|
||||
label="Upravit"
|
||||
/>
|
||||
</div>
|
||||
<q-btn @click="confirmDelete(aircraft.id)" icon="delete" flat text-color="red" label="Smazat"/>
|
||||
</q-card-actions>
|
||||
|
||||
<q-card-section>
|
||||
<q-list>
|
||||
|
||||
<q-item v-if="aircraft.organization">
|
||||
Provozovatel: {{ aircraft.organization.name }}
|
||||
</q-item>
|
||||
|
||||
<q-item v-if="aircraft.manufacturer">
|
||||
Výrobce: {{ aircraft.manufacturer }}
|
||||
</q-item>
|
||||
<q-item v-if="aircraft.model">
|
||||
Model: {{ aircraft.model }}
|
||||
</q-item>
|
||||
<q-item v-if="aircraft.seats">
|
||||
Počet míst: {{ aircraft.seats }}
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<article v-html="aircraft.description" class="q-my-md"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
@@ -2,11 +2,27 @@
|
||||
import {onMounted, reactive} from "vue";
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {setDefaultData} from "src/composables/form/formHandling";
|
||||
import {defaultValues, isRequestPending} from "src/composables/form/props";
|
||||
import {isNumber, isRequired, maxLength, minLength} from "src/composables/form/rules";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
|
||||
const props = defineProps({
|
||||
defaultValues: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
organizations: {
|
||||
type: Array,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
callSign: "",
|
||||
@@ -14,17 +30,8 @@ const formData = reactive({
|
||||
seats: null,
|
||||
manufacturer: "",
|
||||
description: "",
|
||||
photo: null,
|
||||
organization: null
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
defaultValues,
|
||||
isRequestPending,
|
||||
organizations: {
|
||||
type: Array,
|
||||
required: true,
|
||||
}
|
||||
organization: null,
|
||||
isPublic: false
|
||||
});
|
||||
|
||||
const rules = {
|
||||
@@ -54,7 +61,7 @@ onMounted(() => {
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
<q-input
|
||||
type="number"
|
||||
v-model.number="formData.seats"
|
||||
label="Počet míst (včetně pilota)"
|
||||
@@ -84,8 +91,6 @@ onMounted(() => {
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<q-input
|
||||
type="textarea"
|
||||
v-model="formData.description"
|
||||
@@ -93,10 +98,7 @@ onMounted(() => {
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<q-file v-model="formData.photo" label="Fotografie" v-bind="commonProps">
|
||||
<template #prepend>
|
||||
<q-icon name="upload"/>
|
||||
</template>
|
||||
</q-file>
|
||||
<q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/>
|
||||
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import {useQuasar} from "quasar";
|
||||
|
||||
const props = defineProps({
|
||||
aircraft: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list>
|
||||
<q-item v-if="aircraft.organization">
|
||||
Provozovatel: {{ aircraft.organization.name }}
|
||||
</q-item>
|
||||
|
||||
<q-item v-if="aircraft.manufacturer">
|
||||
Výrobce: {{ aircraft.manufacturer }}
|
||||
</q-item>
|
||||
<q-item v-if="aircraft.model">
|
||||
Model: {{ aircraft.model }}
|
||||
</q-item>
|
||||
<q-item v-if="aircraft.seats">
|
||||
Počet míst: {{ aircraft.seats }}
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<article v-html="aircraft.description" class="q-my-md"/>
|
||||
</template>
|
||||
@@ -1,46 +1,53 @@
|
||||
<script setup>
|
||||
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useInfiniteScroll} from "src/composables/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
...useInfiniteScroll().propsDef
|
||||
});
|
||||
|
||||
useAppStore().setPageTitle("Letadla");
|
||||
const emits = defineEmits([...useInfiniteScroll().emitsDef])
|
||||
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
|
||||
v-for="aircraft of props.items" :key="aircraft"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar v-if="aircraft.photoUrl">
|
||||
<img :src="aircraft.photoUrl" alt=""/>
|
||||
</q-avatar>
|
||||
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label label>
|
||||
{{ aircraft.callSign }}
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
{{ aircraft.model }}
|
||||
<template v-if="aircraft.manufacturer">
|
||||
({{ aircraft.manufacturer }})
|
||||
</template>
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="aircraft.organization">
|
||||
Provozovatel: {{ aircraft.organization.name }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-infinite-scroll
|
||||
:disable="!hasMoreData"
|
||||
@load="emitLoadMore"
|
||||
:offset="250"
|
||||
>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
|
||||
v-for="aircraft of props.items" :key="aircraft"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar v-if="aircraft.titlePhoto">
|
||||
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
|
||||
</q-avatar>
|
||||
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label label>
|
||||
{{ aircraft.callSign }}
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
{{ aircraft.model }}
|
||||
<template v-if="aircraft.manufacturer">
|
||||
({{ aircraft.manufacturer }})
|
||||
</template>
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="aircraft.organization">
|
||||
Provozovatel: {{ aircraft.organization.name }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-infinite-scroll>
|
||||
|
||||
</q-item>
|
||||
</q-list>
|
||||
</template>
|
||||
|
||||
@@ -13,11 +13,11 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-my-md">
|
||||
<BasicTextSkeleton :paragraphs="1" :lines="randomInt(1, 3)"/>
|
||||
<BasicTextSkeleton :paragraphs="1" :lines="2"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card class="flight-detail-card" flat>
|
||||
<BasicListSkeleton :items-count-min="1" :items-count-max="4"/>
|
||||
|
||||
<h3>Lety</h3>
|
||||
<BasicListSkeleton :items="5" />
|
||||
</q-card>
|
||||
</template>
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup>
|
||||
import {poi} from "src/composables/objects/poi";
|
||||
|
||||
const props = defineProps({
|
||||
routeBack: {
|
||||
type: Object,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
routeAdd: {
|
||||
type: Object,
|
||||
@@ -8,16 +10,44 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: false,
|
||||
default: "Přidat"
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'info'
|
||||
}
|
||||
});
|
||||
|
||||
const bannerClass = computed(() => {
|
||||
const defaultClasses = 'bg-light-blue text-white';
|
||||
const classMap = {
|
||||
info: defaultClasses,
|
||||
warning: 'bg-orange text-white',
|
||||
error: 'bg-red text-white',
|
||||
}
|
||||
|
||||
return classMap[props.color] || defaultClasses;
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
const iconsMap = {
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
}
|
||||
|
||||
return iconsMap[props.color];
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="q-pa-md q-gutter-sm">
|
||||
<q-banner class="bg-light-blue text-white">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="info"/>
|
||||
<q-banner :class=bannerClass>
|
||||
<template v-slot:avatar v-if="icon">
|
||||
<q-icon :name="icon"/>
|
||||
</template>
|
||||
|
||||
<slot/>
|
||||
|
||||
<template v-slot:action v-if="routeAdd">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import {randomInt} from "src/composables/random";
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
itemsCountMin: {
|
||||
@@ -11,14 +12,19 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required:false,
|
||||
default: 5
|
||||
},
|
||||
items: {
|
||||
type: Number,
|
||||
required: false,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const renderedItems = computed(() => props.items || randomInt(props.itemsCountMin, props.itemsCountMax));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list bordered separator>
|
||||
<q-item v-ripple v-for="i in randomInt(itemsCountMin, itemsCountMax)" :key="i">
|
||||
<q-item v-ripple v-for="i in renderedItems" :key="i">
|
||||
<q-item-section avatar>
|
||||
<q-skeleton type="QAvatar" />
|
||||
</q-item-section>
|
||||
|
||||
@@ -4,24 +4,35 @@ import {onMounted, reactive} from "vue";
|
||||
import {setDefaultData} from "src/composables/form/formHandling";
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import {isRequestPending} from "src/composables/form/props";
|
||||
import {isRequired} from "src/composables/form/rules";
|
||||
import SlugInput from "components/inputs/SlugInput.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useLoggedUserStore} from "stores/LoggedUser";
|
||||
|
||||
const props = defineProps({
|
||||
copilot: {
|
||||
type: Object
|
||||
},
|
||||
isRequestPending
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
name: null,
|
||||
urlSlug: "",
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [isRequired]
|
||||
name: [isRequired],
|
||||
urlSlug: [],
|
||||
}
|
||||
|
||||
const loggedUser = storeToRefs(useLoggedUserStore()).user;
|
||||
|
||||
onMounted(() => {
|
||||
if (props.copilot) {
|
||||
setDefaultData(formData, props.copilot);
|
||||
@@ -38,5 +49,19 @@ onMounted(() => {
|
||||
:rules="rules.name"
|
||||
v-model="formData.name"
|
||||
/>
|
||||
|
||||
<template v-if="loggedUser?.publicUsername">
|
||||
<SlugInput
|
||||
v-if="formData.isPublic"
|
||||
v-bind="commonProps"
|
||||
v-model="formData.urlSlug"
|
||||
label="URL"
|
||||
:url="`/${loggedUser.publicUsername}/a/`"
|
||||
:slug-source="formData.name"
|
||||
:rules="rules.urlSlug"
|
||||
/>
|
||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||
</template>
|
||||
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,10 @@ const props = defineProps({
|
||||
v-ripple
|
||||
v-for="pilot of items" :key="pilot">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="person" color="primary" text-color="white"/>
|
||||
<q-avatar v-if="pilot.titlePhoto">
|
||||
<img :src="pilot.titlePhoto.thumbnailUrl" alt=""/>
|
||||
</q-avatar>
|
||||
<q-avatar v-else icon="person" color="primary" text-color="white"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ pilot.name }}
|
||||
|
||||
@@ -4,5 +4,5 @@ import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
|
||||
<template>
|
||||
<q-skeleton type="rect" class="q-my-lg" style="width: 200px; height: 40px" />
|
||||
<BasicListSkeleton :items-count-min="1" :items-count-max="5"/>
|
||||
<BasicListSkeleton :items="4"/>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import {FORMAT_DATE, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-chip icon="date_range" v-if="event.dateFrom || event.dateTo">
|
||||
<template v-if="event.dateFrom !== event.dateTo">
|
||||
{{ formatDatetime(event.dateFrom, FORMAT_DATE) }} - {{ formatDatetime(event.dateTo, FORMAT_DATE) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
|
||||
</template>
|
||||
</q-chip>
|
||||
</template>
|
||||
@@ -1,5 +0,0 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<em>Copilot skeleton</em>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import MapWithMarkers from "components/map/MapWithMarkers.vue";
|
||||
import {ref} from "vue";
|
||||
import {useEventsStore} from "stores/Events";
|
||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
import {pluralize} from "src/composables/utils";
|
||||
|
||||
const props = defineProps({
|
||||
eventId: {
|
||||
type: Number,
|
||||
required: false
|
||||
},
|
||||
eventUrlSlug: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
onlyPublic: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const tracks = ref([]);
|
||||
const totalTime = ref(0);
|
||||
const totalFlights = ref(0);
|
||||
|
||||
useEventsStore().fetchFlightsTracks({
|
||||
eventId: props.eventId,
|
||||
eventUrlSlug: props.eventUrlSlug,
|
||||
onlyPublic: props.onlyPublic
|
||||
}).then((data) => {
|
||||
if (!data.flights) return;
|
||||
|
||||
totalFlights.value = data.flights.length;
|
||||
|
||||
for (const flightTrack of data.flights) {
|
||||
totalTime.value += flightTrack.durationMinCalculated;
|
||||
|
||||
if (flightTrack.track) {
|
||||
tracks.value.push(flightTrack.track.points.coordinates);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="tracks.length > 1">
|
||||
<h3>Trasy letů</h3>
|
||||
|
||||
<MapWithMarkers
|
||||
style="width: 100%; height: 500px;"
|
||||
:tracks="tracks"
|
||||
/>
|
||||
|
||||
<div class="text-center q-my-md">
|
||||
<q-chip>
|
||||
<q-avatar>{{ totalFlights }}</q-avatar>
|
||||
{{ pluralize(totalFlights, "let", "lety", "letů") }}
|
||||
</q-chip>
|
||||
|
||||
<q-chip icon="schedule">
|
||||
{{ minutesToHumanReadable(totalTime) }}
|
||||
</q-chip>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</template>
|
||||
@@ -4,21 +4,29 @@ import {onMounted, reactive} from "vue";
|
||||
import {setDefaultData} from "src/composables/form/formHandling";
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import {isRequestPending} from "src/composables/form/props";
|
||||
import {isRequired} from "src/composables/form/rules";
|
||||
import RichTextEditor from "components/inputs/RichTextEditor.vue";
|
||||
import DatetimePicker from "components/inputs/DatetimePicker.vue";
|
||||
import {FORMAT_DATE, FORMAT_DATETIME_LONG} from "src/composables/datetimeUtils";
|
||||
import SlugInput from "components/inputs/SlugInput.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useLoggedUserStore} from "stores/LoggedUser";
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: Object
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
isRequestPending
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
name: null,
|
||||
name: "",
|
||||
urlSlug: "",
|
||||
isPublic: false,
|
||||
description: "",
|
||||
dateFrom: null,
|
||||
@@ -35,6 +43,7 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const loggedUser = storeToRefs(useLoggedUserStore()).user;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,12 +54,25 @@ onMounted(() => {
|
||||
:rules="rules.name"
|
||||
v-model="formData.name"
|
||||
/>
|
||||
|
||||
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateFrom" label="Od" />
|
||||
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateTo" label="Do" />
|
||||
|
||||
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
|
||||
<SlugInput
|
||||
v-if="formData.isPublic"
|
||||
v-bind="commonProps"
|
||||
v-model="formData.urlSlug"
|
||||
label="URL"
|
||||
:url="`/${loggedUser?.publicUsername}/udalosti/`"
|
||||
:slug-source="formData.name"
|
||||
/>
|
||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col q-pr-sm">
|
||||
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateFrom" label="Od"/>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateTo" label="Do"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -8,31 +8,30 @@ const props = defineProps({
|
||||
},
|
||||
routeDetail: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({name: 'eventDetail', params: {}})
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getCoverPhotos = (item) => item.flights.filter(f => !!f.coverPhoto).map(f => f.coverPhoto.thumbnailUrl);
|
||||
const getCoverPhotos = (item) => item.flights.filter(f => !!f.titlePhoto).map(f => f.titlePhoto.thumbnailUrl);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
v-for="eventDetail of items" :key="eventDetail"
|
||||
clickable
|
||||
:to="{name: routeDetail.name, params: {...routeDetail.params, id: event.id}}"
|
||||
:to="{name: props.routeDetail.name, params: {...props.routeDetail.params, eventSlug: eventDetail.urlSlug, id: eventDetail.id}}"
|
||||
v-ripple
|
||||
v-for="event of items" :key="event"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<q-avatar icon="event" color="primary" text-color="white" v-if="event.flights.length === 0"/>
|
||||
<AvatarsGroup :avatars="getCoverPhotos(event)" v-else/>
|
||||
<q-avatar icon="event" color="primary" text-color="white" v-if="!eventDetail.flights || eventDetail.flights.length === 0"/>
|
||||
<AvatarsGroup :avatars="getCoverPhotos(eventDetail)" v-else/>
|
||||
|
||||
{{ event.name }}
|
||||
{{ eventDetail.name }}
|
||||
</q-item-label>
|
||||
<q-item-label lines="4" caption>
|
||||
<article v-html="event.description"/>
|
||||
<article v-html="eventDetail.description"/>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
@@ -13,5 +13,5 @@ import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue";
|
||||
|
||||
<BasicTextSkeleton :lines="3" :paragraphs="2"/>
|
||||
|
||||
<BasicListSkeleton :items-count-min="1" :items-count-max="5"/>
|
||||
<BasicListSkeleton :items="4"/>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-skeleton type="QInput" class="q-my-lg"/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col q-pr-sm">
|
||||
<q-skeleton type="QInput"/>
|
||||
</div>
|
||||
<div class="col q-pl-sm">
|
||||
<q-skeleton type="QInput"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-skeleton type="rect" class="q-my-lg" height="400px"/>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import DatetimePicker from "components/inputs/DatetimePicker.vue";
|
||||
import {reactive} from "vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {useFlightPlanCalculations} from "src/composables/flightPlan";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const {getTotalDuration} = useFlightPlanCalculations();
|
||||
|
||||
const formData = reactive({
|
||||
gpxTrackFile: null,
|
||||
takeoffDatetime: props.flightPlan.plannedTakeoffDatetime,
|
||||
landingDatetime: new Date(
|
||||
(new Date(props.flightPlan.plannedTakeoffDatetime)).getTime() + 60 * 1000 * getTotalDuration(props.flightPlan)
|
||||
),
|
||||
copyTrack: true,
|
||||
createPointsOfInterests: true,
|
||||
flightPlan: props.flightPlan,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData" :save-button="{label: 'Vytvořit let z plánu', icon: 'save'}">
|
||||
<template v-if="!formData.gpxTrackFile">
|
||||
<DatetimePicker v-model="formData.takeoffDatetime" label="Čas vzletu"/>
|
||||
<DatetimePicker v-model="formData.landingDatetime" label="Čas přistání"/>
|
||||
</template>
|
||||
|
||||
<q-file
|
||||
v-bind="commonProps"
|
||||
v-model="formData.gpxTrackFile"
|
||||
label="Trasa v GPX"
|
||||
hint="Z trasy se načtou letiště a časy vzletu a přistání. Tyto hodnoty je možné později upravit."
|
||||
/>
|
||||
|
||||
<q-checkbox
|
||||
v-model="formData.copyTrack"
|
||||
label="Zkopírovat otočné body (letiště a POI)?"
|
||||
/>
|
||||
</FormWrapper>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import DatetimePicker from "components/inputs/DatetimePicker.vue";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {computed, reactive} from "vue";
|
||||
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
|
||||
import {useLoggedUserStore} from "stores/LoggedUser";
|
||||
import {storeToRefs} from "pinia";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object
|
||||
},
|
||||
aircrafts: {
|
||||
type: Array,
|
||||
},
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
})
|
||||
|
||||
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
|
||||
|
||||
const formData = reactive({
|
||||
name: props.flightPlan.name || null,
|
||||
isDefaultName: props.flightPlan ? props.flightPlan.isDefaultName : true,
|
||||
isEditableByCopilot: props.flightPlan.isEditableByCopilot || false,
|
||||
isPublic: props.flightPlan.isPublic || false,
|
||||
plannedTakeoffDatetime: props.flightPlan.plannedTakeoffDatetime || null,
|
||||
aircraft: props.flightPlan && props.flightPlan.aircraft ? {
|
||||
id: props.flightPlan.aircraft.id, name: props.flightPlan.aircraft.callSign
|
||||
} : null,
|
||||
plannedSpeed: props.flightPlan.plannedSpeed || 150,
|
||||
description: props.flightPlan.description || '',
|
||||
});
|
||||
|
||||
const {user} = storeToRefs(useLoggedUserStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData" :is-request-pending="isRequestPending">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-8 col-md-10" v-if="!formData.isDefaultName">
|
||||
<q-input v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-checkbox v-model="formData.isDefaultName" label="Automatické jméno" v-bind="commonProps"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-checkbox v-model="formData.isPublic" label="Zveřejnit"/>
|
||||
<CopyLinkButton
|
||||
v-if="flightPlan.isPublic"
|
||||
:route="{name: 'publicFlightPlanDetail', params: {username: user.publicUsername, id: flightPlan.id}}"
|
||||
/>
|
||||
|
||||
<q-editor v-model="formData.description" label="Popis" v-bind="commonProps"/>
|
||||
<DatetimePicker v-model="formData.plannedTakeoffDatetime" label="Plánovaný datum a čas vzletu" v-bind="commonProps"/>
|
||||
<ComboboxInput v-model="formData.aircraft" :options="aircrafts" label="Letadlo" v-bind="commonProps"/>
|
||||
|
||||
<q-input v-model.number="formData.plannedSpeed" type="number" step="5" label="Plánovaná rychlost" v-bind="commonProps">
|
||||
<template #append>km/h</template>
|
||||
</q-input>
|
||||
</FormWrapper>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup>
|
||||
import {computed, ref, watch} from "vue";
|
||||
import {Marker, CustomMarker, Polyline} from "vue3-google-map";
|
||||
import MapWrapper from "components/map/MapWrapper.vue";
|
||||
import {useFlightPlanCalculations, useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
import {useGeocode} from "src/composables/geocoding";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
airspaceData: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
airports: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
pointsOfInterest: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
insertPosition: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(["markers-changed", "change-insert-position"]);
|
||||
|
||||
const {center} = useGeocode();
|
||||
|
||||
const {markerObject, googleMapsMarkerObject, googleMapsCustomMarkerObject, editableNameTypes} = useFlightPlanObjects();
|
||||
const {getMarkersFrom} = useFlightPlanUtils();
|
||||
const {getDuration} = useFlightPlanCalculations();
|
||||
|
||||
const mapWrap = ref(null);
|
||||
|
||||
const handleMarkerClick = (marker) => {
|
||||
mapWrap.value.openInfoWindow({marker});
|
||||
}
|
||||
|
||||
const markers = ref([]);
|
||||
watch(() => props.flightPlan.track, (newVal) => {
|
||||
markers.value = getMarkersFrom(props.flightPlan);
|
||||
}, {immediate: true, deep: true});
|
||||
|
||||
const track = computed(() => markers.value.map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude})));
|
||||
const trackAids = computed(() => props.flightPlan.track.map(p => ({
|
||||
...p,
|
||||
anchorPoint: 'CENTER',
|
||||
bearing: p.bearing,
|
||||
distance: p.distance,
|
||||
position: {
|
||||
lat: (p.from.gpsLatitude + p.to?.gpsLatitude) / 2,
|
||||
lng: (p.from.gpsLongitude + p.to?.gpsLongitude) / 2
|
||||
}
|
||||
}
|
||||
)));
|
||||
const displayTrackAids = ref(true);
|
||||
|
||||
const addMarker = async (point) => {
|
||||
if (!point) return;
|
||||
|
||||
const insertAt = Math.min(markers.value.length, props.insertPosition);
|
||||
markers.value.splice(insertAt, 0, markerObject({...point}));
|
||||
|
||||
emits("markers-changed", markers.value);
|
||||
emits("change-insert-position", insertAt + 1);
|
||||
}
|
||||
|
||||
const removeMarker = (infoWindow) => {
|
||||
const idx = markers.value.findIndex(i => i.gpsLatitude === infoWindow.position.lat && i.gpsLongitude === infoWindow.position.lng);
|
||||
|
||||
if (!idx) return;
|
||||
|
||||
markers.value.splice(idx, 1);
|
||||
emits("markers-changed", markers.value);
|
||||
}
|
||||
|
||||
const {searchPlaceName} = useGeocode();
|
||||
const handleMarkerMove = async (idx, event) => {
|
||||
markers.value[idx].name = await searchPlaceName(event.lat(), event.lng());
|
||||
markers.value[idx].gpsLatitude = event.lat();
|
||||
markers.value[idx].gpsLongitude = event.lng();
|
||||
markers.value[idx].type = "mapPoint"
|
||||
|
||||
emits("markers-changed", markers.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<MapWrapper
|
||||
ref="mapWrap"
|
||||
style="width: 100%; height: 65vh; min-height: 500px"
|
||||
:center="center"
|
||||
:zoom="8"
|
||||
:track="{path: track, geodesic: true, strokeColor: '#ff00ff', strokeOpacity: 1, strokeWeight: 3}"
|
||||
:points-of-interest="pointsOfInterest"
|
||||
:airspaces-data="airspaceData"
|
||||
:airports="airports"
|
||||
v-bind="$attrs"
|
||||
:fullscreen-control="false"
|
||||
:show-info-window="true"
|
||||
:show-layer-controls="true"
|
||||
:allow-add-marker="insertPosition !== null"
|
||||
@add-to-route="addMarker"
|
||||
@remove-from-route="removeMarker"
|
||||
>
|
||||
<template v-for="(marker, i) in markers" :key="marker.name">
|
||||
<!-- TODO: ve verejne casti by nemel byt draggable!-->
|
||||
<Marker
|
||||
v-if="editableNameTypes.includes(marker.type)"
|
||||
:options="googleMapsMarkerObject(marker)"
|
||||
@click="handleMarkerClick(marker)"
|
||||
@dragend="handleMarkerMove(i, $event.latLng)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="displayTrackAids">
|
||||
<template v-for="marker in trackAids" :key="marker">
|
||||
<CustomMarker :options="marker" class="track-info-marker">
|
||||
<div class="track-info" :style='`transform: rotate(${marker.bearing}deg)`'>
|
||||
{{ marker.bearing }}° <br>
|
||||
{{ getDuration(marker.distance, flightPlan.plannedSpeed) }}'
|
||||
</div>
|
||||
</CustomMarker>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="markers.length > 1">
|
||||
<CustomMarker :options="googleMapsCustomMarkerObject(markers[0], 'CENTER')" class="significant-airport">
|
||||
<q-icon name="flight_takeoff"/>
|
||||
</CustomMarker>
|
||||
|
||||
<CustomMarker :options="googleMapsCustomMarkerObject(markers[markers.length - 1], 'CENTER')" class="significant-airport">
|
||||
<q-icon name="flight_land"/>
|
||||
</CustomMarker>
|
||||
|
||||
</template>
|
||||
|
||||
<template #layersControlsEnd>
|
||||
<q-checkbox v-model="displayTrackAids" label="Zobrazovat navigaci po trase?"/>
|
||||
</template>
|
||||
</MapWrapper>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.significant-airport {
|
||||
position: relative;
|
||||
z-index: 99999;
|
||||
text-align: center;
|
||||
margin-top: -40px;
|
||||
|
||||
.q-icon {
|
||||
color: white;
|
||||
background: #39b6e6;
|
||||
border: solid 1px black;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 20px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.track-info {
|
||||
background: rgba(128, 128, 128, 0.5);
|
||||
color: white;
|
||||
border: solid 2px rgba(0, 0, 0, 0.87);
|
||||
padding: 5px;
|
||||
margin: 5px;
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.track-info-marker {
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import {useFlightPlanCalculations} from "src/composables/flightPlan";
|
||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
import {useQuasar} from "quasar";
|
||||
import {textEllipsis} from "src/composables/utils";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
plannedSpeed: {
|
||||
type: Number,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: 'Z',
|
||||
align: "left",
|
||||
field: 'from'
|
||||
},
|
||||
{
|
||||
label: 'Do',
|
||||
align: "left",
|
||||
field: 'to'
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Kurz',
|
||||
field: 'bearing',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Vzdálenost',
|
||||
field: 'distance',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Čas',
|
||||
field: 'duration',
|
||||
},
|
||||
];
|
||||
|
||||
const {getDuration, getTotalDuration, getTotalDistance} = useFlightPlanCalculations();
|
||||
|
||||
const data = computed(() => props.flightPlan.track.map((item) => ({
|
||||
from: textEllipsis(item.from.name, 30),
|
||||
to: textEllipsis(item.to.name, 30),
|
||||
distance: `${item.distance} km`,
|
||||
bearing: `${item.bearing} °`,
|
||||
duration: minutesToHumanReadable(getDuration(item.distance, props.plannedSpeed)),
|
||||
})));
|
||||
|
||||
const {screen} = useQuasar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-table
|
||||
:grid="screen.xs"
|
||||
:dense="screen.lt.md"
|
||||
flat bordered
|
||||
hide-bottom
|
||||
:title="flightPlan.name"
|
||||
:rows="data"
|
||||
:columns="columns"
|
||||
:rows-per-page-options="[0]"
|
||||
>
|
||||
<template #bottom-row="{cols}">
|
||||
<tr class="summary">
|
||||
<td v-for="col in cols" :key="col.field" :class="{'text-right': col.align === 'right'}">
|
||||
<template v-if="col.field === 'duration'">
|
||||
{{ minutesToHumanReadable(getTotalDuration(flightPlan, plannedSpeed)) }}
|
||||
</template>
|
||||
<template v-else-if="col.field === 'distance'">
|
||||
{{ getTotalDistance(flightPlan) }} km
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.summary {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import {ref, watch} from "vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import {useGeocode} from "src/composables/geocoding";
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
const emits = defineEmits(["select"])
|
||||
|
||||
const place = ref("");
|
||||
const placesOptions = ref(null);
|
||||
|
||||
const {searchGeocode} = useGeocode()
|
||||
|
||||
watch(() => place.value, (newVal) => {
|
||||
if (newVal)
|
||||
emits("select", newVal);
|
||||
place.value = null;
|
||||
});
|
||||
const placesFilter = (val, update, abort) => {
|
||||
update(() => {
|
||||
if (val && val !== '') {
|
||||
placesOptions.value = props.options.filter(p => p.name.includes(val));
|
||||
if (placesOptions.value.length === 0) {
|
||||
searchGeocode(val).then(results => {
|
||||
if (!results) return;
|
||||
|
||||
console.log(results)
|
||||
|
||||
placesOptions.value = results.map(result => {
|
||||
console.log(result, result.geometry.location)
|
||||
return {
|
||||
name: result.formatted_address,
|
||||
type: "geocode",
|
||||
id: null,
|
||||
gpsLatitude: result.geometry.location.lat(),
|
||||
gpsLongitude: result.geometry.location.lng(),
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
} else {
|
||||
placesOptions.value = props.options;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-select
|
||||
v-bind="commonProps"
|
||||
v-model="place"
|
||||
label="Místo"
|
||||
use-input
|
||||
input-debounce="100"
|
||||
map-options
|
||||
emit-value
|
||||
option-label="name"
|
||||
option-disable="disabled"
|
||||
:options="placesOptions"
|
||||
@filter="placesFilter"
|
||||
>
|
||||
<template #no-option>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
Toto místo tu nemám. Zkus psát dál, najdem něco v Google Places.
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import {computed, ref, watch} from "vue";
|
||||
import FlightPlanPlaceInput from "components/flightPlans/FlightPlanPlaceInput.vue";
|
||||
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
|
||||
const props = defineProps({
|
||||
markers: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
pointsOfInterest: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
airports: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["markers-changed"]);
|
||||
|
||||
const model = ref(props.markers);
|
||||
const removeMarker = (idx) => {
|
||||
model.value.splice(idx, 1);
|
||||
}
|
||||
const swapMarkers = (idx1, idx2) => {
|
||||
const tmp = model.value[idx1];
|
||||
model.value[idx1] = model.value[idx2];
|
||||
model.value[idx2] = tmp;
|
||||
};
|
||||
const handlePlaceSelect = (modelVal) => {
|
||||
model.value.push(modelVal);
|
||||
|
||||
// TODO: upravit bounds u mapy
|
||||
|
||||
}
|
||||
|
||||
watch(() => model.value, () => {
|
||||
emits("markers-changed", model.value);
|
||||
}, {deep: true});
|
||||
|
||||
const {getIcon} = useFlightPlanUtils();
|
||||
const {editableNameTypes} = useFlightPlanObjects();
|
||||
|
||||
const allPlacesOptions = computed(() => {
|
||||
const reportingPoints = props.airports.map(a => a.reportingPoints.map(r => ({...r, airport: a}))).flat()
|
||||
return [
|
||||
...props.pointsOfInterest.map(poi => ({...poi, pointOfInterestId: poi.id, type: "poi"})),
|
||||
...reportingPoints.map(r => ({
|
||||
...r,
|
||||
name: `${r.airport.icaoCode}:${r.name}`,
|
||||
type: "reportingPoint"
|
||||
})),
|
||||
...props.airports.map(airport => ({
|
||||
...airport,
|
||||
name: `${airport.icaoCode} - ${airport.name}`,
|
||||
type: "airport",
|
||||
airportId: airport.id,
|
||||
}))
|
||||
].map(i => ({...i, disabled: !i.gpsLatitude || !i.gpsLongitude}));
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-scroll-area style="height: 60vh;">
|
||||
<q-list bordered separator class="full-width">
|
||||
<q-item v-for="(marker, i) in model" :key="marker">
|
||||
<q-item-section side>
|
||||
<q-icon :name="getIcon(marker.type)"></q-icon>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="i > 0"
|
||||
text-color="primary"
|
||||
size="16px"
|
||||
flat dense round icon="arrow_upward"
|
||||
@click="swapMarkers(i, i-1)"
|
||||
/>
|
||||
<q-btn
|
||||
v-if="i < model.length - 1"
|
||||
text-color="primary"
|
||||
flat dense round icon="`arrow_downward`"
|
||||
size="16px"
|
||||
@click="swapMarkers(i, i+1)"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<div class="cursor-pointer" v-if="editableNameTypes.includes(marker.type)">
|
||||
{{ marker.name }}
|
||||
<q-icon name="edit"/>
|
||||
<q-popup-edit v-model="marker.name" v-slot="scope">
|
||||
<q-input v-model="scope.value" autofocus @keyup.enter="scope.set">
|
||||
<template #append>
|
||||
<q-icon name="edit"/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-popup-edit>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ marker.name }}
|
||||
</template>
|
||||
</q-item-section>
|
||||
<q-item-section side class="text-right">
|
||||
<q-btn size="12px" @click="removeMarker(i)" flat dense round color="red" icon="delete"/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-scroll-area>
|
||||
|
||||
<FlightPlanPlaceInput
|
||||
:options="allPlacesOptions"
|
||||
@select="handlePlaceSelect"
|
||||
/>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import {computed, ref, watch} from "vue";
|
||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
import {useFlightPlanCalculations, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isFullscreen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
insertPosition: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["open", "change-insert-position", "fullscreen-toggle"])
|
||||
const {getDuration, getTotalDistance, getTotalDuration} = useFlightPlanCalculations();
|
||||
const totalDistance = computed(() => getTotalDistance(props.flightPlan));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list bordered class="q-ml-md-md q-pt-md">
|
||||
<q-item class="mobile-hide">
|
||||
<q-item-section>
|
||||
<div style="display: flex; justify-content: space-between">
|
||||
<q-btn
|
||||
@click="emits('change-insert-position', 0)"
|
||||
icon="add"
|
||||
:color="insertPosition === 0 ? 'primary' : 'grey'"
|
||||
size="sm"
|
||||
>
|
||||
<template v-if="insertPosition === 0">Přidávám na začátek</template>
|
||||
</q-btn>
|
||||
|
||||
<q-btn
|
||||
@click='emits("fullscreen-toggle")'
|
||||
size="sm"
|
||||
round
|
||||
:icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||
/>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-scroll-area class="mobile-hide" style="height: 55vh">
|
||||
<template v-for="(segment, i) in props.flightPlan.track" :key="segment">
|
||||
<q-item dense>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ segment.from.name }}</q-item-label>
|
||||
<q-item-label caption class="q-pa-sm q-ml-sm" style="border-left: solid 1px" v-if="segment.distance">
|
||||
|
||||
{{ segment.bearing }}°,
|
||||
{{ segment.distance }} km,
|
||||
{{ minutesToHumanReadable(getDuration(segment.distance, props.flightPlan.plannedSpeed)) }}
|
||||
|
||||
<q-btn
|
||||
@click="emits('change-insert-position', i+1)"
|
||||
icon="add"
|
||||
size="xs"
|
||||
round
|
||||
class="q-ml-sm"
|
||||
:color="insertPosition === i+1 ? 'primary' : 'grey'"
|
||||
/>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item dense v-if="segment.to && i === props.flightPlan.track.length - 1">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{{ segment.to.name }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
<q-item>
|
||||
<q-item-section v-if="flightPlan.track.length">
|
||||
<div>
|
||||
<q-btn
|
||||
@click="emits('change-insert-position', flightPlan.track.length + 1)"
|
||||
icon="add"
|
||||
size="sm"
|
||||
:color="insertPosition === flightPlan.track.length + 1? 'primary' : 'grey'"
|
||||
>
|
||||
<template v-if="insertPosition === flightPlan.track.length + 1">Přidávám na konec</template>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-scroll-area>
|
||||
|
||||
<template v-if="totalDistance">
|
||||
<q-item class="mobile-hide">
|
||||
<q-item-section>
|
||||
<q-separator/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
Celková vzdálenost: {{ totalDistance }} km
|
||||
<br>
|
||||
Plánovaná doba letu: {{ minutesToHumanReadable(getTotalDuration(props.flightPlan)) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-btn @click="emits('open')" color="secondary">
|
||||
{{ totalDistance ? 'Upravit trasu' : 'Sestavit trasu' }}
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
import {useQuasar} from "quasar";
|
||||
import {useUnitsConvertors} from "src/composables/units";
|
||||
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
|
||||
import {formatDatetime} from "src/composables/datetimeUtils";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: 'Místo',
|
||||
align: "left",
|
||||
name: 'place',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Teplota',
|
||||
name: 'temperature',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Vítr',
|
||||
name: 'wind',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Oblačnost',
|
||||
name: 'clouds',
|
||||
},
|
||||
];
|
||||
|
||||
const {screen} = useQuasar();
|
||||
const {getMarkersFrom} = useFlightPlanUtils();
|
||||
const {kmhToKnots, ftToM} = useUnitsConvertors();
|
||||
|
||||
const getCloudsBase = (weatherInfo) => {
|
||||
const tempDiff = weatherInfo.temperatureSurface - weatherInfo.dewpointSurface;
|
||||
const cloudLevelInFt = tempDiff / 2.5 * 1000;
|
||||
|
||||
return Math.round(ftToM(cloudLevelInFt));
|
||||
}
|
||||
|
||||
const markers = getMarkersFrom(props.flightPlan);
|
||||
const markersWithWeather = markers.filter(marker => !!marker.weatherInfo);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-table
|
||||
:grid="screen.xs"
|
||||
:dense="screen.lt.md"
|
||||
flat bordered
|
||||
hide-bottom
|
||||
:title="flightPlan.name"
|
||||
:rows="markersWithWeather"
|
||||
:columns="columns"
|
||||
:rows-per-page-options="[0]"
|
||||
>
|
||||
<template v-slot:body-cell-place="props">
|
||||
<q-td :props="props">
|
||||
<strong>
|
||||
{{ props.row.name }}
|
||||
</strong>
|
||||
<br>
|
||||
<small>
|
||||
{{ formatDatetime(props.row.weatherInfo.datetime) }}
|
||||
</small>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-temperature="props">
|
||||
<q-td :props="props">
|
||||
{{ props.row.weatherInfo.temperatureSurface }}° C
|
||||
<br>
|
||||
<small>
|
||||
Rosný bod {{ props.row.weatherInfo.dewpointSurface }}° C
|
||||
</small>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-wind="props">
|
||||
<q-td :props="props">
|
||||
<q-icon name="navigation" :style="`transform: rotate(${props.row.weatherInfo.windDirectionSurface}deg)`"/>
|
||||
{{ props.row.weatherInfo.windDirectionSurface }}°
|
||||
<br>
|
||||
{{ kmhToKnots(props.row.weatherInfo.windSpeedSurface) }} kt
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-clouds="props">
|
||||
<q-td :props="props">
|
||||
<CloudCoverIcon :cloudcover="props.row.weatherInfo.cloudcoverTotal"/>
|
||||
<br>
|
||||
celkové pokrytí oblačností {{ props.row.weatherInfo.cloudcoverTotal }} %
|
||||
<br>
|
||||
nízké mraky {{ props.row.weatherInfo.cloudcoverLow }} %
|
||||
|
||||
<template v-if="props.row.weatherInfo.cloudcoverLow > 5">
|
||||
<br>
|
||||
odhadovaná výška základen: {{ getCloudsBase(props.row.weatherInfo) }} m
|
||||
</template>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
|
||||
</q-table>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import {formatDatetime} from "src/composables/datetimeUtils";
|
||||
import {useInfiniteScroll} from "src/composables/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
|
||||
...useInfiniteScroll().propsDef
|
||||
});
|
||||
|
||||
const emits = defineEmits([...useInfiniteScroll().emitsDef])
|
||||
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-infinite-scroll
|
||||
:disable="!hasMoreData"
|
||||
@load="emitLoadMore"
|
||||
:offset="250"
|
||||
>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
clickable
|
||||
:to="{name: 'editFlightPlan', params: {id: flightPlan.id }}"
|
||||
v-ripple
|
||||
v-for="flightPlan of items" :key="flightPlan">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="flight" color="primary" text-color="white"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{{ flightPlan.name || "Nepojmenovaný plán" }}
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="flightPlan.plannedTakeoffDatetime">
|
||||
{{ formatDatetime(flightPlan.plannedTakeoffDatetime) }}
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="flightPlan.aircraft">
|
||||
{{ flightPlan.aircraft.callSign }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-infinite-scroll>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
import {computed} from "vue";
|
||||
|
||||
|
||||
|
||||
const markersCenter = computed(() => {
|
||||
if (markers.value.length === 0) return null;
|
||||
|
||||
return {
|
||||
gpsLatitude: markers.value.map(m => m.gpsLatitude).reduce((a, b) => a + b) / markers.value.length,
|
||||
gpsLongitude: markers.value.map(m => m.gpsLongitude).reduce((a, b) => a + b) / markers.value.length,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const calcAngleFromCenter = (pointA, pointB) => {
|
||||
if (!markersCenter.value) return 0;
|
||||
|
||||
const u = vector(markersCenter.value, pointA);
|
||||
const v = vector(markersCenter.value, pointB);
|
||||
|
||||
return Math.acos(
|
||||
(u[0] * v[0] + u[1] * v[1])
|
||||
/
|
||||
(vectorSize(u) * vectorSize(v))
|
||||
)
|
||||
}
|
||||
|
||||
let targetPosition = null;
|
||||
let minAngleDiff = Infinity;
|
||||
let mapPoints = 1;
|
||||
|
||||
const handleMapClickNefunkcni = (position) => {
|
||||
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng};
|
||||
|
||||
const clickAngle = calcAngleFromCenter(clickPoint, markers.value[0]);
|
||||
|
||||
for (let i = 1; i < markers.value.length - 1; i++) {
|
||||
const marker = markers.value[i];
|
||||
const angle = calcAngleFromCenter(marker, markers.value[0]);
|
||||
|
||||
const angleDiff = clickAngle - angle;
|
||||
console.log("click", i, marker.name, angleDiff);
|
||||
if (angleDiff < minAngleDiff) {
|
||||
console.log(" update => ", minAngleDiff, " > ", Math.abs(angleDiff), marker.name, " -> ", markers.value[i + 1].name);
|
||||
minAngleDiff = angleDiff
|
||||
targetPosition = Math.max(1, angleDiff < 0 ? i - 1 : i + 1);
|
||||
}
|
||||
|
||||
// console.log(marker.name, angle);
|
||||
}
|
||||
|
||||
console.log("click", clickAngle);
|
||||
console.log("insert at position", targetPosition);
|
||||
console.log("---------------")
|
||||
markers.value.splice(targetPosition, 0, {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
|
||||
mapPoints++;
|
||||
}
|
||||
|
||||
const handleMapClickNefunkcni2 = (position) => {
|
||||
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng}
|
||||
|
||||
const markersToSort = markers.value.slice(1, -1);
|
||||
markersToSort.push({name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
|
||||
|
||||
markersToSort.sort(
|
||||
(a, b) => calcAngleFromCenter(a, markers.value[0]) - calcAngleFromCenter(b, markers.value[0])
|
||||
);
|
||||
|
||||
for (const marker of markersToSort) {
|
||||
console.log(marker.name, calcAngleFromCenter(marker, markers.value[0]));
|
||||
}
|
||||
|
||||
markers.value = [markers.value[0], ...markersToSort, markers.value[markers.value.length - 1]];
|
||||
mapPoints++;
|
||||
}
|
||||
|
||||
const calcTotalDistance = (points) => {
|
||||
let distance = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
distance += vectorDistance(points[i-1], points[i])
|
||||
}
|
||||
return distance
|
||||
}
|
||||
|
||||
let minDistance = Infinity;
|
||||
const handleMapClick = (position) => {
|
||||
const clickPoint = {gpsLatitude: position.lat, gpsLongitude: position.lng};
|
||||
const newMarker = {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint}
|
||||
markers.value.push(newMarker)
|
||||
|
||||
for (let i = 1; i < markers.value.length - 1; i++) {
|
||||
console.log(markers.value[i], clickPoint);
|
||||
const dist = vectorDistance(markers.value[i], clickPoint);
|
||||
|
||||
if (dist < minDistance) {
|
||||
minDistance = dist;
|
||||
targetPosition = i;
|
||||
}
|
||||
}
|
||||
|
||||
targetPosition = Math.min(markers.value.length - 1, Math.max(1, targetPosition));
|
||||
markers.value.splice(targetPosition, 0, {name: `Bod v mape ${mapPoints}`, type: "mapPoint", ...clickPoint});
|
||||
mapPoints++;
|
||||
}
|
||||
@@ -1,17 +1,37 @@
|
||||
<script setup>
|
||||
import {flight} from "src/composables/objects/flight";
|
||||
import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
import WeatherInfo from "components/weather/WeatherInfo.vue";
|
||||
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showCopilots: {
|
||||
type: Boolean,
|
||||
copilotDetailRoute: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: false
|
||||
default: null
|
||||
},
|
||||
aircraftDetailRoute: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
poiDetailRoute: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
eventDetailRoute: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
loggedUser: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -23,7 +43,15 @@ const props = defineProps({
|
||||
<q-icon name="flight"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ flight.aircraft.callSign }}
|
||||
<router-link
|
||||
:to="{...aircraftDetailRoute, params: {id: flight.aircraft.id, callSign: flight.aircraft.callSign}}"
|
||||
v-if="flight.aircraft.isPublic"
|
||||
>
|
||||
{{ flight.aircraft.callSign }}
|
||||
</router-link>
|
||||
<template v-else>
|
||||
{{ flight.aircraft.callSign }}
|
||||
</template>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
@@ -44,7 +72,9 @@ const props = defineProps({
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<div>
|
||||
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}),
|
||||
<template v-if="flight.takeoffAirport">
|
||||
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}),
|
||||
</template>
|
||||
čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }}
|
||||
|
||||
<WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/>
|
||||
@@ -52,18 +82,28 @@ const props = defineProps({
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item v-for="poi in flight.track" :key="poi.id">
|
||||
<q-item v-for="poi in flight.turnPoints" :key="poi.id">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="place" v-if="poi.pointOfInterest" />
|
||||
<q-icon name="local_airport" v-if="poi.airport" />
|
||||
<q-icon name="place" v-if="poi.pointOfInterest"/>
|
||||
<q-icon name="local_airport" v-if="poi.airport"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<template v-if="poi.pointOfInterest">
|
||||
{{ poi.pointOfInterest.name }}
|
||||
<router-link
|
||||
:to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, slug: poi.pointOfInterest.urlSlug, id: poi.pointOfInterest.id}}"
|
||||
v-if="poi.pointOfInterest.isPublic"
|
||||
>
|
||||
{{ poi.pointOfInterest.name }}
|
||||
</router-link>
|
||||
<template v-else>{{ poi.pointOfInterest.name }}</template>
|
||||
</template>
|
||||
<span v-if="poi.airport">
|
||||
<strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}),
|
||||
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }}
|
||||
<template v-if="poi.landingDuration > 0">
|
||||
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }}
|
||||
</template>
|
||||
<template v-else>touch and go</template>
|
||||
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
@@ -74,7 +114,9 @@ const props = defineProps({
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<div>
|
||||
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}),
|
||||
<template v-if="flight.landingAirport">
|
||||
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}),
|
||||
</template>
|
||||
čas přistání: {{ formatDatetime(flight.landingDatetime, FORMAT_TIME) }}
|
||||
<WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/>
|
||||
</div>
|
||||
@@ -91,6 +133,18 @@ const props = defineProps({
|
||||
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item v-if="flight.event && (loggedUser || flight.event.isPublic)">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="event"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<router-link :to="eventDetailRoute">
|
||||
{{ flight.event.name }}
|
||||
</router-link>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item v-if="flight.copilots?.length > 0">
|
||||
<q-list>
|
||||
<q-item v-for="copilot in flight.copilots" :key="copilot.id">
|
||||
@@ -98,11 +152,32 @@ const props = defineProps({
|
||||
<q-icon name="people"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<router-link :to="{name: 'copilotDetail', params: {id: copilot.id}}" v-if="showCopilots">
|
||||
{{ copilot.name }}
|
||||
</router-link>
|
||||
<template v-if="copilot.isPublic">
|
||||
<div>
|
||||
<router-link
|
||||
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
|
||||
>
|
||||
{{ copilot.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else> {{ copilot.name }}</template>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<CopyLinkButton
|
||||
v-if="loggedUser && copilot.token && copilot.isPublic"
|
||||
:route='{
|
||||
name: "publicCopilotUploadPhoto",
|
||||
params: {flightSlug: flight.urlSlug, username: loggedUser.publicUsername},
|
||||
query: {token: copilot.token}
|
||||
}'
|
||||
icon="cloud_upload"
|
||||
title="Získat odkaz na nahrání fotek od kopilota"
|
||||
label=""
|
||||
round
|
||||
class="q-mx-3"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-item>
|
||||
|
||||
@@ -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,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>
|
||||
@@ -1,40 +1,56 @@
|
||||
<script setup>
|
||||
import {flight} from "src/composables/objects/flight";
|
||||
import MapWithMarkers from "components/map/MapWithMarkers.vue";
|
||||
import {computed, ref} from "vue";
|
||||
import {computed, reactive, ref} from "vue";
|
||||
import {Line} from "vue-chartjs"
|
||||
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
photos: {
|
||||
type: Array,
|
||||
required: false,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const mapPhotos = computed(() => flight.value.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
|
||||
const poisWithGPS = computed(() => flight.value.track.map(poi => poi.pointOfInterest || poi.airport));
|
||||
const mapPhotos = computed(() => props.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
|
||||
const poisWithGPS = computed(() => props.flight.turnPoints.map(poi => poi.pointOfInterest || poi.airport));
|
||||
|
||||
const mapMarkers = [flight.value.takeoffAirport].concat(poisWithGPS.value).concat([flight.value.landingAirport]);
|
||||
const mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]);
|
||||
const pointsOfInterests = computed(() => mapMarkers
|
||||
.filter(marker => marker.gpsLatitude && marker.gpsLongitude)
|
||||
.filter(marker => marker && marker.gpsLatitude && marker.gpsLongitude)
|
||||
.map(
|
||||
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
|
||||
));
|
||||
|
||||
const gpxTrack = flight.value.gpxTrack;
|
||||
// Tuu bylo reactive()
|
||||
const flightTrack = props.flight.track ? props.flight.track.points : null;
|
||||
|
||||
const displayMap = computed(() => {
|
||||
return (
|
||||
!!gpxTrack ||
|
||||
!!flightTrack ||
|
||||
mapPhotos.value.length > 0 ||
|
||||
poisWithGPS.value.length > 0 ||
|
||||
flight.value.landingAirport.id !== flight.value.takeoffAirport.id
|
||||
props.flight.landingAirport.id !== props.flight.takeoffAirport.id
|
||||
)
|
||||
});
|
||||
|
||||
let chartSpeedData, chartAltitudeData = null;
|
||||
if (gpxTrack) {
|
||||
const chartLabels = gpxTrack.time.map(t => {
|
||||
if (flightTrack) {
|
||||
|
||||
const timestamp = flightTrack.map(i => i.timestamp);
|
||||
const speed = flightTrack.map(i => i.speed);
|
||||
const altitude = flightTrack.map(i => i.altitude);
|
||||
const terrainElevation = flightTrack.map(i => i.terrainElevation || 0);
|
||||
|
||||
const chartLabels = timestamp.map(t => {
|
||||
const datetime = new Date(t);
|
||||
return datetime.getHours() + ":" + datetime.getMinutes();
|
||||
});
|
||||
@@ -44,7 +60,7 @@ if (gpxTrack) {
|
||||
backgroundColor: "#38bd3e",
|
||||
borderColor: "#38bd3e",
|
||||
label: "Rychlost (km/h)",
|
||||
data: gpxTrack.speed,
|
||||
data: speed,
|
||||
pointRadius: 1,
|
||||
}]
|
||||
};
|
||||
@@ -55,18 +71,18 @@ if (gpxTrack) {
|
||||
backgroundColor: "#9ae3ff",
|
||||
borderColor: "#9ae3ff",
|
||||
label: "Nadmořská výška letu (m)",
|
||||
data: gpxTrack.altitude,
|
||||
data: altitude,
|
||||
pointRadius: 1,
|
||||
}]
|
||||
};
|
||||
|
||||
if (gpxTrack.terrainElevation.length > 0) {
|
||||
if (terrainElevation.length > 0) {
|
||||
chartAltitudeData.datasets.push(
|
||||
{
|
||||
backgroundColor: "rgba(148,94,40,0.7)",
|
||||
borderColor: "#945e28",
|
||||
label: "Nadmořská výška země (m)",
|
||||
data: gpxTrack.terrainElevation,
|
||||
data: terrainElevation,
|
||||
fill: true,
|
||||
pointRadius: 1,
|
||||
});
|
||||
@@ -82,7 +98,7 @@ const drawCurrentPositionMarker = (e) => {
|
||||
return;
|
||||
}
|
||||
const idx = res[0].index;
|
||||
currentPositionMarker.value = flight.value.gpxTrack.coordinates[idx];
|
||||
currentPositionMarker.value = props.flight.track.points[idx].coordinates;
|
||||
}
|
||||
|
||||
const chartOptions = {
|
||||
@@ -95,19 +111,38 @@ const chartOptions = {
|
||||
onClick: drawCurrentPositionMarker,
|
||||
onHover: drawCurrentPositionMarker,
|
||||
}
|
||||
|
||||
const {getMarkersFrom} = useFlightPlanUtils();
|
||||
const showPlanTrack = ref(true);
|
||||
const tracks = computed(() => {
|
||||
const items = [];
|
||||
if (props.flight.track) {
|
||||
items.push(props.flight.track.points.map(m => m.coordinates))
|
||||
}
|
||||
if (showPlanTrack.value && props.flightPlan) {
|
||||
items.push(getMarkersFrom(props.flightPlan).map(m => ({lat: m.gpsLatitude, lng: m.gpsLongitude})));
|
||||
}
|
||||
return items;
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MapWithMarkers
|
||||
v-if="displayMap"
|
||||
style="width: 100%; height: 500px;"
|
||||
:gpx-track="flight.gpxTrack?.coordinates || []"
|
||||
:markers="pointsOfInterests"
|
||||
:highlight-position="currentPositionMarker"
|
||||
:photos="mapPhotos"
|
||||
/>
|
||||
<q-no-ssr>
|
||||
<q-intersection>
|
||||
<q-checkbox v-model="showPlanTrack" label="Zobrazovat plán?" v-if="flightPlan" />
|
||||
|
||||
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
|
||||
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
|
||||
<MapWithMarkers
|
||||
v-if="displayMap"
|
||||
style="width: 100%; height: 500px;"
|
||||
:tracks="tracks"
|
||||
:markers="pointsOfInterests"
|
||||
:highlight-position="currentPositionMarker"
|
||||
:photos="mapPhotos"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
|
||||
<Line v-if="flightTrack" :data="chartSpeedData" :options="chartOptions"/>
|
||||
<Line v-if="flightTrack" :data="chartAltitudeData" :options="chartOptions"/>
|
||||
</q-intersection>
|
||||
</q-no-ssr>
|
||||
</template>
|
||||
|
||||
@@ -1,64 +1,104 @@
|
||||
<script setup>
|
||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
import {flightName} from "src/composables/objects/flight";
|
||||
import {flightName} from "src/composables/flight";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
import {useInfiniteScroll} from "src/composables/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
routeDetail: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({name: 'flightDetail', params: {}})
|
||||
},
|
||||
displayVisibilityInfo: {
|
||||
public: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
required: true
|
||||
},
|
||||
...useInfiniteScroll().propsDef
|
||||
});
|
||||
|
||||
const emits = defineEmits([...useInfiniteScroll().emitsDef])
|
||||
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||
|
||||
const routeDetail = (flight) => {
|
||||
if (props.public)
|
||||
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
|
||||
else
|
||||
return {name: "flightDetail", params: {id: flight.id}}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
v-for="flight of items" :key="flight"
|
||||
ripple
|
||||
clickable
|
||||
:to="{name: routeDetail.name, params: {...routeDetail.params, id: flight.id}}"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<AvatarFromPhoto :size="120" v-if="flight.coverPhoto" :src="flight.coverPhoto.thumbnailUrl"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
|
||||
<q-infinite-scroll
|
||||
:disable="!hasMoreData"
|
||||
@load="emitLoadMore"
|
||||
:offset="250"
|
||||
>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
v-for="flight of items" :key="flight"
|
||||
ripple
|
||||
clickable
|
||||
:to="routeDetail(flight)"
|
||||
>
|
||||
<q-item-section avatar class="gt-xs">
|
||||
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
|
||||
<q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-img v-if="flight.titlePhoto" class="lt-sm" :src="flight.titlePhoto.thumbnailUrl"/>
|
||||
|
||||
<div class="text-secondary" v-if="flight.copilot">
|
||||
<q-icon name="person"/>
|
||||
{{ flight.copilot.name }}
|
||||
</div>
|
||||
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
|
||||
|
||||
<div class="text-secondary">
|
||||
<q-icon name="schedule"/>
|
||||
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<q-icon name="flight"/>
|
||||
{{ flight.aircraft.callSign }}
|
||||
</div>
|
||||
<div class="flex justify-between justify-sm-start list-secondary-info">
|
||||
<div class="text-secondary" v-if="props.public">
|
||||
<q-icon name="support_agent"/>
|
||||
{{ flight.pilot.publicUsername }}
|
||||
</div>
|
||||
|
||||
<div class="text-secondary" v-if="flight.event">
|
||||
<q-icon name="event" />
|
||||
{{ flight.event.name }}
|
||||
</div>
|
||||
</q-item-section>
|
||||
<div class="text-secondary" v-if="flight.copilots">
|
||||
<q-icon name="person"/>
|
||||
{{ flight.pilot.name }}
|
||||
{{ flight.copilots }}
|
||||
</div>
|
||||
|
||||
<q-item-section side top v-if="flight.isPublic && displayVisibilityInfo">
|
||||
<q-icon name="visibility"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div class="text-secondary">
|
||||
<q-icon name="schedule"/>
|
||||
{{ minutesToHumanReadable(flight.durationMinCalculated) }}
|
||||
</div>
|
||||
<div class="text-secondary">
|
||||
<q-icon name="flight"/>
|
||||
{{ flight.aircraft.callSign }}
|
||||
</div>
|
||||
|
||||
<div class="text-secondary" v-if="flight.event">
|
||||
<q-icon name="event"/>
|
||||
{{ flight.event.name }}
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side top v-if="!props.public">
|
||||
<!-- Zobraz jen v adminu -->
|
||||
<q-icon name="visibility" v-if="flight.isPublic"/>
|
||||
|
||||
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
|
||||
<q-tooltip>
|
||||
Let je sice publikovaný, nemá ale nastavenou URL adresu!
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<template v-slot:loading>
|
||||
<div class="row justify-center q-my-md">
|
||||
<q-spinner-dots color="primary" size="40px"/>
|
||||
</div>
|
||||
</template>
|
||||
</q-infinite-scroll>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.list-secondary-info div {
|
||||
padding: 0 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import {ref} from "vue";
|
||||
import FlightHeaderInfo from "components/flights/FlightHeaderInfo.vue";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const slide = ref(0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-blue-1 q-py-lg">
|
||||
<h3 class="text-center">Poslední lety</h3>
|
||||
|
||||
<q-carousel
|
||||
v-model="slide"
|
||||
swipeable
|
||||
animated
|
||||
control-color="primary"
|
||||
navigation
|
||||
padding
|
||||
arrows
|
||||
height="500px"
|
||||
class="bg-blue-1 text-white"
|
||||
>
|
||||
<q-carousel-slide :name="idx" v-for="(flight, idx) in items" :key="idx" class="column no-wrap flex-center text-center">
|
||||
<q-img
|
||||
:src="flight.titlePhoto.url"
|
||||
fit="cover"
|
||||
ratio="16/9"
|
||||
style="max-width: 1000px;"
|
||||
height="400px"
|
||||
v-if="flight.titlePhoto"
|
||||
>
|
||||
<div class="absolute-full">
|
||||
<FlightHeaderInfo
|
||||
:route-detail="{name: 'publicFlightDetailNew', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
|
||||
:pilot="flight.pilot"
|
||||
:flight="flight"
|
||||
>
|
||||
|
||||
<div class="absolute-bottom-right">
|
||||
<router-link
|
||||
class="q-pa-lg"
|
||||
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
|
||||
>
|
||||
{{ flight.pilot.publicUsername }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
</FlightHeaderInfo>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,7 +6,6 @@ import DatetimePicker from "components/inputs/DatetimePicker.vue";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import {isRequired} from "src/composables/form/rules";
|
||||
import {isRequestPending} from "src/composables/form/props";
|
||||
|
||||
const props = defineProps({
|
||||
aircrafts: {
|
||||
@@ -21,7 +20,11 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
isRequestPending,
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
|
||||
@@ -29,8 +32,13 @@ const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
|
||||
const airports = computed(() => props.airports.map(a => ({id: a.id, name: airportName(a)})));
|
||||
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
|
||||
|
||||
|
||||
const manualInput = ref(false);
|
||||
const allowGPXUpload = ref(true);
|
||||
|
||||
const formData = reactive({
|
||||
aircraft: null,
|
||||
gpxTrackFile: null,
|
||||
takeoffAirport: null,
|
||||
landingAirport: null,
|
||||
takeoffDatetime: null,
|
||||
@@ -49,8 +57,14 @@ watch(() => props.flight, (newVal) => {
|
||||
if (newVal) {
|
||||
setDefaultData(formData, newVal);
|
||||
formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign};
|
||||
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
|
||||
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)}
|
||||
|
||||
if (newVal.takeoffAirport)
|
||||
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
|
||||
|
||||
if (newVal.landingAirport)
|
||||
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)}
|
||||
allowGPXUpload.value = false;
|
||||
manualInput.value = true;
|
||||
}
|
||||
}, {immediate: true});
|
||||
watch(() => formData.takeoffDatetime, () => {
|
||||
@@ -64,79 +78,75 @@ watch(() => formData.takeoffAirport, () => {
|
||||
formData.landingAirport = {...formData.takeoffAirport};
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-card class="q-my-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Vyber letadlo</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<ComboboxInput
|
||||
:rules="rules.aircraft"
|
||||
v-bind="commonProps"
|
||||
:options="aircrafts"
|
||||
v-model="formData.aircraft"
|
||||
label="Letadlo"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<ComboboxInput
|
||||
:rules="rules.aircraft"
|
||||
v-bind="commonProps"
|
||||
:options="aircrafts"
|
||||
v-model="formData.aircraft"
|
||||
label="Letadlo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<q-card class="q-mr-md-sm q-my-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Vzlet</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<DatetimePicker
|
||||
v-model="formData.takeoffDatetime"
|
||||
:rules="rules.takeoffDatetime"
|
||||
label="Datum a čas vzletu: "
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<ComboboxInput
|
||||
:options="airports"
|
||||
:rules="rules.takeoffAirport"
|
||||
v-model="formData.takeoffAirport"
|
||||
label="Letiště vzletu"
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="row" v-if="!manualInput">
|
||||
<div class="col">
|
||||
<div class="text-h6">Mám trasu v GPX, chci importovat data</div>
|
||||
<q-file v-bind="commonProps" v-model="formData.gpxTrackFile" label="Trasa v GPX" hint="Z trasy se načtou letiště a časy vzletu a přistání. Tyto hodnoty je možné později upravit."/>
|
||||
|
||||
<div class="col col-12 col-md-6">
|
||||
|
||||
<q-card class="q-ml-md-sm q-my-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Přistání</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<DatetimePicker
|
||||
v-model="formData.landingDatetime"
|
||||
:min-date="formData.takeoffDatetime"
|
||||
:rules="rules.landingDatetime"
|
||||
label="Datum a čas přistání"
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<ComboboxInput
|
||||
:options="airports"
|
||||
v-model="formData.landingAirport"
|
||||
label="Letiště přistání"
|
||||
v-bind="commonProps"
|
||||
:rules="rules.landingAirport"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div class="text-center">
|
||||
<q-btn @click="manualInput = true" rounded flat>Zadat informace ručně</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="manualInput">
|
||||
<div class="col col-12 col-md-6 q-pr-md-sm">
|
||||
<div class="text-h6">Vzlet</div>
|
||||
<DatetimePicker
|
||||
v-model="formData.takeoffDatetime"
|
||||
:rules="rules.takeoffDatetime"
|
||||
label="Datum a čas vzletu: "
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<ComboboxInput
|
||||
:options="airports"
|
||||
:rules="rules.takeoffAirport"
|
||||
v-model="formData.takeoffAirport"
|
||||
label="Letiště vzletu"
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col col-12 col-md-6 q-pl-md-sm">
|
||||
<div class="text-h6">Přistání</div>
|
||||
<DatetimePicker
|
||||
v-model="formData.landingDatetime"
|
||||
:min-date="formData.takeoffDatetime"
|
||||
:rules="rules.landingDatetime"
|
||||
label="Datum a čas přistání"
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<ComboboxInput
|
||||
:options="airports"
|
||||
v-model="formData.landingAirport"
|
||||
label="Letiště přistání"
|
||||
v-bind="commonProps"
|
||||
:rules="rules.landingAirport"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 text-center" v-if="allowGPXUpload">
|
||||
<q-btn @click="manualInput = false" rounded flat>Mám GPX, načíst informace z trasy</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -19,43 +19,11 @@ const formData = reactive({
|
||||
copilots: [],
|
||||
})
|
||||
|
||||
const solo = ref(false);
|
||||
watch(() => solo.value, (newVal) => {
|
||||
if (!newVal) {
|
||||
formData.copilots = [];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
watch(() => props.flight, (newVal) => {
|
||||
if (newVal) {
|
||||
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
|
||||
solo.value = formData.copilots.length === 0;
|
||||
}
|
||||
}, {deep: true, immediate: true});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData">
|
||||
<q-btn-toggle
|
||||
spread
|
||||
no-caps
|
||||
v-model="solo"
|
||||
toggle-color="primary"
|
||||
:options="[{label: 'Solo', value: true}, {label: 'S někým', value: false}]"
|
||||
/>
|
||||
|
||||
<div v-if="!solo">
|
||||
<ComboboxInput
|
||||
v-for="i in flight.aircraft.seats - 1"
|
||||
:key="i"
|
||||
v-bind="commonProps"
|
||||
:options="copilots"
|
||||
v-model="formData.copilots[i-1]"
|
||||
:clearable="true"
|
||||
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import {onMounted, reactive, ref, watch} from "vue";
|
||||
import {reactive, ref, watch} from "vue";
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {setDefaultData} from "src/composables/form/formHandling";
|
||||
import RichTextEditor from "components/inputs/RichTextEditor.vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
import {isRequestPending} from "src/composables/objects/flight";
|
||||
import SlugInput from "components/inputs/SlugInput.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useLoggedUserStore} from "stores/LoggedUser";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
@@ -15,15 +18,31 @@ const props = defineProps({
|
||||
events: {
|
||||
type: Array,
|
||||
required: false,
|
||||
},
|
||||
copilots: {
|
||||
type: Array,
|
||||
required: false,
|
||||
},
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
const formData = reactive({
|
||||
name: "",
|
||||
event: null,
|
||||
description: ""
|
||||
description: "",
|
||||
debriefingNotes: "",
|
||||
urlSlug: "",
|
||||
isPublic: null,
|
||||
copilots: []
|
||||
})
|
||||
|
||||
|
||||
const loggedUser = storeToRefs(useLoggedUserStore()).user;
|
||||
|
||||
watch(() => props.flight, (newVal) => {
|
||||
if (newVal) {
|
||||
setDefaultData(formData, newVal);
|
||||
@@ -34,12 +53,88 @@ watch(() => props.flight, (newVal) => {
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
const solo = ref(false);
|
||||
watch(() => solo.value, (newVal) => {
|
||||
if (!newVal) {
|
||||
formData.copilots = [];
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.flight, (newVal) => {
|
||||
if (newVal) {
|
||||
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
|
||||
solo.value = formData.copilots.length === 0;
|
||||
}
|
||||
}, {deep: true, immediate: true});
|
||||
|
||||
const descriptionTab = ref("description");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData" :is-request-pending="isRequestPending">
|
||||
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
|
||||
<ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/>
|
||||
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
|
||||
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
|
||||
<template v-if="loggedUser?.publicUsername">
|
||||
<SlugInput
|
||||
v-if="formData.isPublic"
|
||||
v-bind="commonProps"
|
||||
v-model="formData.urlSlug"
|
||||
label="URL"
|
||||
:url="`/${loggedUser.publicUsername}/`"
|
||||
:slug-source="formData.name"
|
||||
/>
|
||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
Letel jsi
|
||||
<q-radio
|
||||
v-model="solo"
|
||||
:val="true"
|
||||
label="Sólo"
|
||||
/>
|
||||
<q-radio
|
||||
v-model="solo"
|
||||
:val="false"
|
||||
label="S někým"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!solo">
|
||||
<ComboboxInput
|
||||
v-for="i in flight.aircraft.seats - 1"
|
||||
:key="i"
|
||||
v-bind="commonProps"
|
||||
:options="copilots"
|
||||
v-model="formData.copilots[i-1]"
|
||||
:clearable="true"
|
||||
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-tabs v-model="descriptionTab" active-color="primary">
|
||||
<q-tab name="description">Popis</q-tab>
|
||||
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
|
||||
</q-tabs>
|
||||
<q-tab-panels v-model="descriptionTab">
|
||||
<q-tab-panel name="description">
|
||||
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="debriefing">
|
||||
<BasicInfoBanner>
|
||||
Debriefing by měl obsahovat minimálně následující body:
|
||||
<ul>
|
||||
<li><strong>Shrnutí letu:</strong> Trasa, čas a splnění cílů.</li>
|
||||
<li><strong>Výkon:</strong> Navigace, reakce na podmínky, zvládnutí letu.</li>
|
||||
<li><strong>Stav letadla:</strong> Funkčnost přístrojů, případné problémy.</li>
|
||||
<li><strong>Bezpečnostní postupy:</strong> Dodržení bezpečnostních pravidel, řešení neočekávaných situací.</li>
|
||||
<li><strong>Zpětná vazba:</strong> Pocity z letu, spokojenost pasažéra, návrhy na zlepšení.</li>
|
||||
</ul>
|
||||
</BasicInfoBanner>
|
||||
|
||||
<RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {reactive} from "vue";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
@@ -17,6 +16,10 @@ const props = defineProps({
|
||||
const formData = reactive({
|
||||
photos: []
|
||||
});
|
||||
|
||||
const inlinePreview = (photo) => {
|
||||
return URL.createObjectURL(photo);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,6 +29,18 @@ const formData = reactive({
|
||||
align: "left"
|
||||
}'>
|
||||
|
||||
<div class="flex flex-center">
|
||||
<div v-for="photo in formData.photos" :key="photo.name" style="width: 70px" class="q-ma-sm">
|
||||
<q-img
|
||||
:src="inlinePreview(photo)"
|
||||
fit="fill"
|
||||
>
|
||||
<div class="absolute-top absolute-bottom text-center" v-if="isRequestPending">
|
||||
<q-spinner/>
|
||||
</div>
|
||||
</q-img>
|
||||
</div>
|
||||
</div>
|
||||
<q-file
|
||||
class="q-my-lg"
|
||||
outlined
|
||||
|
||||
@@ -25,11 +25,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const steps = [
|
||||
{name: "basic", icon: "flight_takeoff", label: "Základní informace"},
|
||||
{name: "copilot", icon: "person_add", label: "Spolucestující"},
|
||||
{name: "track", icon: "route", label: "Trasa"},
|
||||
{name: "photos", icon: "add_a_photo", label: "Fotky"},
|
||||
{name: "notes", icon: "edit_note", label: "Poznámky"},
|
||||
{name: "editFlightBasic", icon: "flight_takeoff", label: "Základní informace"},
|
||||
{name: "editFlightTrack", icon: "route", label: "Trasa"},
|
||||
{name: "editFlightPhotos", icon: "add_a_photo", label: "Fotky"},
|
||||
{name: "editFlightNotes", icon: "edit_note", label: "Poznámky"},
|
||||
{name: "editFlightSetPlan", icon: "pin_drop", label: "Plán"},
|
||||
];
|
||||
|
||||
const emit = defineEmits(["update:modelValue"])
|
||||
@@ -37,14 +37,20 @@ watch(currentStep, (val) => emit("update:modelValue", val));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-tabs v-if="routerEditMode" active-color="primary" class="q-mb-lg">
|
||||
<q-tabs
|
||||
v-if="routerEditMode"
|
||||
active-color="primary"
|
||||
class="q-mb-lg"
|
||||
left-icon="chevron_left"
|
||||
right-icon="chevron_right"
|
||||
>
|
||||
<q-route-tab
|
||||
v-for="step in steps"
|
||||
:key="step.name"
|
||||
:icon="step.icon"
|
||||
:label="step.label"
|
||||
replace
|
||||
:to="{name: 'flightEdit', params: {id: flightId, step: step.name}}"
|
||||
:to="{name: step.name, params: {id: flightId}}"
|
||||
:disable="disabled.includes(step.name)"
|
||||
exact
|
||||
/>
|
||||
@@ -60,7 +66,6 @@ watch(currentStep, (val) => emit("update:modelValue", val));
|
||||
active-color="primary"
|
||||
class="q-mb-lg"
|
||||
>
|
||||
|
||||
<q-tab v-for="step in steps" :key="step.name" v-bind="step" :disable="disabled.includes(step.name)"/>
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import FormWrapper from "components/FormWrapper.vue";
|
||||
import {reactive, ref, watch} from "vue";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -24,7 +25,7 @@ const props = defineProps({
|
||||
|
||||
const formData = reactive({
|
||||
track: [],
|
||||
gpxTrack: null
|
||||
gpxTrackFile: null
|
||||
});
|
||||
const removeTrackItem = (index) => {
|
||||
formData.track.splice(index, 1);
|
||||
@@ -50,7 +51,8 @@ watch(poi, (newVal) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.flight.track, (newVal) => {
|
||||
watch(() => props.flight.turnPoints, (newVal) => {
|
||||
formData.track = [];
|
||||
for (const i in newVal) {
|
||||
const trackInfo = newVal[i];
|
||||
const info = {landingDuration: trackInfo.landingDuration};
|
||||
@@ -74,14 +76,14 @@ watch(() => props.flight.track, (newVal) => {
|
||||
<template>
|
||||
<FormWrapper v-model="formData">
|
||||
|
||||
<q-banner class="bg-green text-white" v-if="flight.gpxTrack">
|
||||
<q-banner class="bg-green text-white" v-if="flight.track">
|
||||
<q-icon name="check"/>
|
||||
Trasa nahrána
|
||||
</q-banner>
|
||||
|
||||
<q-file
|
||||
accept=".gpx"
|
||||
v-model="formData.gpxTrack"
|
||||
v-model="formData.gpxTrackFile"
|
||||
v-bind="commonProps"
|
||||
label="Nahrát trasu v GPX"
|
||||
hint="Slouží pro vykreslení kompletní a detailní trasy"
|
||||
@@ -91,6 +93,8 @@ watch(() => props.flight.track, (newVal) => {
|
||||
</template>
|
||||
</q-file>
|
||||
|
||||
<h5>Body na mapě</h5>
|
||||
|
||||
<ComboboxInput
|
||||
v-bind="commonProps"
|
||||
v-model="poi"
|
||||
@@ -103,7 +107,7 @@ watch(() => props.flight.track, (newVal) => {
|
||||
/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col-12 col-md-8">
|
||||
<ComboboxInput
|
||||
v-bind="commonProps"
|
||||
v-model="airport"
|
||||
@@ -111,16 +115,16 @@ watch(() => props.flight.track, (newVal) => {
|
||||
:options="airports"
|
||||
clearable
|
||||
label="Letiště pro mezipřistání"
|
||||
hint="Nastav letiště a dobu mezispřistání"
|
||||
hint="Nastav letiště a dobu mezipřistání"
|
||||
/>
|
||||
</div>
|
||||
<div class="col" v-if="airport?.name">
|
||||
<div class="col-md-4 col-12" v-if="airport?.name">
|
||||
<q-input
|
||||
type="number"
|
||||
v-bind="commonProps"
|
||||
v-model="landingDuration"
|
||||
label="Délka mezipřistání (minuty)"
|
||||
class="q-mx-md"
|
||||
class="q-mx-md-md"
|
||||
>
|
||||
<template #after>
|
||||
<q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn>
|
||||
@@ -136,7 +140,7 @@ watch(() => props.flight.track, (newVal) => {
|
||||
<q-avatar color="primary" text-color="white" icon="flight_takeoff"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ flight.takeoffAirport.name }}
|
||||
{{ flight.takeoffAirport?.name || '???' }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
@@ -153,7 +157,11 @@ watch(() => props.flight.track, (newVal) => {
|
||||
</q-badge>
|
||||
</span>
|
||||
<span v-if="item.airport">
|
||||
{{ item.airport.name }}, mezipřistání: {{ item.landingDuration }} minut
|
||||
{{ item.airport.name }},
|
||||
<template v-if="item.landingDuration > 0">
|
||||
mezipřistání {{ minutesToHumanReadable(item.landingDuration) }}
|
||||
</template>
|
||||
<template v-else>touch and go</template>
|
||||
</span>
|
||||
|
||||
</q-item-section>
|
||||
@@ -167,7 +175,7 @@ watch(() => props.flight.track, (newVal) => {
|
||||
<q-avatar color="primary" text-color="white" icon="flight_land"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ flight.landingAirport.name }}
|
||||
{{ flight.landingAirport?.name || '???' }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import {randomInt} from "src/composables/random";
|
||||
import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue";
|
||||
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
|
||||
</script>
|
||||
@@ -16,7 +15,7 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
|
||||
|
||||
<q-item>
|
||||
<q-list>
|
||||
<ListItemSkeleton v-for="i in randomInt(1, 3)" :key="i"/>
|
||||
<ListItemSkeleton v-for="i in 3" :key="i"/>
|
||||
</q-list>
|
||||
</q-item>
|
||||
|
||||
@@ -26,7 +25,7 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
|
||||
</q-list>
|
||||
|
||||
<article class="q-my-lg">
|
||||
<BasicTextSkeleton :lines="randomInt(2, 5)" :paragraphs="randomInt(1, 2)" />
|
||||
<BasicTextSkeleton :lines="7" :paragraphs="2" />
|
||||
</article>
|
||||
|
||||
<q-skeleton height="400px" square class="q-mt-lg"/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-card class="q-my-sm">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
|
||||
<q-card class="q-mr-md-sm q-my-sm">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<em>POI edit skeleton</em>
|
||||
edit copilot skeleton
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
<q-skeleton type="rect" width="100%" height="500px"/>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
|
||||
</template>
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup>
|
||||
import {randomInt} from "src/composables/random";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 10,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list bordered separator class="q-mx-md q-my-md">
|
||||
<q-item v-for="i in randomInt(5, 10)" :key="i" ripple>
|
||||
<q-list bordered separator>
|
||||
<q-item v-for="i in items" :key="i" ripple>
|
||||
<q-item-section avatar>
|
||||
<q-avatar size="100px">
|
||||
<q-skeleton type="QAvatar" size="100px"></q-skeleton>
|
||||
@@ -12,10 +20,9 @@ import {randomInt} from "src/composables/random";
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-skeleton :width="randomInt(30,50)+'%'"/>
|
||||
|
||||
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/>
|
||||
<q-skeleton type="text" :width="randomInt(5,10)+'%'"/>
|
||||
<q-skeleton type="text" width="8%"/>
|
||||
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/>
|
||||
<q-skeleton type="text" :width="randomInt(5,10)+'%'"/>
|
||||
<q-skeleton type="text" width="8%"/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row q-mx-md">
|
||||
<div class="col-12 col-md-6 col-lg-6 self-center info-box">
|
||||
<div class="text-h3 text-center q-my-lg">Fotogalerie ze vzduchu</div>
|
||||
|
||||
<p>Polétání.cz je aplikace pro piloty, kteří si chtějí spravovat fotografie ze svých výletů, zobrazovat je na mapě a ukládat si trasy svých výletů.</p>
|
||||
|
||||
<div class="text-center q-my-lg">
|
||||
<q-btn size="xl" :to="{name: 'registration'}" rounded color="secondary">Založit účet</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-6 text-center">
|
||||
<div>
|
||||
<q-img src="hp/mobile-preview-200.webp" height="401px" width="200px" class="q-ma-lg" fit="contain" alt="Nahled aplikace"/>
|
||||
<q-img src="hp/desktop-preview-350.webp" height="310px" width="350px" fit="contain" class="xs-hide sm-hide" alt="Nahled aplikace"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.info-box {
|
||||
font-size: 150%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-timeline color="primary" class="q-my-lg">
|
||||
<q-timeline-entry title="Naplánuj si let">
|
||||
<div>
|
||||
Pomocí jednoduchého plánovače si sestav trasu, označ si zajímavé body, připrav si navigační štítek a zkontroluj počasí.
|
||||
</div>
|
||||
</q-timeline-entry>
|
||||
|
||||
<q-timeline-entry title="Zaznamenej si let">
|
||||
<div>
|
||||
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
|
||||
</div>
|
||||
<div>
|
||||
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
|
||||
</div>
|
||||
</q-timeline-entry>
|
||||
|
||||
<q-timeline-entry title="Vyber a nahraj fotky">
|
||||
<div>A pokud mají v sobě uloženou informaci o poloze, p
|
||||
Máš nějaké fotky z letu? Nahraj je ke svému letu! Polétání.cz ti zobrazí i místo kde fotka vznikla.
|
||||
</div>
|
||||
</q-timeline-entry>
|
||||
|
||||
<q-timeline-entry title="Popiš zážitek">
|
||||
<div>
|
||||
Určitě se po ceste stalo něco vzrušujícího. Proč o tom nenapsat pár vět?
|
||||
</div>
|
||||
</q-timeline-entry>
|
||||
|
||||
<q-timeline-entry title="Sdílej">
|
||||
<div>
|
||||
Létat v sóle je super, ale ještě lepší je se o zážitek podělit. Jedním kliknutím můžeš svůj let zveřejnit a
|
||||
sdílet odkaz kde chceš. Třeba s tvým kopilotem.
|
||||
</div>
|
||||
</q-timeline-entry>
|
||||
</q-timeline>
|
||||
</template>
|
||||