Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f8ef6b985 | ||
|
|
160bc1b031 | ||
|
|
ae7072ddee | ||
|
|
f7845b963d | ||
|
|
65ea19664c | ||
|
|
348025afee | ||
|
|
33e7aa6e36 | ||
|
|
ea88a29750 | ||
|
|
448d97e310 | ||
|
|
018153aff1 | ||
|
|
87c7f17116 | ||
|
|
532aee9f32 | ||
|
|
92c9e53ea0 | ||
|
|
be977ee241 | ||
|
|
bffdd96be2 | ||
|
|
86e8dbf8e7 | ||
|
|
786c59cc4a | ||
|
|
7011aa3223 |
@@ -28,3 +28,5 @@ yarn-error.log*
|
||||
*.njsproj
|
||||
*.sln
|
||||
.vscode
|
||||
|
||||
quasar.config.js.temporary.*
|
||||
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
# develop stage
|
||||
FROM node:20-alpine AS develop-stage
|
||||
FROM node:22-alpine AS develop-stage
|
||||
|
||||
ENV TZ=Europe/Prague
|
||||
|
||||
@@ -18,8 +18,9 @@ ENV API_URL "https://api.poletani.cz"
|
||||
ENV GOOGLE_MAPS_API_KEY "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
|
||||
|
||||
RUN quasar build -m ssr
|
||||
RUN cp /app/dist/ssr/server/server-entry.mjs /app/dist/ssr/server/server-entry.js
|
||||
|
||||
FROM node:20-alpine AS production-stage
|
||||
FROM node:22-alpine AS production-stage
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV TZ=Europe/Prague
|
||||
|
||||
+16
@@ -11,8 +11,24 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="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>
|
||||
|
||||
+36
-24
@@ -10,37 +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",
|
||||
"@sentry/browser": "^8.9.2",
|
||||
"@apollo/client": "^4.0.5",
|
||||
"@quasar/extras": "^1.16.16",
|
||||
"@sentry/browser": "^10.14.0",
|
||||
"@sentry/integrations": "^7.114.0",
|
||||
"@sentry/vue": "^8.11.0",
|
||||
"@vue/apollo-composable": "4.0.0-beta.2",
|
||||
"@vue/apollo-option": "^4.0.0-beta.5",
|
||||
"apollo-upload-client": "^17.0.0",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.1",
|
||||
"graphql": "16.6.0",
|
||||
"@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.16.2",
|
||||
"vue": "3.4.27",
|
||||
"vue-chartjs": "^5.3.1",
|
||||
"pinia": "^3.0.3",
|
||||
"quasar": "2.18.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"vue": "3.5.21",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue3-google-map": "^0.20.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"vue3-google-map": "^0.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/app-vite": "1.9.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-vue": "^9.19.2",
|
||||
"postcss": "^8.4.14",
|
||||
"prettier": "^2.8.8"
|
||||
"@quasar/app-vite": "2.4.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 10 Chrome versions",
|
||||
"last 10 Firefox versions",
|
||||
"last 4 Edge versions",
|
||||
"last 7 Safari versions",
|
||||
"last 8 Android versions",
|
||||
"last 8 ChromeAndroid versions",
|
||||
"last 8 FirefoxAndroid versions",
|
||||
"last 10 iOS versions",
|
||||
"last 5 Opera versions"
|
||||
],
|
||||
"engines": {
|
||||
"npm": ">= 6.13.4",
|
||||
"yarn": ">= 1.21.1"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// https://github.com/michael-ciniawsky/postcss-load-config
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
// https://github.com/postcss/autoprefixer
|
||||
require('autoprefixer')({
|
||||
overrideBrowserslist: [
|
||||
'last 4 Chrome versions',
|
||||
'last 4 Firefox versions',
|
||||
'last 4 Edge versions',
|
||||
'last 4 Safari versions',
|
||||
'last 4 Android versions',
|
||||
'last 4 ChromeAndroid versions',
|
||||
'last 4 FirefoxAndroid versions',
|
||||
'last 4 iOS versions'
|
||||
]
|
||||
})
|
||||
|
||||
// https://github.com/elchininet/postcss-rtlcss
|
||||
// If you want to support RTL css, then
|
||||
// 1. yarn/npm install postcss-rtlcss
|
||||
// 2. optionally set quasar.config.js > framework > lang to an RTL language
|
||||
// 3. uncomment the following line:
|
||||
// require('postcss-rtlcss')
|
||||
]
|
||||
}
|
||||
@@ -3,3 +3,9 @@ apps:
|
||||
script: /app/index.js
|
||||
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'
|
||||
|
||||
+7
-3
@@ -8,8 +8,7 @@
|
||||
// Configuration for your app
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||
|
||||
const {configure} = require('quasar/wrappers');
|
||||
|
||||
import { configure } from 'quasar/wrappers'
|
||||
|
||||
module.exports = configure(function (ctx) {
|
||||
return {
|
||||
@@ -29,7 +28,12 @@ module.exports = configure(function (ctx) {
|
||||
// --> boot files are part of "main.js"
|
||||
// https://v2.quasar.dev/quasar-cli/boot-files
|
||||
boot: [
|
||||
"sentry",
|
||||
// client-only: Sentry browser SDK (browserTracingIntegration,
|
||||
// replayIntegration) is DOM-only and has no business running per SSR
|
||||
// request - server: false means Quasar excludes this boot file from
|
||||
// the server bundle entirely (not just a runtime guard).
|
||||
// See handoffs/04-sentry-ssr-guard.md.
|
||||
{path: "sentry", server: false},
|
||||
"cookies",
|
||||
"axios",
|
||||
"apollo",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ssrMiddleware } from 'quasar/wrappers'
|
||||
import { defineSsrMiddleware } from '#q-app/wrappers'
|
||||
|
||||
// This middleware should execute as last one
|
||||
// since it captures everything and tries to
|
||||
// render the page with Vue
|
||||
|
||||
export default ssrMiddleware(({ app, resolve, render, serve }) => {
|
||||
export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
|
||||
// we capture any other Express route and hand it
|
||||
// over to Vue and Vue Router to render our page
|
||||
app.get(resolve.urlPath('*'), (req, res) => {
|
||||
@@ -28,12 +28,9 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
|
||||
} else if (err.code === 404) {
|
||||
// hmm, Vue Router could not find the requested route
|
||||
|
||||
console.log("404 v renderu")
|
||||
|
||||
// Should reach here only if no "catch-all" route
|
||||
// is defined in /src/routes
|
||||
// res.status(404).send('404 | Page Not Found')
|
||||
res.redirect(resolve.urlPath('404'))
|
||||
res.status(404).send('404 | Page Not Found')
|
||||
} else if (process.env.DEV) {
|
||||
// well, we treat any other code as error;
|
||||
// if we're in dev mode, then we can use Quasar CLI
|
||||
@@ -42,7 +39,6 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
|
||||
|
||||
// serve.error is available on dev only
|
||||
serve.error({ err, req, res })
|
||||
|
||||
} else {
|
||||
// we're in production, so we should have another method
|
||||
// to display something to the client when we encounter an error
|
||||
@@ -51,7 +47,11 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
|
||||
|
||||
// Render Error Page on production or
|
||||
// create a route (/src/routes) for an error page and redirect to it
|
||||
res.redirect(resolve.urlPath('500'))
|
||||
res.status(500).send('500 | Internal Server Error')
|
||||
|
||||
if (process.env.DEBUGGING) {
|
||||
console.error(err.stack)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+46
-25
@@ -12,21 +12,22 @@
|
||||
import express from 'express'
|
||||
import compression from 'compression'
|
||||
import {
|
||||
ssrClose,
|
||||
ssrCreate,
|
||||
ssrListen,
|
||||
ssrRenderPreloadTag,
|
||||
ssrServeStaticContent
|
||||
} from 'quasar/wrappers'
|
||||
defineSsrCreate,
|
||||
defineSsrInjectDevMiddleware,
|
||||
defineSsrListen,
|
||||
defineSsrClose,
|
||||
defineSsrServeStaticContent,
|
||||
defineSsrRenderPreloadTag
|
||||
} from '#q-app/wrappers'
|
||||
|
||||
/**
|
||||
* Create your webserver and return its instance.
|
||||
* If needed, prepare your webserver to receive
|
||||
* connect-like middlewares.
|
||||
*
|
||||
* Should NOT be async!
|
||||
* Can be async: defineSsrCreate(async ({ ... }) => { ... })
|
||||
*/
|
||||
export const create = ssrCreate((/* { ... } */) => {
|
||||
export const create = defineSsrCreate((/* { ... } */) => {
|
||||
const app = express()
|
||||
|
||||
// attackers can use this header to detect apps running Express
|
||||
@@ -42,6 +43,19 @@ export const create = ssrCreate((/* { ... } */) => {
|
||||
return app
|
||||
})
|
||||
|
||||
/**
|
||||
* Used by Quasar SSR dev server to inject middleware into the webserver.
|
||||
* It uses it to handle Vite dev server, handle public paths, etc.
|
||||
* The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
|
||||
*
|
||||
* Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
|
||||
*/
|
||||
export const injectDevMiddleware = defineSsrInjectDevMiddleware(({ app }) => {
|
||||
return (middleware) => {
|
||||
app.use(middleware)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* You need to make the server listen to the indicated port
|
||||
* and return the listening instance or whatever you need to
|
||||
@@ -52,10 +66,12 @@ export const create = ssrCreate((/* { ... } */) => {
|
||||
*
|
||||
* For production, you can instead export your
|
||||
* handler for serverless use or whatever else fits your needs.
|
||||
*
|
||||
* Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
|
||||
*/
|
||||
export const listen = ssrListen(async ({ app, port, isReady }) => {
|
||||
await isReady()
|
||||
return app.listen(port, () => {
|
||||
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
|
||||
const server = devHttpsApp || app
|
||||
return server.listen(port, () => {
|
||||
if (process.env.PROD) {
|
||||
console.log('Server listening at port ' + port)
|
||||
}
|
||||
@@ -70,9 +86,9 @@ export const listen = ssrListen(async ({ app, port, isReady }) => {
|
||||
* Should you need the result of the "listen()" call above,
|
||||
* you can use the "listenResult" param.
|
||||
*
|
||||
* Can be async.
|
||||
* Can be async: defineSsrClose(async ({ listenResult }) => { ... })
|
||||
*/
|
||||
export const close = ssrClose(({ listenResult }) => {
|
||||
export const close = defineSsrClose(({ listenResult }) => {
|
||||
return listenResult.close()
|
||||
})
|
||||
|
||||
@@ -81,14 +97,19 @@ const maxAge = process.env.DEV
|
||||
: 1000 * 60 * 60 * 24 * 30
|
||||
|
||||
/**
|
||||
* Should return middleware that serves the indicated path
|
||||
* with static content.
|
||||
* Should return a function that will be used to configure the webserver
|
||||
* to serve static content at "urlPath" from "pathToServe" folder/file.
|
||||
*
|
||||
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
|
||||
*
|
||||
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
|
||||
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
|
||||
*/
|
||||
export const serveStaticContent = ssrServeStaticContent((path, opts) => {
|
||||
return express.static(path, {
|
||||
maxAge,
|
||||
...opts
|
||||
})
|
||||
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
|
||||
return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
|
||||
const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
|
||||
app.use(resolve.urlPath(urlPath), serveFn)
|
||||
}
|
||||
})
|
||||
|
||||
const jsRE = /\.js$/
|
||||
@@ -103,13 +124,13 @@ const pngRE = /\.png$/
|
||||
* Should return a String with HTML output
|
||||
* (if any) for preloading indicated file
|
||||
*/
|
||||
export const renderPreloadTag = ssrRenderPreloadTag((file) => {
|
||||
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
|
||||
if (jsRE.test(file) === true) {
|
||||
return `<link rel="modulepreload" href="${file}" crossorigin>`
|
||||
}
|
||||
|
||||
if (cssRE.test(file) === true) {
|
||||
return `<link rel="stylesheet" href="${file}">`
|
||||
return `<link rel="stylesheet" href="${file}" crossorigin>`
|
||||
}
|
||||
|
||||
if (woffRE.test(file) === true) {
|
||||
@@ -121,15 +142,15 @@ export const renderPreloadTag = ssrRenderPreloadTag((file) => {
|
||||
}
|
||||
|
||||
if (gifRE.test(file) === true) {
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/gif">`
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
|
||||
}
|
||||
|
||||
if (jpgRE.test(file) === true) {
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/jpeg">`
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
|
||||
}
|
||||
|
||||
if (pngRE.test(file) === true) {
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/png">`
|
||||
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
|
||||
}
|
||||
|
||||
return ''
|
||||
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
|
||||
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
|
||||
import "quasar/dist/types/feature-flag";
|
||||
|
||||
declare module "quasar/dist/types/feature-flag" {
|
||||
interface QuasarFeatureFlags {
|
||||
ssr: true;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {computed} from "vue";
|
||||
import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useMeta} from "quasar";
|
||||
import {metaData} from "src/composables/meta";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -13,6 +15,10 @@ const layoutComponent = computed(() => {
|
||||
});
|
||||
|
||||
const appStatus = storeToRefs(useAppStore()).appStatus;
|
||||
|
||||
useMeta(() => metaData({
|
||||
title: useAppStore().pageTitle,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+112
-27
@@ -1,23 +1,38 @@
|
||||
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core'
|
||||
import {onError} from "@apollo/client/link/error";
|
||||
import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core'
|
||||
import {ErrorLink} from "@apollo/client/link/error";
|
||||
import {defineStore} from "pinia";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {Notify} from "quasar";
|
||||
import {Dialog, Notify} from "quasar";
|
||||
import {errorObject, warningObject} from "src/composables/dialog";
|
||||
import {useAuthStore} from "stores/Auth";
|
||||
import {createUploadLink} from "apollo-upload-client";
|
||||
// import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
|
||||
import UploadHttpLink from "apollo-upload-client/UploadHttpLink.mjs";
|
||||
import LoginOverlayDialog from "components/auth/LoginOverlayDialog.vue";
|
||||
|
||||
const handleRefreshToken = (store, router, forward, operation) => {
|
||||
if (!operation.query?._refreshUnauthorizedUser) return;
|
||||
// Dedup across concurrently failing operations: if a query and a mutation
|
||||
// both 401 around the same time, they must share one login overlay instead
|
||||
// of each opening its own dialog. Module-level state is fine here (unlike
|
||||
// the SSR-sensitive apolloWrapper below) because this path only ever runs
|
||||
// client-side, one browser tab per module instance.
|
||||
let pendingLoginPromise = null;
|
||||
|
||||
const showLoginOverlay = (store) => {
|
||||
if (pendingLoginPromise) return pendingLoginPromise;
|
||||
|
||||
console.log("refreshing token, ", operation.query);
|
||||
return fromPromise(useAuthStore(store).refreshAccessToken()
|
||||
.catch((error) => {
|
||||
Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu."));
|
||||
router.push({name: "login"})
|
||||
}))
|
||||
.filter((value) => Boolean(value))
|
||||
.flatMap((accessToken) => {
|
||||
|
||||
pendingLoginPromise = new Promise((resolve) => {
|
||||
Dialog.create({
|
||||
component: LoginOverlayDialog,
|
||||
persistent: true,
|
||||
}).onOk(() => resolve(useAuthStore(store).getAccessToken()));
|
||||
}).finally(() => {
|
||||
pendingLoginPromise = null;
|
||||
});
|
||||
|
||||
return pendingLoginPromise;
|
||||
};
|
||||
|
||||
const retryWithToken = (operation, forward) => (accessToken) => {
|
||||
const oldHeaders = operation.getContext().headers;
|
||||
operation.setContext({
|
||||
headers: {
|
||||
@@ -28,12 +43,61 @@ const handleRefreshToken = (store, router, forward, operation) => {
|
||||
|
||||
// retry the request, returning the new observable
|
||||
return forward(operation);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefreshToken = (store, router, forward, operation) => {
|
||||
if (!operation.query?._refreshUnauthorizedUser) return;
|
||||
|
||||
// Loop guard: if the operation retried after a refresh/login still comes
|
||||
// back 401, don't try forever - surface a hard error instead.
|
||||
if (operation.getContext()._tokenRefreshAttempted) {
|
||||
Notify.create(errorObject("Přihlášení se nezdařilo, zkus to prosím znovu."));
|
||||
return Promise.reject(new Error("Token refresh retry failed"));
|
||||
}
|
||||
operation.setContext({_tokenRefreshAttempted: true});
|
||||
|
||||
const retry = retryWithToken(operation, forward);
|
||||
|
||||
// The overlay only makes sense client-side (no DOM during SSR); keep the
|
||||
// previous redirect-to-login behavior there.
|
||||
const onRefreshFailed = process.env.SERVER
|
||||
? () => {
|
||||
router.push({name: "login"});
|
||||
return Promise.reject(new Error("Token refresh failed (SSR)"));
|
||||
}
|
||||
: () => showLoginOverlay(store).then(retry);
|
||||
|
||||
return useAuthStore(store).refreshAccessToken()
|
||||
.then(retry)
|
||||
.catch(onRefreshFailed);
|
||||
}
|
||||
|
||||
export const apolloWrapper = {};
|
||||
export default (({store, router}) => {
|
||||
const uploadLink = createUploadLink({
|
||||
// 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'
|
||||
})
|
||||
@@ -48,7 +112,7 @@ export default (({store, router}) => {
|
||||
return forward(operation);
|
||||
});
|
||||
|
||||
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
|
||||
const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => {
|
||||
if (networkError?.statusCode === 401) {
|
||||
return handleRefreshToken(store, router, forward, operation);
|
||||
}
|
||||
@@ -64,17 +128,17 @@ export default (({store, router}) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const apolloClient = new ApolloClient({
|
||||
client = new ApolloClient({
|
||||
link: ApolloLink.from([authLink, errorLink, uploadLink]),
|
||||
cache: new InMemoryCache(),
|
||||
connectToDevTools: true,
|
||||
ssrMode: true,
|
||||
});
|
||||
|
||||
apolloWrapper.query = (query, variables = {}) => {
|
||||
return apolloClient.query({
|
||||
queryImpl = (query, variables = {}) => {
|
||||
return client.query({
|
||||
query: query,
|
||||
fetchPolicy: 'no-cache',
|
||||
variables: variables
|
||||
@@ -85,15 +149,15 @@ export default (({store, router}) => {
|
||||
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")) {
|
||||
} else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
|
||||
console.error("Error during query: ", err)
|
||||
}
|
||||
return Promise.reject(err);
|
||||
})
|
||||
};
|
||||
apolloWrapper.mutate = (mutation, variables) => {
|
||||
return apolloClient
|
||||
|
||||
mutateImpl = (mutation, variables) => {
|
||||
return client
|
||||
.mutate({
|
||||
mutation: mutation,
|
||||
fetchPolicy: 'no-cache',
|
||||
@@ -102,6 +166,27 @@ export default (({store, router}) => {
|
||||
.then((response) => response)
|
||||
.catch(err => {
|
||||
console.error("Error during mutation: ", err);
|
||||
return Promise.reject(err);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const query = (query, variables) => queryImpl(query, variables)
|
||||
const mutate = (mutation, variables) => mutateImpl(mutation, variables)
|
||||
|
||||
return {init, query, mutate}
|
||||
})
|
||||
|
||||
// Backward-compatible shape so existing call sites
|
||||
// (`apolloWrapper.query(...)`, `apolloWrapper.mutate(...)` in
|
||||
// stores/helpers.js and stores/Organizations.js) keep working unchanged,
|
||||
// now delegating to the request-scoped store above instead of a shared
|
||||
// mutable singleton.
|
||||
export const apolloWrapper = {
|
||||
query: (...args) => useApolloStore().query(...args),
|
||||
mutate: (...args) => useApolloStore().mutate(...args),
|
||||
}
|
||||
|
||||
export default (({store, router}) => {
|
||||
useApolloStore(store).init({store, router})
|
||||
});
|
||||
|
||||
+18
-2
@@ -13,8 +13,24 @@ const api = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// SSR: boot() runs again for every request, but `api` above is a
|
||||
// module-level singleton created once. Without ejecting the previous
|
||||
// request's interceptors before adding new ones, api.interceptors.*
|
||||
// would grow without bound for the lifetime of the process, each
|
||||
// handler closing over that request's (by-then-stale) store - see
|
||||
// handoffs/01-axios-interceptors-leak.md for the full analysis.
|
||||
let requestInterceptorId
|
||||
let responseInterceptorId
|
||||
|
||||
export default boot(({store}) => {
|
||||
api.interceptors.request.use((config) => {
|
||||
if (requestInterceptorId !== undefined) {
|
||||
api.interceptors.request.eject(requestInterceptorId)
|
||||
}
|
||||
if (responseInterceptorId !== undefined) {
|
||||
api.interceptors.response.eject(responseInterceptorId)
|
||||
}
|
||||
|
||||
requestInterceptorId = api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore(store).getAccessToken();
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
@@ -28,7 +44,7 @@ export default boot(({store}) => {
|
||||
})
|
||||
|
||||
// Response interceptor for API calls
|
||||
api.interceptors.response.use(
|
||||
responseInterceptorId = api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async function (error) {
|
||||
const originalRequest = error.config;
|
||||
|
||||
+28
-5
@@ -1,9 +1,32 @@
|
||||
import {Cookies} from 'quasar'
|
||||
import {boot} from "quasar/wrappers";
|
||||
import {defineStore} from 'pinia'
|
||||
|
||||
let cookies = Cookies;
|
||||
export default boot(({ssrContext}) => {
|
||||
cookies = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
|
||||
// SSR: boot() runs again for every request. `cookies` used to be a single
|
||||
// shared module-level variable reassigned in place by every request's
|
||||
// boot() - under concurrent SSR requests, one request's
|
||||
// Cookies.parseSSR(ssrContext) result (which carries that request's own
|
||||
// accessToken) could overwrite another still-in-flight request's
|
||||
// `cookies` before it finished reading it, leaking one user's access
|
||||
// token to another. See handoffs/03-cookies-shared-state.md.
|
||||
//
|
||||
// Fix: make this a Pinia store, initialized explicitly per request from
|
||||
// boot() with that request's own `store` (same proven pattern as
|
||||
// boot/apollo.js), instead of a shared mutable variable.
|
||||
export const useCookiesStore = defineStore('Cookies', () => {
|
||||
let instance = null
|
||||
|
||||
const init = (ssrContext) => {
|
||||
instance = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
|
||||
}
|
||||
|
||||
const get = (...args) => instance.get(...args)
|
||||
const set = (...args) => instance.set(...args)
|
||||
const remove = (...args) => instance.remove(...args)
|
||||
|
||||
return {init, get, set, remove}
|
||||
})
|
||||
|
||||
export default boot(({store, ssrContext}) => {
|
||||
useCookiesStore(store).init(ssrContext)
|
||||
});
|
||||
|
||||
export {cookies}
|
||||
|
||||
+7
-11
@@ -1,7 +1,14 @@
|
||||
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",
|
||||
@@ -9,19 +16,8 @@ export default boot(({app, router}) => {
|
||||
Sentry.browserTracingIntegration({router}),
|
||||
Sentry.replayIntegration(),
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for tracing.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
// Set `tracePropagationTargets` to control for which URLs trace propagation should be enabled
|
||||
// tracePropagationTargets: ["localhost", /^https:\/\/yourserver\.io\/api/],
|
||||
|
||||
// Capture Replay for 10% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,14 +38,23 @@ onMounted(() => {
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
totalItemsCount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
itemsLength: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 5,
|
||||
},
|
||||
isQueryRunning: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const emits = defineEmits(["load-more"]);
|
||||
const loadMore = () => {
|
||||
emits("load-more", props.itemsPerPage);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot name="items"/>
|
||||
<slot name="skeleton" v-if="isQueryRunning"/>
|
||||
|
||||
<div class="text-center q-my-md">
|
||||
<q-btn color="primary" :loading="isQueryRunning" rounded size="lg" icon="expand_more" @click="loadMore()" v-if="totalItemsCount > itemsLength">
|
||||
<slot name="loadMoreText"/>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
routerLink: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const config = computed(() => {
|
||||
if (props.mode === "add") {
|
||||
return {icon: "add", label: "Přidat"};
|
||||
} else if (props.mode === "edit") {
|
||||
return {icon: "edit", label: "Upravit"};
|
||||
}
|
||||
|
||||
return {icon: "unknown", label: `Neznámý typ ${props.mode}`}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<q-toolbar class="justify-end q-pr-none" v-if="$q.screen.gt.sm">
|
||||
<q-btn color="primary" size="lg" :icon="config.icon" :to="props.routerLink">{{ config.label }}</q-btn>
|
||||
</q-toolbar>
|
||||
|
||||
<q-page-sticky
|
||||
location="bottom"
|
||||
:offset="[30, 30]"
|
||||
v-else
|
||||
>
|
||||
<q-fab
|
||||
:icon="config.icon"
|
||||
color="primary"
|
||||
:label="config.label"
|
||||
@click="$router.push(props.routerLink)"
|
||||
/>
|
||||
</q-page-sticky>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
<script setup>
|
||||
import LoadMoreList from "components/LoadMoreList.vue";
|
||||
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import {useInfiniteScroll} from "src/composables/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
totalItemsCount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 5,
|
||||
},
|
||||
isQueryRunning: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
...useInfiniteScroll().propsDef
|
||||
});
|
||||
|
||||
const emits = defineEmits([...useInfiniteScroll().emitsDef])
|
||||
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
|
||||
<template #items>
|
||||
<q-infinite-scroll
|
||||
:disable="!hasMoreData"
|
||||
@load="emitLoadMore"
|
||||
:offset="250"
|
||||
>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
clickable
|
||||
@@ -57,12 +48,6 @@ const props = defineProps({
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</template>
|
||||
<template #skeleton>
|
||||
<BasicListSkeleton :items="itemsPerPage"/>
|
||||
</template>
|
||||
<template #loadMoreText>
|
||||
Další
|
||||
</template>
|
||||
</LoadMoreList>
|
||||
</q-infinite-scroll>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<q-dialog ref="dialogRef" persistent @hide="onDialogHide">
|
||||
<q-card style="width: 95%; max-width: 400px;">
|
||||
<q-form @submit.prevent="logUser">
|
||||
<q-card-section class="bg-primary">
|
||||
<h4 class="text-h6 q-my-md text-white">Přihlášení vypršelo</h4>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<p>Tvoje přihlášení vypršelo. Přihlaš se prosím znovu, aby se neztratila rozpracovaná data.</p>
|
||||
<q-input v-model="formData.email" :rules="rules.email" type="email" label="E-mail">
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="email"/>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-input v-model="formData.password" :rules="rules.password" type="password" label="Heslo">
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock"/>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
<q-card-actions>
|
||||
<q-btn type="submit" unelevated size="lg" color="primary" class="full-width" :loading="loading" label="Přihlásit se"/>
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {reactive, ref} from "vue";
|
||||
import {useDialogPluginComponent, useQuasar} from "quasar";
|
||||
import {isRequired, isValidEmail, minLength} from "src/composables/form/rules";
|
||||
import {errorObject} from "src/composables/dialog";
|
||||
import {useAuthStore} from "stores/Auth";
|
||||
|
||||
const {dialogRef, onDialogHide, onDialogOK} = useDialogPluginComponent();
|
||||
defineEmits([...useDialogPluginComponent.emits]);
|
||||
|
||||
const $q = useQuasar();
|
||||
const loading = ref(false);
|
||||
const formData = reactive({
|
||||
email: "",
|
||||
password: ""
|
||||
});
|
||||
const rules = {
|
||||
email: [isRequired, isValidEmail],
|
||||
password: [isRequired, minLength(4)]
|
||||
};
|
||||
|
||||
const logUser = () => {
|
||||
loading.value = true;
|
||||
useAuthStore().login(formData)
|
||||
.then(() => onDialogOK())
|
||||
.catch(() => $q.notify(errorObject("Přihlášení se nezdařilo.")))
|
||||
.finally(() => loading.value = false);
|
||||
};
|
||||
</script>
|
||||
@@ -37,7 +37,7 @@ useEventsStore().fetchFlightsTracks({
|
||||
totalTime.value += flightTrack.durationMinCalculated;
|
||||
|
||||
if (flightTrack.track) {
|
||||
tracks.value.push(flightTrack.track.coordinates);
|
||||
tracks.value.push(flightTrack.track.points.coordinates);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import DatetimePicker from "components/inputs/DatetimePicker.vue";
|
||||
import {computed, reactive} from "vue";
|
||||
import {reactive} from "vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {useFlightPlanCalculations, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
import {useFlightPlanCalculations} from "src/composables/flightPlan";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
@@ -17,18 +17,13 @@ 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)),
|
||||
landingDatetime: new Date(
|
||||
(new Date(props.flightPlan.plannedTakeoffDatetime)).getTime() + 60 * 1000 * getTotalDuration(props.flightPlan)
|
||||
),
|
||||
copyTrack: true,
|
||||
createPointsOfInterests: true,
|
||||
flightPlan: props.flightPlan,
|
||||
});
|
||||
|
||||
const {getMarkersFrom} = useFlightPlanUtils();
|
||||
|
||||
const hasGeocodeInTrack = computed(() => {
|
||||
return !!getMarkersFrom(props.flightPlan).find(point => point.type === 'geocode');
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 {reactive} from "vue";
|
||||
import {computed, reactive} from "vue";
|
||||
import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
|
||||
import {useLoggedUserStore} from "stores/LoggedUser";
|
||||
import {storeToRefs} from "pinia";
|
||||
@@ -22,6 +22,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
|
||||
|
||||
const formData = reactive({
|
||||
name: props.flightPlan.name || null,
|
||||
|
||||
-1
@@ -18,7 +18,6 @@ const props = defineProps({
|
||||
|
||||
const columns = [
|
||||
{
|
||||
// name: 'from',
|
||||
label: 'Z',
|
||||
align: "left",
|
||||
field: 'from'
|
||||
@@ -29,13 +29,18 @@ const placesFilter = (val, update, abort) => {
|
||||
searchGeocode(val).then(results => {
|
||||
if (!results) return;
|
||||
|
||||
placesOptions.value = results.map(result => ({
|
||||
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,
|
||||
}))
|
||||
gpsLatitude: result.geometry.location.lat(),
|
||||
gpsLongitude: result.geometry.location.lng(),
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -31,6 +31,9 @@ const swapMarkers = (idx1, idx2) => {
|
||||
};
|
||||
const handlePlaceSelect = (modelVal) => {
|
||||
model.value.push(modelVal);
|
||||
|
||||
// TODO: upravit bounds u mapy
|
||||
|
||||
}
|
||||
|
||||
watch(() => model.value, () => {
|
||||
@@ -80,7 +83,7 @@ const allPlacesOptions = computed(() => {
|
||||
<q-btn
|
||||
v-if="i < model.length - 1"
|
||||
text-color="primary"
|
||||
flat dense round icon="arrow_downward"
|
||||
flat dense round icon="`arrow_downward`"
|
||||
size="16px"
|
||||
@click="swapMarkers(i, i+1)"
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
import {useQuasar} from "quasar";
|
||||
import {useUnitsConvertors} from "src/composables/units";
|
||||
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
|
||||
import {formatDatetime} from "src/composables/datetimeUtils";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: 'Místo',
|
||||
align: "left",
|
||||
name: 'place',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Teplota',
|
||||
name: 'temperature',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Vítr',
|
||||
name: 'wind',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
label: 'Oblačnost',
|
||||
name: 'clouds',
|
||||
},
|
||||
];
|
||||
|
||||
const {screen} = useQuasar();
|
||||
const {getMarkersFrom} = useFlightPlanUtils();
|
||||
const {kmhToKnots, ftToM} = useUnitsConvertors();
|
||||
|
||||
const getCloudsBase = (weatherInfo) => {
|
||||
const tempDiff = weatherInfo.temperatureSurface - weatherInfo.dewpointSurface;
|
||||
const cloudLevelInFt = tempDiff / 2.5 * 1000;
|
||||
|
||||
return Math.round(ftToM(cloudLevelInFt));
|
||||
}
|
||||
|
||||
const markers = getMarkersFrom(props.flightPlan);
|
||||
const markersWithWeather = markers.filter(marker => !!marker.weatherInfo);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-table
|
||||
:grid="screen.xs"
|
||||
:dense="screen.lt.md"
|
||||
flat bordered
|
||||
hide-bottom
|
||||
:title="flightPlan.name"
|
||||
:rows="markersWithWeather"
|
||||
:columns="columns"
|
||||
:rows-per-page-options="[0]"
|
||||
>
|
||||
<template v-slot:body-cell-place="props">
|
||||
<q-td :props="props">
|
||||
<strong>
|
||||
{{ props.row.name }}
|
||||
</strong>
|
||||
<br>
|
||||
<small>
|
||||
{{ formatDatetime(props.row.weatherInfo.datetime) }}
|
||||
</small>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-temperature="props">
|
||||
<q-td :props="props">
|
||||
{{ props.row.weatherInfo.temperatureSurface }}° C
|
||||
<br>
|
||||
<small>
|
||||
Rosný bod {{ props.row.weatherInfo.dewpointSurface }}° C
|
||||
</small>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-wind="props">
|
||||
<q-td :props="props">
|
||||
<q-icon name="navigation" :style="`transform: rotate(${props.row.weatherInfo.windDirectionSurface}deg)`"/>
|
||||
{{ props.row.weatherInfo.windDirectionSurface }}°
|
||||
<br>
|
||||
{{ kmhToKnots(props.row.weatherInfo.windSpeedSurface) }} kt
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-clouds="props">
|
||||
<q-td :props="props">
|
||||
<CloudCoverIcon :cloudcover="props.row.weatherInfo.cloudcoverTotal"/>
|
||||
<br>
|
||||
celkové pokrytí oblačností {{ props.row.weatherInfo.cloudcoverTotal }} %
|
||||
<br>
|
||||
nízké mraky {{ props.row.weatherInfo.cloudcoverLow }} %
|
||||
|
||||
<template v-if="props.row.weatherInfo.cloudcoverLow > 5">
|
||||
<br>
|
||||
odhadovaná výška základen: {{ getCloudsBase(props.row.weatherInfo) }} m
|
||||
</template>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
|
||||
</q-table>
|
||||
</template>
|
||||
@@ -1,16 +1,27 @@
|
||||
<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
|
||||
@@ -33,4 +44,5 @@ const props = defineProps({
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-infinite-scroll>
|
||||
</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>
|
||||
@@ -2,10 +2,7 @@
|
||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
import {flightName} from "src/composables/flight";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
|
||||
import {computed} from "vue";
|
||||
import LoadMoreList from "components/LoadMoreList.vue";
|
||||
import {pluralize} from "../../composables/utils";
|
||||
import {useInfiniteScroll} from "src/composables/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
@@ -16,37 +13,26 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
totalItemsCount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 5,
|
||||
},
|
||||
isQueryRunning: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
...useInfiniteScroll().propsDef
|
||||
});
|
||||
|
||||
const emits = defineEmits([...useInfiniteScroll().emitsDef])
|
||||
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||
|
||||
const routeDetail = (flight) => {
|
||||
if (props.public)
|
||||
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
|
||||
else
|
||||
return {name: "flightDetail", params: {id: flight.id}}
|
||||
};
|
||||
const loadMoreCount = computed(() => {
|
||||
return Math.min(props.itemsPerPage, props.totalItemsCount - props.items.length);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
|
||||
<template #items>
|
||||
<q-infinite-scroll
|
||||
:disable="!hasMoreData"
|
||||
@load="emitLoadMore"
|
||||
:offset="250"
|
||||
>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
v-for="flight of items" :key="flight"
|
||||
@@ -54,16 +40,19 @@ const loadMoreCount = computed(() => {
|
||||
clickable
|
||||
:to="routeDetail(flight)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-item-section avatar class="gt-xs">
|
||||
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
|
||||
<q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-img v-if="flight.titlePhoto" class="lt-sm" :src="flight.titlePhoto.thumbnailUrl"/>
|
||||
|
||||
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
|
||||
|
||||
<div class="flex justify-between justify-sm-start list-secondary-info">
|
||||
<div class="text-secondary" v-if="props.public">
|
||||
<q-icon name="support_agent"/>
|
||||
@{{ flight.pilot.publicUsername }}
|
||||
{{ flight.pilot.publicUsername }}
|
||||
</div>
|
||||
|
||||
<div class="text-secondary" v-if="flight.copilots">
|
||||
@@ -85,6 +74,7 @@ const loadMoreCount = computed(() => {
|
||||
<q-icon name="event"/>
|
||||
{{ flight.event.name }}
|
||||
</div>
|
||||
</div>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side top v-if="!props.public">
|
||||
@@ -99,13 +89,16 @@ const loadMoreCount = computed(() => {
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<template v-slot:loading>
|
||||
<div class="row justify-center q-my-md">
|
||||
<q-spinner-dots color="primary" size="40px"/>
|
||||
</div>
|
||||
</template>
|
||||
<template #skeleton>
|
||||
<FlightsListSkeleton :items="itemsPerPage"/>
|
||||
</template>
|
||||
<template #loadMoreText>
|
||||
Načíst {{ pluralize(loadMoreCount, 'jeden let', `další ${loadMoreCount} lety`, `dalších ${loadMoreCount} letů`) }}
|
||||
</template>
|
||||
</LoadMoreList>
|
||||
|
||||
</q-infinite-scroll>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.list-secondary-info div {
|
||||
padding: 0 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
+19
-5
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import {ref} from "vue";
|
||||
import {flightName} from "src/composables/flight";
|
||||
import LatestFlightSliderInfo from "components/flights/slider/LatestFlightSliderInfo.vue";
|
||||
import FlightHeaderInfo from "components/flights/FlightHeaderInfo.vue";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
@@ -37,11 +37,25 @@ const slide = ref(0);
|
||||
height="400px"
|
||||
v-if="flight.titlePhoto"
|
||||
>
|
||||
<div class="absolute-bottom">
|
||||
<LatestFlightSliderInfo :flight="flight"/>
|
||||
<div class="absolute-full">
|
||||
<FlightHeaderInfo
|
||||
:route-detail="{name: 'publicFlightDetailNew', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
|
||||
:pilot="flight.pilot"
|
||||
:flight="flight"
|
||||
>
|
||||
|
||||
<div class="absolute-bottom-right">
|
||||
<router-link
|
||||
class="q-pa-lg"
|
||||
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
|
||||
>
|
||||
{{ flight.pilot.publicUsername }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
</FlightHeaderInfo>
|
||||
</div>
|
||||
</q-img>
|
||||
<LatestFlightSliderInfo v-else :flight="flight" text-color="primary"/>
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
</div>
|
||||
@@ -19,42 +19,11 @@ const formData = reactive({
|
||||
copilots: [],
|
||||
})
|
||||
|
||||
const solo = ref(false);
|
||||
watch(() => solo.value, (newVal) => {
|
||||
if (!newVal) {
|
||||
formData.copilots = [];
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
watch(() => props.flight, (newVal) => {
|
||||
if (newVal) {
|
||||
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
|
||||
solo.value = formData.copilots.length === 0;
|
||||
}
|
||||
}, {deep: true, immediate: true});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData">
|
||||
<q-btn-toggle
|
||||
spread
|
||||
no-caps
|
||||
v-model="solo"
|
||||
toggle-color="primary"
|
||||
:options="[{label: 'Solo', value: true}, {label: 'S někým', value: false}]"
|
||||
/>
|
||||
|
||||
<div v-if="!solo">
|
||||
<ComboboxInput
|
||||
v-for="i in flight.aircraft.seats - 1"
|
||||
:key="i"
|
||||
v-bind="commonProps"
|
||||
:options="copilots"
|
||||
v-model="formData.copilots[i-1]"
|
||||
:clearable="true"
|
||||
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
|
||||
/>
|
||||
</div>
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
import SlugInput from "components/inputs/SlugInput.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useLoggedUserStore} from "stores/LoggedUser";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
@@ -18,6 +19,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: false,
|
||||
},
|
||||
copilots: {
|
||||
type: Array,
|
||||
required: false,
|
||||
},
|
||||
isRequestPending: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -31,7 +36,8 @@ const formData = reactive({
|
||||
description: "",
|
||||
debriefingNotes: "",
|
||||
urlSlug: "",
|
||||
isPublic: null
|
||||
isPublic: null,
|
||||
copilots: []
|
||||
})
|
||||
|
||||
|
||||
@@ -47,6 +53,20 @@ watch(() => props.flight, (newVal) => {
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
const solo = ref(false);
|
||||
watch(() => solo.value, (newVal) => {
|
||||
if (!newVal) {
|
||||
formData.copilots = [];
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.flight, (newVal) => {
|
||||
if (newVal) {
|
||||
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
|
||||
solo.value = formData.copilots.length === 0;
|
||||
}
|
||||
}, {deep: true, immediate: true});
|
||||
|
||||
const descriptionTab = ref("description");
|
||||
</script>
|
||||
|
||||
@@ -66,18 +86,52 @@ const descriptionTab = ref("description");
|
||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
Letel jsi
|
||||
<q-radio
|
||||
v-model="solo"
|
||||
:val="true"
|
||||
label="Sólo"
|
||||
/>
|
||||
<q-radio
|
||||
v-model="solo"
|
||||
:val="false"
|
||||
label="S někým"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!solo">
|
||||
<ComboboxInput
|
||||
v-for="i in flight.aircraft.seats - 1"
|
||||
:key="i"
|
||||
v-bind="commonProps"
|
||||
:options="copilots"
|
||||
v-model="formData.copilots[i-1]"
|
||||
:clearable="true"
|
||||
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-tabs v-model="descriptionTab" active-color="primary">
|
||||
<q-tab name="description">Popis</q-tab>
|
||||
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
|
||||
</q-tabs>
|
||||
<q-tab-panels v-model="descriptionTab">
|
||||
<q-tab-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>
|
||||
|
||||
@@ -25,12 +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: "plan", icon: "pin_drop", label: "Plán"},
|
||||
{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"])
|
||||
@@ -38,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
|
||||
/>
|
||||
@@ -61,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>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-card class="q-my-sm">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
|
||||
<q-card class="q-mr-md-sm q-my-sm">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
<q-skeleton type="rect" width="100%" height="500px"/>
|
||||
</template>
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup>
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import {randomInt} from "src/composables/random";
|
||||
|
||||
const props = defineProps({
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="step === 'basic'">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-card class="q-my-sm">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
|
||||
<q-card class="q-mr-md-sm q-my-sm">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<q-card v-else-if="step === 'copilot'">
|
||||
<q-card class="q-mr-md-sm q-my-sm">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-card>
|
||||
|
||||
<template v-else-if="step === 'track'">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="step === 'photos'">
|
||||
<q-card class="q-my-lg">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="i in randomInt(3, 10)" :key="i">
|
||||
<q-card class="q-ma-md">
|
||||
<q-skeleton height="300px" type="rect"/>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<div v-else-if="step === 'notes'">
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
<q-skeleton type="rect" width="100%" height="500px"/>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup>
|
||||
import {flightName} from "src/composables/flight";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
textColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "white"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-h4">
|
||||
<router-link
|
||||
:class="`text-${props.textColor}`"
|
||||
:to="{name: 'publicFlightDetail', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
|
||||
>
|
||||
{{ flightName(flight) }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
:class="`text-${props.textColor}`"
|
||||
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
|
||||
>
|
||||
@{{ flight.pilot.publicUsername }}
|
||||
</router-link>
|
||||
</template>
|
||||
@@ -3,13 +3,16 @@
|
||||
|
||||
<template>
|
||||
<q-timeline color="primary" class="q-my-lg">
|
||||
<q-timeline-entry title="Naplánuj si let">
|
||||
<div>
|
||||
Pomocí jednoduchého plánovače si sestav trasu, označ si zajímavé body, připrav si navigační štítek a zkontroluj počasí.
|
||||
</div>
|
||||
</q-timeline-entry>
|
||||
|
||||
<q-timeline-entry title="Zaznamenej si let">
|
||||
<div>
|
||||
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
|
||||
</div>
|
||||
</q-timeline-entry>
|
||||
|
||||
<q-timeline-entry title="Ulož trasu">
|
||||
<div>
|
||||
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import {Marker} from "vue3-google-map";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {onMounted, ref, watch} from "vue";
|
||||
import MapWrapper from "components/map/MapWrapper.vue";
|
||||
import {useGeocode} from "src/composables/geocoding";
|
||||
import axios from "axios";
|
||||
@@ -32,22 +32,34 @@ const markerDropped = (position) => {
|
||||
}
|
||||
|
||||
const zoom = ref(8);
|
||||
onMounted(() => {
|
||||
if (props.gpsLatitude && props.gpsLongitude) {
|
||||
center.value.lng = props.gpsLongitude;
|
||||
center.value.lat = props.gpsLatitude
|
||||
} else if (props.label) {
|
||||
searchGeocode(props.label).then(results => {
|
||||
|
||||
|
||||
watch(() => [props.label, props.gpsLongitude, props.gpsLatitude], (val, oldValue) => {
|
||||
const [newAddress, newLon, newLat] = val
|
||||
const [previousAddress, oldLon, oldLat] = oldValue
|
||||
if (newAddress) {
|
||||
if (newAddress === previousAddress) return
|
||||
|
||||
searchGeocode(newAddress).then(results => {
|
||||
if (!results) return;
|
||||
center.value = results[0].geometry.location;
|
||||
|
||||
const lat = results[0].geometry.location.lat()
|
||||
const lng = results[0].geometry.location.lng()
|
||||
|
||||
center.value = {lng, lat}
|
||||
emitModels(center.value.lat, center.value.lng);
|
||||
zoom.value = 13;
|
||||
});
|
||||
} else if (newLat && newLon && (props.gpsLatitude !== newLat || props.gpsLongitude !== newLon)) {
|
||||
center.value.lng = newLon;
|
||||
center.value.lat = newLat
|
||||
} else {
|
||||
console.log(newAddress, newLon, newLat, props.label, props.gpsLongitude, props.gpsLatitude)
|
||||
console.error("Set either label or gps coordinates.");
|
||||
}
|
||||
})
|
||||
|
||||
},
|
||||
// {immediate: true}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
import {ref} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Object, Array]
|
||||
},
|
||||
})
|
||||
|
||||
const model = ref("");
|
||||
|
||||
const options = useAirportsStore().fetch();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxInput :options="options" v-model="model"
|
||||
</template>
|
||||
@@ -241,6 +241,11 @@ const isControlsOpened = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<!-- <q-banner>-->
|
||||
<!-- https://dev.to/quasar/how-to-use-google-maps-in-a-quasar-project-1c13-->
|
||||
<!-- </q-banner>-->
|
||||
|
||||
<GoogleMap
|
||||
:api-key="key"
|
||||
v-bind="$attrs"
|
||||
|
||||
@@ -19,6 +19,9 @@ const props = defineProps({
|
||||
copilots: {
|
||||
type: Array
|
||||
},
|
||||
loading: {
|
||||
type: Boolean
|
||||
}
|
||||
});
|
||||
|
||||
const formData = ref({
|
||||
@@ -44,7 +47,12 @@ const setup = () => {
|
||||
<q-card-section class="text-center">
|
||||
<q-img :src="photo.thumbnailUrl" width="80px" class="q-ma-sm" v-for="photo in photos" :key="photo.thumbnailUrl"/>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
|
||||
<q-card-section v-if="loading">
|
||||
<q-spinner />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-else>
|
||||
<FormWrapper :save-button="false">
|
||||
<ComboboxInput
|
||||
:options="locations"
|
||||
|
||||
@@ -18,6 +18,9 @@ const props = defineProps({
|
||||
},
|
||||
copilots: {
|
||||
type: Array
|
||||
},
|
||||
loading: {
|
||||
type: Boolean
|
||||
}
|
||||
});
|
||||
|
||||
@@ -38,7 +41,10 @@ const setup = (() => {
|
||||
<template>
|
||||
<DialogWrapper :formData="formData" :object-id="photoId" @show="setup">
|
||||
<q-img :src="photo.thumbnailUrl"/>
|
||||
<q-card-section>
|
||||
<q-card-section v-if="loading">
|
||||
<q-spinner />
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
<FormWrapper :save-button="false">
|
||||
<ComboboxInput
|
||||
:options="locations"
|
||||
|
||||
@@ -46,17 +46,6 @@ const initLightbox = () => {
|
||||
$lightbox.value.init();
|
||||
}
|
||||
|
||||
const isCollapsed = ref(true);
|
||||
const photosCollapseLimit = computed(() => {
|
||||
if ($q.screen.width <= $q.screen.sizes.sm) return 2;
|
||||
else if ($q.screen.width <= $q.screen.sizes.md) return 3;
|
||||
else return 4;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
isCollapsed.value = props.photos.length >= photosCollapseLimit.value;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if ($lightbox.value) {
|
||||
$lightbox.value.destroy();
|
||||
@@ -81,12 +70,12 @@ watch(() => [openedPhoto.value, $lightbox.value], () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{'photogallery--wrapper': true, collapsed: isCollapsed}" :id="`${galleryId}-wrapper`">
|
||||
<div :class="{'photogallery--wrapper': true}" :id="`${galleryId}-wrapper`">
|
||||
<div :id="galleryId" class="row photogallery--content">
|
||||
<div
|
||||
v-for="photo in photos" :key="photo.id"
|
||||
style="min-height: 100px"
|
||||
class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm"
|
||||
class="col-6 col-sm-4 col-md-4 col-lg-2 q-pa-sm"
|
||||
>
|
||||
<a
|
||||
:data-pswp-src="photo.url"
|
||||
@@ -111,9 +100,6 @@ watch(() => [openedPhoto.value, $lightbox.value], () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="photogallery--collapse" v-if="isCollapsed" @click="isCollapsed = false">
|
||||
<q-btn class="q-mt-lg q-mb-md" color="secondary">Rozbalit</q-btn>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -86,7 +86,7 @@ const showLarge = (displayedPhoto) => {
|
||||
</div>
|
||||
<q-btn flat icon="edit" color="blue" @click="openEdit(photo)"/>
|
||||
<q-btn flat icon="tune" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/>
|
||||
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"/>
|
||||
<q-btn flat icon="delete" color="red" :disabled="titlePhoto?.id === photo.id" @click="confirmPhotoDelete(photo.id)"/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
@@ -74,8 +74,18 @@ const handlePositionNotFound = () => {
|
||||
<template #append v-if="showResetMapButton">
|
||||
<q-btn @click="resetMap" icon="gps_fixed" round flat text-color="green" title="Nastav pozici na mapě"/>
|
||||
</template>
|
||||
<template #after>
|
||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<MapGPSInput
|
||||
:label="poiNameForMap"
|
||||
:zoom="zoom"
|
||||
v-model:gpsLatitude="formData.gpsLatitude"
|
||||
v-model:gpsLongitude="formData.gpsLongitude"
|
||||
@not-found="handlePositionNotFound"
|
||||
/>
|
||||
<SlugInput
|
||||
v-if="formData.isPublic"
|
||||
v-bind="commonProps"
|
||||
@@ -85,21 +95,11 @@ const handlePositionNotFound = () => {
|
||||
:slug-source="formData.name"
|
||||
:rules="rules.urlSlug"
|
||||
/>
|
||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||
|
||||
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
|
||||
|
||||
<MapGPSInput
|
||||
v-if="poiNameForMap"
|
||||
:key="poiNameForMap"
|
||||
:label="poiNameForMap"
|
||||
:zoom="zoom"
|
||||
v-model:gpsLatitude="formData.gpsLatitude"
|
||||
v-model:gpsLongitude="formData.gpsLongitude"
|
||||
@not-found="handlePositionNotFound"
|
||||
/>
|
||||
|
||||
<q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis"/>
|
||||
|
||||
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import LoadMoreList from "components/LoadMoreList.vue";
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import {useInfiniteScroll} from "src/composables/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
@@ -11,20 +10,7 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
totalItemsCount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 5,
|
||||
},
|
||||
isQueryRunning: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
...useInfiniteScroll().propsDef,
|
||||
});
|
||||
|
||||
const getRoute = (item) => {
|
||||
@@ -33,10 +19,17 @@ const getRoute = (item) => {
|
||||
else
|
||||
return {name: "poiDetail", params: {id: item.id}}
|
||||
};
|
||||
|
||||
const emits = defineEmits(useInfiniteScroll().emitsDef)
|
||||
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
|
||||
<template #items>
|
||||
<q-infinite-scroll
|
||||
:disable="!hasMoreData"
|
||||
@load="emitLoadMore"
|
||||
:offset="250"
|
||||
>
|
||||
<q-list bordered separator>
|
||||
<q-item clickable v-for="poi of items" :key="poi" :to="getRoute(poi)">
|
||||
<q-item-section avatar>
|
||||
@@ -56,12 +49,5 @@ const getRoute = (item) => {
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</template>
|
||||
<template #skeleton>
|
||||
<BasicListSkeleton :items="itemsPerPage"/>
|
||||
</template>
|
||||
<template #loadMoreText>
|
||||
Další
|
||||
</template>
|
||||
</LoadMoreList>
|
||||
</q-infinite-scroll>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import {ref} from "vue";
|
||||
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
|
||||
import {useUnitsConvertors} from "src/composables/units";
|
||||
|
||||
const props = defineProps({
|
||||
weather: {
|
||||
@@ -9,9 +10,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const kmhToKnots = (speedKmh) => {
|
||||
return Math.round(speedKmh * 0.53996);
|
||||
}
|
||||
const {kmhToKnots} = useUnitsConvertors();
|
||||
|
||||
const openDetail = ref(false);
|
||||
</script>
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
import {ref} from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
export const useGeocode = () => {
|
||||
|
||||
const center = ref({lat: 50.1314, lng: 14.5256});
|
||||
|
||||
const getGeocoder = (limitMs = 5000) => {
|
||||
let interval = null
|
||||
const poolingWindowMs = 100
|
||||
|
||||
const searchGeocode = (address) => {
|
||||
return axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURI(address)}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => {
|
||||
if (response.data.status !== 'OK') {
|
||||
return null;
|
||||
const tries = limitMs / poolingWindowMs
|
||||
let currentTry = 0
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let instance = null
|
||||
interval = setInterval(() => {
|
||||
try {
|
||||
instance = new google.maps.Geocoder()
|
||||
|
||||
clearInterval(interval)
|
||||
return resolve(instance)
|
||||
|
||||
} catch (e) {
|
||||
if (++currentTry >= tries) {
|
||||
clearInterval(interval)
|
||||
return reject(instance)
|
||||
}
|
||||
}
|
||||
}, poolingWindowMs)
|
||||
})
|
||||
}
|
||||
|
||||
return response.data.results;
|
||||
});
|
||||
}
|
||||
const searchGeocode = (address) => new Promise((resolve, reject) => {
|
||||
getGeocoder().then(geocoder => geocoder
|
||||
.geocode({address})
|
||||
.then(({results}) => resolve(results))
|
||||
.catch((e) => reject(e))).catch(() => {
|
||||
console.log("Cannot get geocoder")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
const searchPlaceName = (lat, lng) => {
|
||||
return axios.get(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => {
|
||||
if (response.data.status !== 'OK' || !response.data.results?.length) {
|
||||
return null;
|
||||
}
|
||||
const latlng = {
|
||||
lat: parseFloat(lat), lng: parseFloat(lng),
|
||||
};
|
||||
|
||||
const results = response.data.results;
|
||||
return new Promise((resolve, reject) => {
|
||||
getGeocoder().then((geocoder) => geocoder
|
||||
.geocode({location: latlng})
|
||||
.then((response) => {
|
||||
const results = response.results;
|
||||
const components = {};
|
||||
for (const addressComponent of results[0].address_components) {
|
||||
for (const namePartType of ["locality", "postal_town", 'administrative_area_level_2']) {
|
||||
@@ -33,11 +59,15 @@ export const useGeocode = () => {
|
||||
}
|
||||
|
||||
if (!Object.keys(components).length) {
|
||||
return "Bod na mapě";
|
||||
return resolve("Bod na mapě");
|
||||
}
|
||||
|
||||
return Object.values(components).join(", ");
|
||||
});
|
||||
return resolve(Object.values(components).join(", "));
|
||||
})
|
||||
.catch((e) => reject(e))
|
||||
)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
return {center, searchGeocode, searchPlaceName}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const commonKeywords = [
|
||||
|
||||
|
||||
export const aircraftKeywords = (aircraft) => {
|
||||
return [aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
|
||||
return ["letadlo", "letoun", aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
|
||||
};
|
||||
|
||||
export const flightEventKeywords = (event) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ref} from "vue";
|
||||
import {computed, ref} from "vue";
|
||||
|
||||
|
||||
export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
|
||||
@@ -9,19 +9,72 @@ export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
|
||||
const paginationLimit = ref(itemsPerPage);
|
||||
const paginationOffset = ref(0);
|
||||
const filters = ref({});
|
||||
const hasMoreData = ref(true)
|
||||
const isQueryRunning = ref(false)
|
||||
|
||||
const loadPage = (appendItems) => {
|
||||
|
||||
const loadFirstPage = () => {
|
||||
paginationOffset.value = 0
|
||||
isQueryRunning.value = true
|
||||
return fetchFunction({
|
||||
...filters.value,
|
||||
limit: paginationLimit.value,
|
||||
offset: paginationOffset.value,
|
||||
appendItems
|
||||
}).then(() => {
|
||||
...filters.value, limit: paginationLimit.value, offset: 0, appendItems: false
|
||||
}).then((data) => {
|
||||
paginationOffset.value += paginationLimit.value;
|
||||
hasMoreData.value = data.length === paginationLimit.value;
|
||||
})
|
||||
.finally(() => {
|
||||
isQueryRunning.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const loadPage = (index, done) => {
|
||||
if (isQueryRunning.value) return
|
||||
|
||||
isQueryRunning.value = true
|
||||
return fetchFunction({
|
||||
...filters.value, appendItems: true, limit: paginationLimit.value, offset: paginationOffset.value,
|
||||
}).then((data) => {
|
||||
paginationOffset.value += paginationLimit.value;
|
||||
hasMoreData.value = data.length === paginationLimit.value;
|
||||
if (done) done()
|
||||
})
|
||||
.finally(() => {
|
||||
isQueryRunning.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleFiltersChanged = (newFilters) => {
|
||||
filters.value = newFilters;
|
||||
loadFirstPage()
|
||||
}
|
||||
|
||||
return {
|
||||
paginationOffset, paginationLimit, filters, loadPage
|
||||
paginationOffset, paginationLimit, handleFiltersChanged, filters, loadPage, loadFirstPage, hasMoreData
|
||||
}
|
||||
}
|
||||
|
||||
export const useLoading = (promiseGetter) => {
|
||||
const loading = ref(false)
|
||||
const load = () => {
|
||||
loading.value = true
|
||||
return promiseGetter().finally(() => loading.value = false)
|
||||
}
|
||||
|
||||
return {loading, load}
|
||||
}
|
||||
|
||||
|
||||
export const useInfiniteScroll = (emits, props) => {
|
||||
const emitsDef = ["load-more"]
|
||||
const propsDef = {
|
||||
hasMoreData: {
|
||||
type: Boolean, required: false, default: true
|
||||
}
|
||||
}
|
||||
|
||||
const hasMoreData = computed(() => props.hasMoreData)
|
||||
const emitLoadMore = (index, done) => emits("load-more", {index, done});
|
||||
|
||||
return {emitsDef, propsDef, hasMoreData, emitLoadMore}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
export const ktToKph = (speedInKt) => {
|
||||
return speedInKt;
|
||||
export const useUnitsConvertors = () => {
|
||||
|
||||
const kmhToKnots = (speedKmh) => {
|
||||
return Math.round(speedKmh * 0.53996);
|
||||
}
|
||||
|
||||
const ftToM = (distanceInFt) => {
|
||||
return Math.round(distanceInFt * 0.3048 * 10) / 10;
|
||||
}
|
||||
|
||||
return {kmhToKnots, ftToM}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
export const FRAGMENT_AIRCRAFT_BASIC_INFO = gql`
|
||||
fragment aircraftBasicInfo on Aircraft {
|
||||
fragment aircraftBasicInfo on Aircraft {
|
||||
id
|
||||
callSign
|
||||
manufacturer
|
||||
@@ -10,5 +10,5 @@ fragment aircraftBasicInfo on Aircraft {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
export const FRAGMENT_AIRPORT_LIST_INFO = gql`
|
||||
fragment airportListInfo on Airport {
|
||||
id
|
||||
name
|
||||
icaoCode
|
||||
gpsLatitude
|
||||
gpsLongitude
|
||||
elevation
|
||||
iconUrl
|
||||
}
|
||||
`;
|
||||
|
||||
export const FRAGMENT_AIRPORT_INFO = gql`
|
||||
fragment airportInfo on Airport {
|
||||
id
|
||||
|
||||
@@ -7,7 +7,7 @@ import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
|
||||
|
||||
export const FRAGMENT_WEATHER_FULL_INFO = gql`
|
||||
fragment weatherFullInfo on WeatherInfo {
|
||||
datetime
|
||||
dateTime
|
||||
qnh
|
||||
temperatureSurface
|
||||
dewpointSurface
|
||||
@@ -30,7 +30,6 @@ fragment flightFullInfo on Flight {
|
||||
durationMinCalculated
|
||||
isPublic
|
||||
flightPlanId
|
||||
|
||||
socialImageUrl
|
||||
titlePhoto {
|
||||
thumbnailUrl
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_WEATHER_FULL_INFO} from "src/graphql/fragments/flight";
|
||||
|
||||
export const FRAGMENT_FLIGHT_PLAN_MARKER = gql`
|
||||
fragment flightPlanMarker on FlightPlanMarker {
|
||||
@@ -9,7 +10,13 @@ fragment flightPlanMarker on FlightPlanMarker {
|
||||
gpsLongitude
|
||||
pointOfInterestId
|
||||
airportId
|
||||
|
||||
weatherInfo {
|
||||
...weatherFullInfo
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_WEATHER_FULL_INFO}
|
||||
`
|
||||
|
||||
export const FRAGMENT_FLIGHT_PLAN_FULL_INFO = gql`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_AIRPORT_INFO} from "src/graphql/fragments/airport";
|
||||
import {FRAGMENT_AIRPORT_LIST_INFO} from "src/graphql/fragments/airport";
|
||||
import {FRAGMENT_AIRCRAFT_BASIC_INFO} from "src/graphql/fragments/aircraft_basic";
|
||||
|
||||
export const FRAGMENT_FLIGHT_LIST_INFO = gql`
|
||||
@@ -33,11 +33,11 @@ fragment flightListInfo on Flight {
|
||||
}
|
||||
|
||||
takeoffAirport {
|
||||
...airportInfo
|
||||
...airportListInfo
|
||||
}
|
||||
|
||||
landingAirport {
|
||||
...airportInfo
|
||||
...airportListInfo
|
||||
}
|
||||
|
||||
aircraft {
|
||||
@@ -45,7 +45,7 @@ fragment flightListInfo on Flight {
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_AIRPORT_INFO}
|
||||
${FRAGMENT_AIRPORT_LIST_INFO}
|
||||
${FRAGMENT_AIRCRAFT_BASIC_INFO}
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,19 +2,17 @@ import gql from "graphql-tag";
|
||||
import {FRAGMENT_AIRCRAFT_FULL_INFO, FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO, FRAGMENT_AIRCRAFT_LIST_INFO} from "src/graphql/fragments/aircraft";
|
||||
|
||||
export const AIRCRAFTS_LIST = gql`
|
||||
query AircraftsList ($limit: Int!, $offset: Int) {
|
||||
aircrafts(limit: $limit, offset: $offset) {
|
||||
query AircraftsList ($search: String, $limit: Int!, $offset: Int) {
|
||||
aircrafts(search: $search, limit: $limit, offset: $offset) {
|
||||
totalItemsCount
|
||||
items {
|
||||
...aircraftListInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_AIRCRAFT_LIST_INFO}
|
||||
`;
|
||||
|
||||
|
||||
export const AIRCRAFT_DETAIL = gql`
|
||||
query AircraftDetail($id: Int!) {
|
||||
aircraft(id: $id) {
|
||||
|
||||
@@ -25,7 +25,8 @@ query eventsFlightsTracks($eventId: Int, $eventUrlSlug: String, $onlyPublic: Boo
|
||||
event(id: $eventId, urlSlug: $eventUrlSlug, public: $onlyPublic) {
|
||||
flights(onlyPublic: $onlyPublic) {
|
||||
durationMinCalculated
|
||||
tracks {
|
||||
track {
|
||||
points {
|
||||
coordinates {
|
||||
lat
|
||||
lng
|
||||
@@ -33,6 +34,7 @@ query eventsFlightsTracks($eventId: Int, $eventUrlSlug: String, $onlyPublic: Boo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@ import gql from "graphql-tag";
|
||||
import {FRAGMENT_FLIGHT_PLAN_FULL_INFO, FRAGMENT_FLIGHT_PLAN_LIST_INFO} from "src/graphql/fragments/flightPlan";
|
||||
|
||||
export const FLIGHT_PLANS_LIST = gql`
|
||||
query flightPlansList {
|
||||
flightPlans {
|
||||
query flightPlansList($search: String, $limit: Int!, $offset: Int) {
|
||||
flightPlans(search: $search, limit: $limit, offset: $offset) {
|
||||
totalItemsCount
|
||||
items {
|
||||
...flightPlanListInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_FLIGHT_PLAN_LIST_INFO}
|
||||
${FRAGMENT_FLIGHT_PLAN_LIST_INFO}
|
||||
`;
|
||||
|
||||
export const FLIGHT_PLAN_DETAIL = gql`
|
||||
|
||||
@@ -5,12 +5,13 @@ import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
|
||||
import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
|
||||
|
||||
export const FLIGHTS_LIST = gql`
|
||||
query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $limit: Int!, $offset: Int) {
|
||||
query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $search: String, $limit: Int!, $offset: Int) {
|
||||
flights(
|
||||
eventId: $eventId,
|
||||
aircraftId: $aircraftId,
|
||||
copilotId: $copilotId,
|
||||
pointOfInterestId: $pointOfInterestId,
|
||||
search: $search,
|
||||
limit: $limit,
|
||||
offset: $offset
|
||||
) {
|
||||
|
||||
@@ -60,10 +60,10 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
||||
});
|
||||
|
||||
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
filters.value = {aircraftId: aircraftId};
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
loadFirstPage();
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
<script setup>
|
||||
import AircraftsList from "components/aircrafts/AircraftsList.vue";
|
||||
import FAB from "components/FAB.vue";
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
import {useAircraftsStore} from "stores/Aircrafts";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {usePagination} from "src/composables/pagination";
|
||||
import {onMounted} from "vue";
|
||||
import ListFilters from "components/ListFilters.vue";
|
||||
import PrimaryAction from "components/PrimaryAction.vue";
|
||||
|
||||
useAppStore().setPageTitle("Letadla");
|
||||
useAppStore().setBreadcrumbs([])
|
||||
|
||||
const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({fetchFunction: useAircraftsStore().fetch});
|
||||
const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsStore());
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useAircraftsStore().fetch});
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicListSkeleton :items="paginationLimit" v-if="isQueryRunning && !totalItemsCount"/>
|
||||
<ListFilters @filters-changed="handleFiltersChanged">
|
||||
<template #append>
|
||||
<PrimaryAction mode="add" :router-link="{name: 'addAircraft'}"/>
|
||||
</template>
|
||||
</ListFilters>
|
||||
|
||||
<BasicListSkeleton :items="5" v-if="isQueryRunning && !totalItemsCount"/>
|
||||
|
||||
<BasicInfoBanner
|
||||
v-if="!isQueryRunning && totalItemsCount === 0"
|
||||
:route-add="{name: 'addAircraft'}"
|
||||
label-add="Vytvořit letadlo"
|
||||
label-add="Přidat letadlo"
|
||||
>
|
||||
Aktuálně nemáš přidané žádné letadlo. Přidej se k existující organizaci nebo si sem ulož vlastní letadlo.
|
||||
<br>
|
||||
@@ -35,13 +38,8 @@ onMounted(() => {
|
||||
<AircraftsList
|
||||
v-else
|
||||
:items="aircrafts"
|
||||
:total-items-count="totalItemsCount"
|
||||
:is-query-running="isQueryRunning"
|
||||
:items-per-page="paginationLimit"
|
||||
:public="true"
|
||||
class="q-mb-xl"
|
||||
@load-more="loadPage(true)"
|
||||
:has-more-data="hasMoreData"
|
||||
@load-more="loadPage($event.index, $event.done)"
|
||||
/>
|
||||
|
||||
<FAB mode="add" :router-link="{name: 'addAircraft'}"/>
|
||||
</template>
|
||||
|
||||
@@ -40,10 +40,10 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
||||
$q, store: useCopilotsStore(), object: copilot.value, variables
|
||||
});
|
||||
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
filters.value = {copilotId: copilotId};
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
loadFirstPage();
|
||||
});
|
||||
|
||||
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
||||
|
||||
@@ -26,7 +26,7 @@ onMounted(() => {
|
||||
{label: event.value.name}
|
||||
])
|
||||
});
|
||||
loadPage(false);
|
||||
loadFirstPage();
|
||||
})
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
||||
|
||||
const {isQueryRunning, event} = storeToRefs(useEventsStore());
|
||||
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
filters.value = {eventId: eventId}
|
||||
|
||||
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
||||
|
||||
@@ -7,53 +7,19 @@ export default {
|
||||
return useFlightsStore().fetchDetail(flightId);
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
|
||||
import {onMounted, ref, toRefs, watch} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
|
||||
import {useAircraftsStore} from "stores/Aircrafts";
|
||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import {useQuasar} from "quasar";
|
||||
import {storeToRefs} from "pinia";
|
||||
import FlightFormCopilot from "components/flights/form/FlightFormCopilot.vue";
|
||||
import {useCopilotsStore} from "stores/Copilots";
|
||||
import FlightFormNotes from "components/flights/form/FlightFormNotes.vue";
|
||||
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
|
||||
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
|
||||
import {errorObject, successObject, warningObject} from "src/composables/dialog";
|
||||
import PhotoFormDialog from "components/photos/PhotoFormDialog.vue";
|
||||
import FlightFormTrack from "components/flights/form/FlightFormTrack.vue";
|
||||
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
|
||||
import BatchPhotoFormDialog from "components/photos/BatchPhotoFormDialog.vue";
|
||||
import {useEventsStore} from "stores/Events";
|
||||
import FlightEditCopilotSkeleton from "components/flights/skeletons/FlightEditCopilotSkeleton.vue";
|
||||
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
|
||||
import {usePhotosStore} from "stores/Photos";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {flightName} from "src/composables/flight";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||
|
||||
const $q = useQuasar();
|
||||
const $route = useRoute();
|
||||
const flightId = parseInt($route.params.id);
|
||||
|
||||
const {aircrafts} = storeToRefs(useAircraftsStore());
|
||||
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
||||
const {airports} = storeToRefs(useAirportsStore());
|
||||
const {copilots} = storeToRefs(useCopilotsStore());
|
||||
const {events} = storeToRefs(useEventsStore());
|
||||
const {photos} = storeToRefs(usePhotosStore());
|
||||
const {flightPlan, flightPlans} = storeToRefs(useFlightPlansStore());
|
||||
const {flight, isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
|
||||
|
||||
const formStep = ref("basic");
|
||||
watch(() => $route.params.step, (val) => formStep.value = val);
|
||||
|
||||
const flightNameFormatted = flightName(flight.value);
|
||||
useAppStore().setPageTitle(`Editace - ${flightNameFormatted}`);
|
||||
useAppStore().setBreadcrumbs([
|
||||
@@ -62,192 +28,18 @@ useAppStore().setBreadcrumbs([
|
||||
{label: "Editace"}
|
||||
]);
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
photos.value = flight.value.photos;
|
||||
|
||||
formStep.value = $route.params.step || "basic";
|
||||
useAircraftsStore().fetch({});
|
||||
usePointsOfInterestStore().fetch({});
|
||||
useAirportsStore().fetch();
|
||||
useCopilotsStore().fetch();
|
||||
useEventsStore().fetch({});
|
||||
|
||||
useFlightPlansStore().fetch();
|
||||
if (flight.value.flightPlanId) {
|
||||
useFlightPlansStore().fetchDetail(flight.value.flightPlanId);
|
||||
}
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const $route = useRoute();
|
||||
const handleFlightEdit = (data) => {
|
||||
const flightId = parseInt($route.params.id);
|
||||
useFlightsStore().update(flightId, data).then((data) => {
|
||||
$q.notify(successObject("Úspěšně upraveno"));
|
||||
})
|
||||
}
|
||||
const {
|
||||
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
|
||||
} = useBasicPhotoOperations({});
|
||||
|
||||
const setTitlePhoto = (photo) => {
|
||||
useFlightsStore().update(flight.value.id, {titlePhotoId: photo.id}).then(f => {
|
||||
$q.notify(successObject("Úvodní fotografie úspěšně nastavena"));
|
||||
});
|
||||
}
|
||||
|
||||
const handlePhotoUpdate = (flightId, formData) => {
|
||||
const photoId = formData.id;
|
||||
delete formData.id;
|
||||
|
||||
return updateSinglePhoto(photoId, formData);
|
||||
};
|
||||
|
||||
const openEdit = ref(false);
|
||||
const editPhoto = ref(null);
|
||||
|
||||
const openPhotoEdit = (photo) => {
|
||||
openEdit.value = true;
|
||||
editPhoto.value = photo;
|
||||
};
|
||||
|
||||
|
||||
// batch edit
|
||||
const openBatchEdit = ref(false);
|
||||
const editBatchPhotos = ref([]);
|
||||
const openBatchPhotoForm = (photos) => {
|
||||
openBatchEdit.value = true;
|
||||
editBatchPhotos.value = photos;
|
||||
}
|
||||
|
||||
const handleBatchPhotoEdit = async (data) => {
|
||||
const photoIds = data.photoIds;
|
||||
delete data.photoIds;
|
||||
delete data.id;
|
||||
|
||||
if (data.pointOfInterest && !data.pointOfInterest.id) {
|
||||
const poiData = {
|
||||
...data.pointOfInterest,
|
||||
isPublic: false,
|
||||
description: "",
|
||||
urlSlug: "",
|
||||
}
|
||||
await usePointsOfInterestStore().create(poiData).then(poi => data.pointOfInterest = {id: poi.id, name: poi.name});
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (const id of photoIds) {
|
||||
const updateData = {
|
||||
isAircraft: data.isAircraft,
|
||||
copilots: data.copilots.map(c => ({id: c.id, name: c.name}))
|
||||
};
|
||||
if (data.name !== '') updateData.name = data.name;
|
||||
if (data.description !== '') updateData.description = data.description;
|
||||
if (data.pointOfInterest) updateData.pointOfInterest = data.pointOfInterest;
|
||||
|
||||
promises.push(updateSinglePhoto(id, data));
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
Promise.allSettled(promises).then(result => {
|
||||
for (const item of result) {
|
||||
if (item.status === 'fulfilled') success++;
|
||||
}
|
||||
const error = result.length - success;
|
||||
|
||||
if (success > 0) $q.notify(successObject("Upraveno " + success + " fotografií"));
|
||||
if (error > 0) $q.notify(errorObject(`Nepovedlo se uložit ${error} fotografií`))
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightFormSteps :router-edit-mode="true" :flightId="flightId"/>
|
||||
<FlightFormSteps :router-edit-mode="true" :flightId="flight.id"/>
|
||||
|
||||
<q-tab-panels v-model="formStep">
|
||||
<q-tab-panel name="basic">
|
||||
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
|
||||
<FlightFormBasic
|
||||
v-else
|
||||
@save="handleFlightEdit"
|
||||
:flight="flight"
|
||||
:locations="pointsOfInterest"
|
||||
:airports="airports"
|
||||
:aircrafts="aircrafts"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="copilot">
|
||||
<FlightEditCopilotSkeleton v-if="isQueryRunning || !flight"/>
|
||||
<FlightFormCopilot
|
||||
v-else
|
||||
@save="handleFlightEdit"
|
||||
:flight="flight"
|
||||
:copilots="copilots"/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="track">
|
||||
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
|
||||
<FlightFormTrack
|
||||
v-if="flight && pointsOfInterest"
|
||||
:flight="flight"
|
||||
:locations="pointsOfInterest"
|
||||
:airports="airports"
|
||||
@save="handleFlightEdit"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="photos">
|
||||
<FlightFormPhotos
|
||||
:flight="flight"
|
||||
:is-request-pending="uploadInProgress"
|
||||
@save="handlePhotosUpload"
|
||||
/>
|
||||
|
||||
<template v-if="flight">
|
||||
<PhotosEditorList
|
||||
:title-photo="flight.titlePhoto"
|
||||
:photos="photos"
|
||||
:show-actions="true"
|
||||
@edit="openPhotoEdit"
|
||||
@batch-edit="openBatchPhotoForm"
|
||||
@batch-rotate="batchRotatePhotos"
|
||||
@delete="deletePhoto"
|
||||
@set-title-photo="setTitlePhoto"
|
||||
/>
|
||||
|
||||
<!-- Dialogs for photo edit -->
|
||||
<PhotoFormDialog
|
||||
v-model="openEdit"
|
||||
:copilots="flight.copilots"
|
||||
:aircrafts="aircrafts"
|
||||
:locations="pointsOfInterest"
|
||||
:photo="editPhoto"
|
||||
@save="handlePhotoUpdate(flightId, $event)"
|
||||
/>
|
||||
|
||||
<BatchPhotoFormDialog
|
||||
v-model="openBatchEdit"
|
||||
:locations="pointsOfInterest"
|
||||
:photos="editBatchPhotos"
|
||||
:copilots="flight.copilots"
|
||||
:aircrafts="aircrafts"
|
||||
@save="handleBatchPhotoEdit"
|
||||
@delete="deletePhoto"
|
||||
/>
|
||||
|
||||
</template>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="notes">
|
||||
<FlightFormNotes @save="handleFlightEdit" :events="events" :flight="flight"/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="plan">
|
||||
<template v-if="flight.flightPlanId">
|
||||
{{ flightPlan.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Bez planu
|
||||
</template>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
<router-view :flight="flight" @save="handleFlightEdit"/>
|
||||
</template>
|
||||
|
||||
@@ -101,7 +101,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
||||
<template #content>
|
||||
<q-card-section>
|
||||
<ContentCardEditButton
|
||||
:to="{name: 'flightEdit', params: {id: flightId, step: 'basic'}}"
|
||||
:to="{name: 'editFlightBasic', params: {id: flightId}}"
|
||||
label="Upravit informace"
|
||||
/>
|
||||
<FlightBasicInfo
|
||||
@@ -114,7 +114,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
||||
/>
|
||||
|
||||
<ContentCardEditButton
|
||||
:to="{name: 'flightEdit', params: {id: flightId, step: 'notes'}}"
|
||||
:to="{name: 'editFlightNotes', params: {id: flightId}}"
|
||||
label="Upravit text"
|
||||
/>
|
||||
<article class="q-my-lg" v-html="flight.description"/>
|
||||
@@ -129,12 +129,12 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
||||
<q-separator/>
|
||||
|
||||
<ContentCardEditButton
|
||||
:to="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
|
||||
:to="{name: 'editFlightPhotos', params: {id: flightId}}"
|
||||
label="Upravit fotografie"
|
||||
/>
|
||||
<BasicInfoBanner
|
||||
v-if="flight.photos.length === 0"
|
||||
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
|
||||
:route-add="{name: 'editFlightPhotos', params: {id: flightId,}}"
|
||||
>
|
||||
U tohoto letu nemáš nahrané žádné fotografie.
|
||||
</BasicInfoBanner>
|
||||
@@ -145,12 +145,12 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
||||
<q-separator/>
|
||||
|
||||
<ContentCardEditButton
|
||||
:to="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
|
||||
:to="{name: 'editFlightTrack', params: {id: flightId}}"
|
||||
label="Upravit trasu"
|
||||
/>
|
||||
<BasicInfoBanner
|
||||
v-if="flight.turnPoints.length === 0 && !flight.track"
|
||||
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
|
||||
:route-add="{name: 'editFlightTrack', params: {id: flightId}}"
|
||||
>
|
||||
Nemáš nastavenou žádnou trasu.
|
||||
</BasicInfoBanner>
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
<script setup>
|
||||
import FlightsList from "components/flights/FlightsList.vue";
|
||||
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
|
||||
import FAB from "components/FAB.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {usePagination} from "src/composables/pagination";
|
||||
import {onMounted} from "vue";
|
||||
import PrimaryAction from "components/PrimaryAction.vue";
|
||||
import ListFilters from "components/ListFilters.vue";
|
||||
|
||||
useAppStore().setPageTitle("Moje lety");
|
||||
useAppStore().setBreadcrumbs([]);
|
||||
|
||||
const {loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
});
|
||||
const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore());
|
||||
const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
const {flights, isQueryRunning} = storeToRefs(useFlightsStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListFilters @filters-changed="handleFiltersChanged">
|
||||
<template #append>
|
||||
<PrimaryAction mode="add" :router-link="{name: 'addFlight'}"/>
|
||||
</template>
|
||||
</ListFilters>
|
||||
|
||||
<FlightsListSkeleton v-if="isQueryRunning && flights.length === 0"/>
|
||||
|
||||
<BasicInfoBanner
|
||||
v-else-if="!isQueryRunning && totalItemsCount === 0"
|
||||
v-else-if="!isQueryRunning && flights.length === 0"
|
||||
:route-add="{name: 'addFlight'}"
|
||||
label-add="Přidat let"
|
||||
>
|
||||
@@ -34,11 +37,7 @@ const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore()
|
||||
v-else
|
||||
:items="flights"
|
||||
:public="false"
|
||||
:is-query-running="isQueryRunning"
|
||||
:items-per-page="paginationLimit"
|
||||
:total-items-count="totalItemsCount"
|
||||
@load-more="loadPage(true)"
|
||||
:has-more-data="hasMoreData"
|
||||
@load-more="loadPage($event.index, $event.done)"
|
||||
/>
|
||||
|
||||
<FAB mode="add" :router-link="{name: 'addFlight'}"/>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {useAirportsStore} from "stores/Airports";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
|
||||
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
|
||||
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
|
||||
import FlightEditBasicInfoSkeleton from "components/flights/skeletons/FlightEditBasicInfoSkeleton.vue";
|
||||
|
||||
useAppStore().setPageTitle("Nový let");
|
||||
useAppStore().setBreadcrumbs([
|
||||
@@ -32,10 +32,10 @@ const loading = computed(() => [
|
||||
useAircraftsStore().isQueryRunning, usePointsOfInterestStore().isQueryRunning, useAirportsStore().isQueryRunning
|
||||
].some(i => i.value))
|
||||
|
||||
const router = useRouter();
|
||||
const $router = useRouter();
|
||||
const handleSave = (formData) => {
|
||||
useFlightsStore().create(formData).then((flight) => {
|
||||
router.push({name: "flightEdit", params: {id: flight.id, step: "copilot"}});
|
||||
$router.push({name: "editFlightPhotos", params: {id: flight.id}});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -43,14 +43,13 @@ const handleSave = (formData) => {
|
||||
<template>
|
||||
<FlightFormSteps :disabled="['copilot', 'track', 'photos', 'notes']"/>
|
||||
|
||||
<FlightEditSkeleton step="basic" v-if="loading"/>
|
||||
<template v-else>
|
||||
<FlightEditBasicInfoSkeleton v-if="loading"/>
|
||||
<FlightFormBasic
|
||||
@save="handleSave"
|
||||
v-else
|
||||
:is-request-pending="isMutationRunning"
|
||||
:aircrafts="aircrafts"
|
||||
:airports="airports"
|
||||
:locations="pointsOfInterest"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
|
||||
import {onMounted} from "vue";
|
||||
import {useAircraftsStore} from "stores/Aircrafts";
|
||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
import FlightEditBasicInfoSkeleton from "components/flights/skeletons/FlightEditBasicInfoSkeleton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
onMounted(() => {
|
||||
useAircraftsStore().fetch({});
|
||||
usePointsOfInterestStore().fetch({});
|
||||
useAirportsStore().fetch();
|
||||
});
|
||||
const emits = defineEmits(["save"]);
|
||||
|
||||
const {aircrafts} = storeToRefs(useAircraftsStore());
|
||||
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
||||
const {airports} = storeToRefs(useAirportsStore());
|
||||
const {isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightEditBasicInfoSkeleton v-if="isQueryRunning || !flight"/>
|
||||
<FlightFormBasic
|
||||
v-else
|
||||
:flight="flight"
|
||||
:locations="pointsOfInterest"
|
||||
:airports="airports"
|
||||
:aircrafts="aircrafts"
|
||||
@save="emits('save', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import FlightFormNotes from "components/flights/form/FlightFormNotes.vue";
|
||||
import {computed, onMounted} from "vue";
|
||||
import {useEventsStore} from "stores/Events";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useCopilotsStore} from "stores/Copilots";
|
||||
import FlightEditExtraInfoSkeleton from "components/flights/skeletons/FlightEditExtraInfoSkeleton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["save"]);
|
||||
|
||||
onMounted(() => {
|
||||
useEventsStore().fetch({});
|
||||
useCopilotsStore().fetch();
|
||||
});
|
||||
|
||||
const {events, isQueryRunning: loadingEvents} = storeToRefs(useEventsStore());
|
||||
const {copilots, isQueryRunning: loadingCopilots} = storeToRefs(useCopilotsStore())
|
||||
|
||||
const isLoading = computed(() => loadingCopilots.value || loadingEvents.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightEditExtraInfoSkeleton v-if="isLoading" />
|
||||
<FlightFormNotes
|
||||
v-else
|
||||
:events="events"
|
||||
:flight="flight"
|
||||
:copilots="copilots"
|
||||
@save="emits('save', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import BatchPhotoFormDialog from "components/photos/BatchPhotoFormDialog.vue";
|
||||
import PhotoFormDialog from "components/photos/PhotoFormDialog.vue";
|
||||
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
|
||||
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
|
||||
import {errorObject, successObject} from "src/composables/dialog";
|
||||
import {computed, onMounted, ref, watch} from "vue";
|
||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useAircraftsStore} from "stores/Aircrafts";
|
||||
import {usePhotosStore} from "stores/Photos";
|
||||
import {useQuasar} from "quasar";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["save"]);
|
||||
|
||||
onMounted(() => {
|
||||
photos.value = props.flight.photos;
|
||||
})
|
||||
|
||||
const {aircrafts, isQueryRunning: aircraftsLoading} = storeToRefs(useAircraftsStore());
|
||||
const {pointsOfInterest, isQueryRunning: poisLoading} = storeToRefs(usePointsOfInterestStore());
|
||||
const {photos, isQueryRunning: photosLoading} = storeToRefs(usePhotosStore());
|
||||
|
||||
const isLoading = computed(() => aircraftsLoading.value || poisLoading.value || photosLoading.value);
|
||||
|
||||
const $q = useQuasar();
|
||||
const setTitlePhoto = (photo) => {
|
||||
useFlightsStore().update(props.flight.id, {titlePhotoId: photo.id}).then(f => {
|
||||
$q.notify(successObject("Úvodní fotografie úspěšně nastavena"));
|
||||
});
|
||||
}
|
||||
|
||||
const handlePhotoUpdate = (flightId, formData) => {
|
||||
const photoId = formData.id;
|
||||
delete formData.id;
|
||||
|
||||
return updateSinglePhoto(photoId, formData);
|
||||
};
|
||||
|
||||
const openEdit = ref(false);
|
||||
const editPhoto = ref(null);
|
||||
|
||||
const openPhotoEdit = (photo) => {
|
||||
openEdit.value = true;
|
||||
editPhoto.value = photo;
|
||||
};
|
||||
|
||||
const {
|
||||
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
|
||||
} = useBasicPhotoOperations({});
|
||||
|
||||
|
||||
// batch edit
|
||||
const openBatchEdit = ref(false);
|
||||
const editBatchPhotos = ref([]);
|
||||
const openBatchPhotoForm = (photos) => {
|
||||
openBatchEdit.value = true;
|
||||
editBatchPhotos.value = photos;
|
||||
}
|
||||
|
||||
const handleBatchPhotoEdit = async (data) => {
|
||||
const photoIds = data.photoIds;
|
||||
delete data.photoIds;
|
||||
delete data.id;
|
||||
|
||||
if (data.pointOfInterest && !data.pointOfInterest.id) {
|
||||
const poiData = {
|
||||
...data.pointOfInterest,
|
||||
isPublic: false,
|
||||
description: "",
|
||||
urlSlug: "",
|
||||
}
|
||||
await usePointsOfInterestStore().create(poiData).then(poi => data.pointOfInterest = {id: poi.id, name: poi.name});
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (const id of photoIds) {
|
||||
const updateData = {
|
||||
isAircraft: data.isAircraft,
|
||||
copilots: data.copilots.map(c => ({id: c.id, name: c.name}))
|
||||
};
|
||||
if (data.name !== '') updateData.name = data.name;
|
||||
if (data.description !== '') updateData.description = data.description;
|
||||
if (data.pointOfInterest) updateData.pointOfInterest = data.pointOfInterest;
|
||||
|
||||
promises.push(updateSinglePhoto(id, data));
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
Promise.allSettled(promises).then(result => {
|
||||
for (const item of result) {
|
||||
if (item.status === 'fulfilled') success++;
|
||||
}
|
||||
const error = result.length - success;
|
||||
|
||||
if (success > 0) $q.notify(successObject("Upraveno " + success + " fotografií"));
|
||||
if (error > 0) $q.notify(errorObject(`Nepovedlo se uložit ${error} fotografií`))
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => [openEdit.value, openBatchEdit.value], (newVal) => {
|
||||
if (newVal) {
|
||||
// load new data on each dialog open
|
||||
useAircraftsStore().fetch({});
|
||||
usePointsOfInterestStore().fetch({});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightFormPhotos
|
||||
:flight="flight"
|
||||
:is-request-pending="uploadInProgress"
|
||||
@save="handlePhotosUpload"
|
||||
/>
|
||||
|
||||
<PhotosEditorList
|
||||
:title-photo="flight.titlePhoto"
|
||||
:photos="flight.photos"
|
||||
:show-actions="true"
|
||||
@edit="openPhotoEdit"
|
||||
@batch-edit="openBatchPhotoForm"
|
||||
@batch-rotate="batchRotatePhotos"
|
||||
@delete="deletePhoto"
|
||||
@set-title-photo="setTitlePhoto"
|
||||
/>
|
||||
|
||||
<PhotoFormDialog
|
||||
v-model="openEdit"
|
||||
:copilots="flight.copilots"
|
||||
:loading="isLoading"
|
||||
:aircrafts="aircrafts"
|
||||
:locations="pointsOfInterest"
|
||||
:photo="editPhoto"
|
||||
@save="handlePhotoUpdate(flight.id, $event)"
|
||||
/>
|
||||
|
||||
<BatchPhotoFormDialog
|
||||
v-model="openBatchEdit"
|
||||
:loading="isLoading"
|
||||
:locations="pointsOfInterest"
|
||||
:photos="editBatchPhotos"
|
||||
:copilots="flight.copilots"
|
||||
:aircrafts="aircrafts"
|
||||
@save="handleBatchPhotoEdit"
|
||||
@delete="deletePhoto"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import {onMounted} from "vue";
|
||||
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||
import {storeToRefs} from "pinia";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["save"]);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.flight.flightPlanId) {
|
||||
useFlightPlansStore().fetchDetail(props.flight.flightPlanId);
|
||||
}
|
||||
});
|
||||
|
||||
const {flightPlan} = storeToRefs(useFlightPlansStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="flight.flightPlanId">
|
||||
{{ flightPlan.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Bez planu
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import FlightFormTrack from "components/flights/form/FlightFormTrack.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {computed, onMounted} from "vue";
|
||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import FlightEditTrackSkeleton from "components/flights/skeletons/FlightEditTrackSkeleton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["save"]);
|
||||
|
||||
onMounted(() => {
|
||||
usePointsOfInterestStore().fetch({});
|
||||
useAirportsStore().fetch();
|
||||
});
|
||||
|
||||
const {pointsOfInterest, isQueryRunning: poisLoading} = storeToRefs(usePointsOfInterestStore());
|
||||
const {airports, isQueryRunning: airportsLoading} = storeToRefs(useAirportsStore());
|
||||
const isLoading = computed(() => poisLoading.value || airportsLoading.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightEditTrackSkeleton v-if="isLoading"/>
|
||||
<FlightFormTrack
|
||||
v-else
|
||||
:flight="flight"
|
||||
:locations="pointsOfInterest"
|
||||
:airports="airports"
|
||||
@save="emits('save', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -9,266 +9,34 @@ export default {
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
import {computed} from "vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useAirspacesStore} from "stores/Airspaces";
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
import FlightPlanMap from "components/flightPlans/FlightPlanMap.vue";
|
||||
import FlightPlanRoute from "components/flightPlans/FlightPlanRoute.vue";
|
||||
import FlightPlanSideControls from "components/flightPlans/FlightPlanSideControls.vue";
|
||||
import {useAircraftsStore} from "stores/Aircrafts";
|
||||
import {errorObject, successObject} from "src/composables/dialog";
|
||||
import {useQuasar} from "quasar";
|
||||
import FlightPlanRouteTable from "components/flightPlans/FlightPlanRouteTable.vue";
|
||||
import FlightPlanForm from "components/flightPlans/FlightPlanForm.vue";
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
import {useRouter} from "vue-router";
|
||||
import FlightFromPlanForm from "components/flightPlans/FlightFromPlanForm.vue";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
|
||||
const {flightPlan} = storeToRefs(useFlightPlansStore());
|
||||
const flightPlanName = computed(() => flightPlan.value.name || "Nepojmenovaný plán");
|
||||
useAppStore().setPageTitle(`${flightPlanName.value} - plánovnání`);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "flightPlans"}, label: "Moje plány"},
|
||||
{label: flightPlanName.value},
|
||||
]);
|
||||
|
||||
onMounted(() => {
|
||||
useAirspacesStore().fetch();
|
||||
useAircraftsStore().fetch({});
|
||||
usePointsOfInterestStore().fetch({limit: 10000});
|
||||
useAirportsStore().fetch();
|
||||
const displayedTabs = computed(() => {
|
||||
const tabs = [
|
||||
{label: "Trasa", route: "editFlightPlan", icon: "route"},
|
||||
{label: "Informace", route: "editFlightPlanInfo", icon: "info"},
|
||||
{label: "Počasí", route: "editFlightPlanWeather", icon: "mdi-weather-windy"}, // TODO: jen pokud se povedlo nejake pocasi stahnout
|
||||
{label: "Navigační štítek", route: "editFlightPlanNavlog", icon: "navigation"},
|
||||
];
|
||||
|
||||
if (flightPlan.value.aircraft) {
|
||||
tabs.push({label: "Let", route: "flightPlanFlight", icon: "flight"})
|
||||
}
|
||||
|
||||
return tabs;
|
||||
});
|
||||
|
||||
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
||||
const {airspaces: airspacesData, areAirspacesLoading: isQueryRunning} = storeToRefs(useAirspacesStore())
|
||||
const {airports} = storeToRefs(useAirportsStore());
|
||||
const {aircrafts: aircraftsData} = storeToRefs(useAircraftsStore());
|
||||
const aircrafts = computed(() => aircraftsData.value.map(a => ({id: a.id, name: a.callSign})));
|
||||
|
||||
const {getMarkersFrom} = useFlightPlanUtils();
|
||||
const {markerObject} = useFlightPlanObjects();
|
||||
|
||||
const openedTab = ref("route");
|
||||
const flightPlannerDialogOpen = ref(false);
|
||||
const flightPlanMarkers = ref(getMarkersFrom(flightPlan.value));
|
||||
|
||||
const handleMarkersChanges = (markers) => {
|
||||
flightPlanMarkers.value = markers;
|
||||
|
||||
useFlightPlansStore().update(flightPlan.value.id, {
|
||||
markers: markers.map(m => markerObject({
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
airportId: m.airportId,
|
||||
pointOfInterestId: m.pointOfInterestId,
|
||||
gpsLatitude: m.gpsLatitude,
|
||||
gpsLongitude: m.gpsLongitude,
|
||||
}))
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const $q = useQuasar();
|
||||
const handleFlightPlanInfoSave = (formData) => {
|
||||
useFlightPlansStore().update(flightPlan.value.id, formData).then(() => {
|
||||
$q.notify(successObject("Informace úspěšně uloženy"));
|
||||
})
|
||||
}
|
||||
const insertAtPosition = ref(flightPlanMarkers.value.length);
|
||||
const fullscreen = ref(false);
|
||||
|
||||
const takeoffAirport = computed(() => {
|
||||
const markers = getMarkersFrom(flightPlan.value)
|
||||
return markers[0].type === 'airport' ? markers[0] : null;
|
||||
})
|
||||
|
||||
const landingAirport = computed(() => {
|
||||
const markers = getMarkersFrom(flightPlan.value);
|
||||
const lastMarker = markers[markers.length - 1];
|
||||
|
||||
return lastMarker.type === 'airport' ? lastMarker : null;
|
||||
})
|
||||
|
||||
const $router = useRouter();
|
||||
const createNewFlightFromPlan = (formData) => {
|
||||
|
||||
const markers = getMarkersFrom(formData.flightPlan);
|
||||
const takeoff = markers.shift();
|
||||
const landing = markers.pop();
|
||||
|
||||
if ((takeoff.type !== 'airport' || landing.type !== 'airport') && !formData.gpxTrackFile) {
|
||||
$q.notify(errorObject("Zkontroluj letiště vzletu a přistání. Aktuálně zvolené hodnoty se jako letiště nezdají. Pro pokračování uprav buď plán, nebo zkus nahrát trasu v GPX."));
|
||||
return;
|
||||
}
|
||||
|
||||
const track = [];
|
||||
if (formData.copyTrack) {
|
||||
|
||||
// TODO: po ulozeni mapPoint/geocode??? upravit i trasu v planu - predelat ten mapPoint na POI s IDckem
|
||||
|
||||
for (const i in markers) {
|
||||
const marker = markers[i];
|
||||
if (i === 0 || i === markers.length - 1 || marker.type === 'mapPoint') continue;
|
||||
|
||||
const obj = {
|
||||
[marker.type === 'airport' ? 'airport' : 'pointOfInterest']: {
|
||||
id: marker.airportId || marker.pointOfInterestId || null,
|
||||
name: marker.name,
|
||||
},
|
||||
gpsLatitude: marker.gpsLatitude,
|
||||
gpsLongitude: marker.gpsLongitude
|
||||
};
|
||||
track.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
useFlightsStore().create({
|
||||
name: formData.flightPlan.name,
|
||||
aircraft: {id: formData.flightPlan.aircraft.id, name: formData.flightPlan.aircraft.callSign},
|
||||
flightPlanId: formData.flightPlan.id,
|
||||
takeoffAirport: takeoff ? {id: takeoff.airportId, name: takeoff.name} : null,
|
||||
landingAirport: landing ? {id: landing.airportId, name: landing.name} : null,
|
||||
gpxTrackFile: formData.gpxTrackFile,
|
||||
takeoffDatetime: formData.takeoffDatetime,
|
||||
landingDatetime: formData.landingDatetime,
|
||||
track
|
||||
}).then(res => {
|
||||
$q.notify(successObject("Let úspěšně vytvořen. Nyní můžeš nastavit detaily."))
|
||||
$router.push({name: 'flightEdit', params: {id: res.id, step: 'photo'}});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-tabs v-model="openedTab">
|
||||
<q-tab name="route">
|
||||
<q-icon name="route"/>
|
||||
Trasa
|
||||
</q-tab>
|
||||
|
||||
<q-tab name="info">
|
||||
<q-icon name="info"/>
|
||||
Informace
|
||||
</q-tab>
|
||||
|
||||
<q-tab name="navLog">
|
||||
<q-icon name="navigation"/>
|
||||
Navigační štítek
|
||||
</q-tab>
|
||||
|
||||
<q-tab name="flight" v-if="flightPlan.aircraft">
|
||||
<q-icon name="flight"/>
|
||||
Let
|
||||
</q-tab>
|
||||
|
||||
<q-tabs>
|
||||
<q-route-tab v-for="tab in displayedTabs" :key="tab.route" replace exact :to="{name: tab.route, params: {id: flightPlan.id}}">
|
||||
<q-icon :name="tab.icon"/>
|
||||
{{ tab.label }}
|
||||
</q-route-tab>
|
||||
</q-tabs>
|
||||
<q-tab-panels v-model="openedTab">
|
||||
<q-tab-panel name="route">
|
||||
<div :class="{row: true, fullscreen}">
|
||||
<div class="col map--wrapper" style="min-width: 1px">
|
||||
<FlightPlanMap
|
||||
class="map"
|
||||
:insert-position="insertAtPosition"
|
||||
:airspace-data="airspacesData"
|
||||
:airports="airports"
|
||||
:points-of-interest="pointsOfInterest"
|
||||
:flight-plan="flightPlan"
|
||||
@change-insert-position="insertAtPosition = $event"
|
||||
@markers-changed="handleMarkersChanges"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 col-lg-2 markers-control">
|
||||
<FlightPlanSideControls
|
||||
:is-fullscreen="fullscreen"
|
||||
:insert-position="insertAtPosition"
|
||||
:flight-plan="flightPlan"
|
||||
@change-insert-position="insertAtPosition = $event"
|
||||
@open="flightPlannerDialogOpen=true"
|
||||
@fullscreen-toggle="fullscreen = !fullscreen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="info">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<FlightPlanForm
|
||||
:aircrafts="aircrafts"
|
||||
:flight-plan="flightPlan"
|
||||
@save="handleFlightPlanInfoSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="navLog">
|
||||
<FlightPlanRouteTable
|
||||
:flight-plan="flightPlan"
|
||||
:planned-speed="flightPlan.plannedSpeed"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="flight" v-if="flightPlan.aircraft">
|
||||
<BasicInfoBanner v-if="!takeoffAirport || !landingAirport" color="error">
|
||||
V plánu nemáš žádné letiště vzletu nebo přistání. Pro import letu musíš nahrát trasu.
|
||||
</BasicInfoBanner>
|
||||
<FlightFromPlanForm
|
||||
:flight-plan="flightPlan"
|
||||
@save="createNewFlightFromPlan"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
<q-dialog full-width v-model="flightPlannerDialogOpen">
|
||||
<q-card>
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">Přidat otočný bod</div>
|
||||
<q-space/>
|
||||
<q-btn icon="close" flat round dense v-close-popup/>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<FlightPlanRoute
|
||||
:markers="flightPlanMarkers"
|
||||
:points-of-interest="pointsOfInterest"
|
||||
:airports="airports"
|
||||
@markers-changed="handleMarkersChanges"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="Hotovo" v-close-popup/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<router-view :flight-plan-name="flightPlanName" :flight-plan="flightPlan"/>
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 2001;
|
||||
|
||||
.map--wrapper {
|
||||
height: 100%;
|
||||
|
||||
.map {
|
||||
height: 100vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
.markers-control {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
import FlightFromPlanForm from "components/flightPlans/FlightFromPlanForm.vue";
|
||||
import {computed} from "vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import {errorObject, successObject} from "src/composables/dialog";
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
import {useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
import {useQuasar} from "quasar";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const {getMarkersFrom} = useFlightPlanUtils();
|
||||
|
||||
const takeoffAirport = computed(() => {
|
||||
const markers = getMarkersFrom(props.flightPlan)
|
||||
return markers[0].type === 'airport' ? markers[0] : null;
|
||||
})
|
||||
|
||||
const landingAirport = computed(() => {
|
||||
const markers = getMarkersFrom(props.flightPlan);
|
||||
const lastMarker = markers[markers.length - 1];
|
||||
|
||||
return lastMarker.type === 'airport' ? lastMarker : null;
|
||||
})
|
||||
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
const createNewFlightFromPlan = (formData) => {
|
||||
const markers = getMarkersFrom(formData.flightPlan);
|
||||
const takeoff = markers.shift();
|
||||
const landing = markers.pop();
|
||||
|
||||
if ((takeoff.type !== 'airport' || landing.type !== 'airport') && !formData.gpxTrackFile) {
|
||||
$q.notify(errorObject("Zkontroluj letiště vzletu a přistání. Aktuálně zvolené hodnoty se jako letiště nezdají. Pro pokračování uprav buď plán, nebo zkus nahrát trasu v GPX."));
|
||||
return;
|
||||
}
|
||||
|
||||
const track = [];
|
||||
if (formData.copyTrack) {
|
||||
|
||||
// TODO: po ulozeni mapPoint/geocode??? upravit i trasu v planu - predelat ten mapPoint na POI s IDckem
|
||||
|
||||
for (const i in markers) {
|
||||
const marker = markers[i];
|
||||
if (i === 0 || i === markers.length - 1 || marker.type === 'mapPoint') continue;
|
||||
|
||||
const obj = {
|
||||
[marker.type === 'airport' ? 'airport' : 'pointOfInterest']: {
|
||||
id: marker.airportId || marker.pointOfInterestId || null,
|
||||
name: marker.name,
|
||||
},
|
||||
gpsLatitude: marker.gpsLatitude,
|
||||
gpsLongitude: marker.gpsLongitude
|
||||
};
|
||||
track.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
useFlightsStore().create({
|
||||
name: formData.flightPlan.name,
|
||||
aircraft: {id: formData.flightPlan.aircraft.id, name: formData.flightPlan.aircraft.callSign},
|
||||
flightPlanId: formData.flightPlan.id,
|
||||
takeoffAirport: takeoff ? {id: takeoff.airportId, name: takeoff.name} : null,
|
||||
landingAirport: landing ? {id: landing.airportId, name: landing.name} : null,
|
||||
gpxTrackFile: formData.gpxTrackFile,
|
||||
takeoffDatetime: formData.takeoffDatetime,
|
||||
landingDatetime: formData.landingDatetime,
|
||||
track
|
||||
}).then(res => {
|
||||
$q.notify(successObject("Let úspěšně vytvořen. Nyní můžeš nastavit detaily."))
|
||||
$router.push({name: 'flightEdit', params: {id: res.id, step: 'copilot'}});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicInfoBanner v-if="!takeoffAirport || !landingAirport" color="error">
|
||||
V plánu nemáš žádné letiště vzletu nebo přistání. Pro import letu musíš nahrát trasu.
|
||||
</BasicInfoBanner>
|
||||
<FlightFromPlanForm
|
||||
v-bind="$attrs"
|
||||
:flight-plan="flightPlan"
|
||||
@save="createNewFlightFromPlan"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import FlightPlanForm from "components/flightPlans/FlightPlanForm.vue";
|
||||
import {useQuasar} from "quasar";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useAircraftsStore} from "stores/Aircrafts";
|
||||
import {onMounted} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
useAircraftsStore().fetch({});
|
||||
});
|
||||
|
||||
const {aircrafts} = storeToRefs(useAircraftsStore());
|
||||
|
||||
const $q = useQuasar();
|
||||
const handleFlightPlanInfoSave = (formData) => {
|
||||
useFlightPlansStore().update(props.flightPlan.id, formData).then(() => {
|
||||
$q.notify(successObject("Informace úspěšně uloženy"));
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightPlanForm
|
||||
:aircrafts="aircrafts"
|
||||
:flight-plan="flightPlan"
|
||||
@save="handleFlightPlanInfoSave"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
import FlightPlanMap from "components/flightPlans/FlightPlanMap.vue";
|
||||
import FlightPlanSideControls from "components/flightPlans/FlightPlanSideControls.vue";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {useAirspacesStore} from "stores/Airspaces";
|
||||
import {useAircraftsStore} from "stores/Aircrafts";
|
||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useFlightPlanObjects, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||
import FlightPlanRoute from "components/flightPlans/FlightPlanRoute.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
useAirspacesStore().fetch();
|
||||
useAircraftsStore().fetch({});
|
||||
usePointsOfInterestStore().fetch({limit: 10000});
|
||||
useAirportsStore().fetch();
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
flightPlanName: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
useAppStore().setPageTitle(`${props.flightPlanName} - plánovnání`);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "flightPlans"}, label: "Moje plány"},
|
||||
{label: props.flightPlanName},
|
||||
]);
|
||||
|
||||
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
||||
const {airspaces: airspacesData, areAirspacesLoading: isQueryRunning} = storeToRefs(useAirspacesStore())
|
||||
const {airports} = storeToRefs(useAirportsStore());
|
||||
const {aircrafts: aircraftsData} = storeToRefs(useAircraftsStore());
|
||||
const aircrafts = computed(() => aircraftsData.value.map(a => ({id: a.id, name: a.callSign})));
|
||||
|
||||
const {getMarkersFrom} = useFlightPlanUtils();
|
||||
const {markerObject} = useFlightPlanObjects();
|
||||
const flightPlanMarkers = ref(getMarkersFrom(props.flightPlan));
|
||||
|
||||
const handleMarkersChanges = (markers) => {
|
||||
flightPlanMarkers.value = markers;
|
||||
|
||||
useFlightPlansStore().update(props.flightPlan.id, {
|
||||
markers: markers.map(m => markerObject({
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
airportId: m.airportId,
|
||||
pointOfInterestId: m.pointOfInterestId,
|
||||
gpsLatitude: m.gpsLatitude,
|
||||
gpsLongitude: m.gpsLongitude,
|
||||
}))
|
||||
});
|
||||
};
|
||||
|
||||
const insertAtPosition = ref(flightPlanMarkers.value.length);
|
||||
const fullscreen = ref(false);
|
||||
const flightPlannerDialogOpen = ref(false);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div :class="{row: true, fullscreen}">
|
||||
<div class="col map--wrapper" style="min-width: 1px">
|
||||
<FlightPlanMap
|
||||
class="map"
|
||||
:insert-position="insertAtPosition"
|
||||
:airspace-data="airspacesData"
|
||||
:airports="airports"
|
||||
:points-of-interest="pointsOfInterest"
|
||||
:flight-plan="flightPlan"
|
||||
@change-insert-position="insertAtPosition = $event"
|
||||
@markers-changed="handleMarkersChanges"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 col-lg-2 markers-control">
|
||||
<FlightPlanSideControls
|
||||
:is-fullscreen="fullscreen"
|
||||
:insert-position="insertAtPosition"
|
||||
:flight-plan="flightPlan"
|
||||
@change-insert-position="insertAtPosition = $event"
|
||||
@open="flightPlannerDialogOpen=true"
|
||||
@fullscreen-toggle="fullscreen = !fullscreen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog full-width v-model="flightPlannerDialogOpen">
|
||||
<q-card>
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">Přidat otočný bod</div>
|
||||
<q-space/>
|
||||
<q-btn icon="close" flat round dense v-close-popup/>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<FlightPlanRoute
|
||||
:markers="flightPlanMarkers"
|
||||
:points-of-interest="pointsOfInterest"
|
||||
:airports="airports"
|
||||
@markers-changed="handleMarkersChanges"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="Hotovo" v-close-popup/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 2001;
|
||||
|
||||
.map--wrapper {
|
||||
height: 100%;
|
||||
|
||||
.map {
|
||||
height: 100vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
.markers-control {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import FlightPlanNavLogTable from "components/flightPlans/FlightPlanNavLogTable.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightPlanNavLogTable
|
||||
:flight-plan="flightPlan"
|
||||
:planned-speed="flightPlan.plannedSpeed"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import FlightPlanWeatherInfo from "components/flightPlans/FlightPlanWeatherInfo.vue";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
|
||||
const props = defineProps({
|
||||
flightPlan: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicInfoBanner>
|
||||
Pozor, informace o počasí mají jen informativní charakter a mohou být nepřesné!
|
||||
</BasicInfoBanner>
|
||||
|
||||
<FlightPlanWeatherInfo v-bind="$attrs" :flight-plan="flightPlan"/>
|
||||
</template>
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {onMounted} from "vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
import FAB from "components/FAB.vue";
|
||||
import FlightPlansList from "components/flightPlans/FlightPlansList.vue";
|
||||
import ListFilters from "components/ListFilters.vue";
|
||||
import PrimaryAction from "components/PrimaryAction.vue";
|
||||
import {usePagination} from "src/composables/pagination";
|
||||
|
||||
useAppStore().setPageTitle("Nový plán");
|
||||
useAppStore().setBreadcrumbs([
|
||||
@@ -14,14 +15,20 @@ useAppStore().setBreadcrumbs([
|
||||
{label: "Plánované lety"},
|
||||
]);
|
||||
|
||||
onMounted(() => {
|
||||
useFlightPlansStore().fetch();
|
||||
const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({
|
||||
fetchFunction: useFlightPlansStore().fetch,
|
||||
itemsPerPage: 20
|
||||
});
|
||||
|
||||
const {flightPlans, isQueryRunning} = storeToRefs(useFlightPlansStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListFilters @filters-changed="handleFiltersChanged">
|
||||
<template #append>
|
||||
<PrimaryAction mode="add" :router-link="{name: 'newFlightPlan'}"/>
|
||||
</template>
|
||||
</ListFilters>
|
||||
|
||||
<BasicListSkeleton :items=5 v-if="isQueryRunning || !flightPlans"/>
|
||||
|
||||
<BasicInfoBanner
|
||||
@@ -31,6 +38,10 @@ const {flightPlans, isQueryRunning} = storeToRefs(useFlightPlansStore());
|
||||
Aktuálně nemáš žádné naplánované lety.
|
||||
</BasicInfoBanner>
|
||||
|
||||
<FlightPlansList :items="flightPlans" v-else/>
|
||||
<FAB :router-link="{name: 'newFlightPlan'}" mode="add"/>
|
||||
<FlightPlansList
|
||||
v-else
|
||||
:items="flightPlans"
|
||||
:has-more-data="hasMoreData"
|
||||
@load-more="loadPage($event.index, $event.done)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {usePointsOfInterestTypeStore} from "stores/PointOfInterestType";
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
@@ -22,10 +23,16 @@ useAppStore().setBreadcrumbs([
|
||||
{label: "Nové"},
|
||||
])
|
||||
|
||||
const {isMutationRunning} = storeToRefs(usePointsOfInterestStore());
|
||||
usePointsOfInterestTypeStore().fetch();
|
||||
|
||||
const {isMutationRunning} = storeToRefs(usePointsOfInterestStore());
|
||||
const {types} = storeToRefs(usePointsOfInterestTypeStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<POIForm @save="handleSave" :is-request-pending="isMutationRunning"/>
|
||||
<POIForm
|
||||
:types="types"
|
||||
:is-request-pending="isMutationRunning"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -38,9 +38,9 @@ const togglePublicVisibility = (variables) => toggleVisibility({
|
||||
$q, store: usePointsOfInterestStore(), object: pointOfInterest.value, variables
|
||||
});
|
||||
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
|
||||
filters.value = {pointOfInterestId: poiId};
|
||||
loadPage(false);
|
||||
loadFirstPage();
|
||||
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
|
||||
const {isQueryRunning, pointOfInterest} = storeToRefs(usePointsOfInterestStore());
|
||||
</script>
|
||||
|
||||
@@ -9,27 +9,25 @@ import {storeToRefs} from "pinia";
|
||||
import {usePagination} from "src/composables/pagination";
|
||||
import ListFilters from "components/ListFilters.vue";
|
||||
import {onMounted} from "vue";
|
||||
import PrimaryAction from "components/PrimaryAction.vue";
|
||||
|
||||
useAppStore().setPageTitle("Zajímavá místa");
|
||||
useAppStore().setBreadcrumbs([]);
|
||||
|
||||
const {filters, loadPage, paginationLimit, paginationOffset} = usePagination({
|
||||
fetchFunction: usePointsOfInterestStore().fetch
|
||||
const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({
|
||||
fetchFunction: usePointsOfInterestStore().fetch,
|
||||
itemsPerPage: 20
|
||||
});
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
})
|
||||
const handleFiltersChanged = (newFilters) => {
|
||||
paginationOffset.value = 0;
|
||||
filters.value = newFilters;
|
||||
loadPage(false);
|
||||
}
|
||||
|
||||
const {isQueryRunning, totalItemsCount, pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
||||
const {isQueryRunning, pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListFilters @filters-changed="handleFiltersChanged"/>
|
||||
<ListFilters @filters-changed="handleFiltersChanged">
|
||||
<template #append>
|
||||
<PrimaryAction mode="add" :router-link="{name: 'addPOI'}"/>
|
||||
</template>
|
||||
</ListFilters>
|
||||
|
||||
<BasicListSkeleton :items="10" v-if="isQueryRunning && !pointsOfInterest.length"/>
|
||||
<BasicInfoBanner
|
||||
@@ -45,10 +43,7 @@ const {isQueryRunning, totalItemsCount, pointsOfInterest} = storeToRefs(usePoint
|
||||
v-else
|
||||
:public="false"
|
||||
:items="pointsOfInterest"
|
||||
:total-items-count="totalItemsCount"
|
||||
:is-query-running="isQueryRunning"
|
||||
:items-per-page="paginationLimit"
|
||||
@load-more="loadPage(true)"
|
||||
:has-more-data="hasMoreData"
|
||||
@load-more="loadPage($event.index, $event.done)"
|
||||
/>
|
||||
<FAB :router-link="{name: 'addPOI'}" mode="add"/>
|
||||
</template>
|
||||
|
||||
@@ -10,9 +10,6 @@ useAppStore().setBreadcrumbs([
|
||||
...homepageBreadcrumbs(),
|
||||
{label: "O projektu"}
|
||||
]);
|
||||
useMeta(() => metaData({
|
||||
title: "O projektu",
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,7 +20,7 @@ useMeta(() => metaData({
|
||||
<p>
|
||||
Polétání.cz je fotogalerie určená pilotům malých letadel. Nesnaží se být leteckým zápisníkem, nabízí ale
|
||||
možnosti uložení fotografií pořízených během letu. Posláním aplikace je vytvořit vizuálně příjemnou prezentaci
|
||||
tvých zážitků ze vzduchu a umožnit sdílet detaily o letu se svým kamarádem (kopilotem).
|
||||
zážitků ze vzduchu a umožnit sdílet detaily o letu se svým kamarádem (kopilotem).
|
||||
</p>
|
||||
|
||||
<h3>Externí data</h3>
|
||||
|
||||
@@ -2,27 +2,51 @@
|
||||
import {useAppStore} from "stores/App";
|
||||
import ContentCard from "components/ContentCard.vue";
|
||||
import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
|
||||
import {useMeta} from "quasar";
|
||||
import {metaData} from "src/composables/meta";
|
||||
|
||||
useAppStore().setPageTitle("Co Polétání.cz umí?");
|
||||
useAppStore().setBreadcrumbs([
|
||||
...homepageBreadcrumbs(),
|
||||
{label: "Funkce"}
|
||||
]);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
|
||||
<ContentCard title="Co Polétání.cz umí?">
|
||||
<template #content>
|
||||
<q-card-section>
|
||||
Sdílet zážitky s kamarády
|
||||
Upravovat fotky
|
||||
Zobrazovat je v kontextu polohy
|
||||
Planovat let
|
||||
Debriefing letu: https://www.easa.europa.eu/community/topics/post-flight-debrief
|
||||
<h3>Ukládat informace o letech</h3>
|
||||
<p>
|
||||
Ať už létáš sám nebo s někým, je fajn si někam zaznamenat jaké to bylo. Po letu je možné nahrát trasu, uložit fotky, popsat zážitek a zapsat si poznámky k debriefingu letu.
|
||||
</p>
|
||||
|
||||
<h3>Sdílet zážitky s kamarády</h3>
|
||||
<p>
|
||||
Nahranou trasu a fotografie, společně se slovním popisem a dalšími informace můžeš jednoduše poslat kamarádovi
|
||||
jako vzpomínku na fajn čas ve vzduchu. A pokud s někým létáš časteji, uvidí svoje lety pěkně na jedné hromadě!
|
||||
</p>
|
||||
|
||||
<h3>Upravovat fotky</h3>
|
||||
<p>
|
||||
Nahrané fotografie z výletů můžeš jednoduše upravovat přímo v rozhraní polétání.cz. Sice aplikace neumí to co Photoshop nebo lepší editor v mobilu, ale na umí srovnat horizont, oříznout fotku a trošku upravit barvy.
|
||||
</p>
|
||||
|
||||
<h3>Plánování letu</h3>
|
||||
<p>
|
||||
Polétání.cz nabízí jednoduchý plánovač. Na pár kliků je možné naplánovat výlet, poslat návrh trasy
|
||||
kopilotovi a jednodušeji se domluvit na trase výletu. V mapě jsou zobrazeny i prolétávané prostory, letiště
|
||||
(i frekvencemi i dráhami). To usnadňuje celkovou předletovou přípravu. Tato data se pravidelně stahují ze
|
||||
služby OpenAIP (nejedná se o oficiální zdroj informací a je potřeba informace ověřit v oficiálních zdrojích).
|
||||
</p>
|
||||
|
||||
<h3>Objevuj nová místa</h3>
|
||||
<p>
|
||||
Díky veřejně dostupné mapě zajímavých míst můžeš najít nové cíle svých výletů! Případně pokud letíš na druhý kraj republiky, můžeš najít další fajn trasy na další polétání!
|
||||
</p>
|
||||
</q-card-section>
|
||||
</template>
|
||||
</ContentCard>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {useMeta} from "quasar";
|
||||
import {metaData} from "src/composables/meta";
|
||||
import AppFooter from "components/AppFooter.vue";
|
||||
import LatestFlightsSlider from "components/flights/slider/LatestFlightsSlider.vue";
|
||||
import LatestFlightsSlider from "components/flights/LatestFlightsSlider.vue";
|
||||
import {onMounted} from "vue";
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
import {storeToRefs} from "pinia";
|
||||
|
||||
@@ -11,48 +11,133 @@ useAppStore().setBreadcrumbs([
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentCard title="Informace o správě osobních údajů">
|
||||
<ContentCard title="Zásady ochrany osobních údajů">
|
||||
<template #content>
|
||||
<q-card-section>
|
||||
<section>
|
||||
<h4>1. ochrana osobních údajů:</h4>
|
||||
<p>Aplikace Poletani.cz respektuje vaše soukromí a ochranu osobních údajů. Veškeré osobní údaje, které nám poskytnete, budou zpracovány v souladu s naší Zásadou Ochrany Osobních Údajů a v souladu s nařízením GDPR.</p>
|
||||
</section>
|
||||
<p>
|
||||
Tyto Zásady ochrany osobních údajů stanovují, jakým způsobem jsou zpracovávány a chráněny osobní údaje uživatelů aplikace <a href="https://poletani.cz">Poletani.cz</a>. Vaše soukromí je pro nás důležité a zavazujeme se k jeho ochraně v souladu s platnými právními předpisy, zejména s Obecným nařízením o ochraně osobních údajů (GDPR).
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h4>2. shromažďované údaje:</h4>
|
||||
<p>Aplikace může shromažďovat a zpracovávat různé kategorie osobních údajů, včetně, ale neomezujíc se na, jméno, e-mailovou adresu, geografická data a informace o zařízení, které používáte.</p>
|
||||
</section>
|
||||
<h4>1. Správce osobních údajů</h4>
|
||||
<p>
|
||||
Správcem osobních údajů je Michal Kvacek, který je dostupný na emailové adrese <a href="mailto:michal@kvacek.cz">michal@kvacek.cz</a>.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h4>3. účely zpracování:</h4>
|
||||
<p>Osobní údaje budou zpracovány pro specifické účely, které budou uvedeny při shromažďování těchto údajů. To může zahrnovat poskytování služeb, personalizaci obsahu, analytiku a marketingové účely.</p>
|
||||
</section>
|
||||
<h4>2. Shromažďované osobní údaje</h4>
|
||||
<p>
|
||||
Aplikace shromažďuje následující osobní údaje:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Jméno</li>
|
||||
<li>Emailová adresa</li>
|
||||
</ul>
|
||||
|
||||
<section>
|
||||
<h4>4. práva uživatelů podle GDPR:</h4>
|
||||
<p>Podle nařízení GDPR máte právo na přístup k vašim osobním údajům, opravu, výmaz, omezení zpracování, přenositelnost údajů a vznést námitku proti zpracování.</p>
|
||||
</section>
|
||||
<h4>3. Účel zpracování osobních údajů</h4>
|
||||
<p>
|
||||
Shromážděné osobní údaje jsou využívány pro následující účely:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Registrace do aplikace Poletani.cz</li>
|
||||
<li>Zasílání novinek a informací o aplikaci na registrovaný email (na základě souhlasu)</li>
|
||||
</ul>
|
||||
|
||||
<section>
|
||||
<h4>5. bezpečnost osobních údajů:</h4>
|
||||
<p>Aplikace přijímá veškerá opatření k zajištění bezpečnosti osobních údajů uživatelů. To zahrnuje šifrování dat, pravidelné bezpečnostní auditování a opatření k zabránění neoprávněného přístupu k údajům.</p>
|
||||
</section>
|
||||
<h4>4. Právní základ pro zpracování</h4>
|
||||
<p>
|
||||
Zpracování osobních údajů je prováděno na základě následujících právních základů:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Plnění smlouvy:</strong> Osobní údaje jsou zpracovávány za účelem registrace a používání aplikace.</li>
|
||||
<li><strong>Souhlas:</strong> Pro zasílání novinek a informací na emailovou adresu je nezbytný souhlas uživatele, který může být kdykoliv odvolán.</li>
|
||||
</ul>
|
||||
|
||||
<section>
|
||||
<h4>6. sdílení osobních údajů:</h4>
|
||||
<p>Osobní údaje nebudou sdíleny s třetími stranami bez vašeho souhlasu, s výjimkou případů stanovených zákonnou povinností.</p>
|
||||
</section>
|
||||
<h4>5. Doba uchovávání osobních údajů</h4>
|
||||
<p>
|
||||
Osobní údaje jsou uchovávány po dobu, dokud má uživatel aktivní účet v aplikaci. Uživatel má právo kdykoliv svůj účet smazat, čímž dojde k vymazání všech jeho osobních údajů.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h4>7. cookies:</h4>
|
||||
<p>Pro lepší funkčnost a personalizaci obsahu může aplikace využívat soubory cookies.</p>
|
||||
</section>
|
||||
<h4>6. Sdílení osobních údajů</h4>
|
||||
<p>
|
||||
Osobní údaje nejsou sdíleny s žádnými třetími stranami.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h4>8. změny v podmínkách:</h4>
|
||||
<p>Podmínky používání a Zásady Ochrany Osobních Údajů mohou být aktualizovány. Aktualizované verze budou zveřejněny na našem webu.</p>
|
||||
</section>
|
||||
<h4>7. Práva uživatelů</h4>
|
||||
<p>
|
||||
Uživatelé mají následující práva týkající se jejich osobních údajů:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Právo na přístup k osobním údajům</li>
|
||||
<li>Právo na opravu osobních údajů</li>
|
||||
<li>Právo na výmaz osobních údajů (právo být zapomenut)</li>
|
||||
<li>Právo na omezení zpracování</li>
|
||||
<li>Právo na přenositelnost údajů</li>
|
||||
<li>Právo vznést námitku proti zpracování</li>
|
||||
<li>Právo podat stížnost u dozorového úřadu</li>
|
||||
</ul>
|
||||
|
||||
<h4>8. Použití cookies</h4>
|
||||
<p>
|
||||
Aplikace používá cookies pro následující účely:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Měření přístupů pomocí Google Analytics</li>
|
||||
<li>Integrace služeb Google Maps a Google Fonts</li>
|
||||
<li>Použití Google Tag Manager pro správu značek</li>
|
||||
<li>Řešení souhlasu s cookies prostřednictvím služby consentmanager.com</li>
|
||||
</ul>
|
||||
<p>
|
||||
Uživatelé mohou správu souborů cookies provádět prostřednictvím nastavení svého prohlížeče nebo pomocí nástroje poskytovaného aplikací.
|
||||
</p>
|
||||
|
||||
<h4>9. Změny v Zásadách ochrany osobních údajů</h4>
|
||||
<p>
|
||||
Tyto Zásady ochrany osobních údajů mohou být čas od času aktualizovány. O všech podstatných změnách budou uživatelé informováni prostřednictvím emailu nebo prostřednictvím aplikace.
|
||||
</p>
|
||||
|
||||
<h4>10. Kontakt</h4>
|
||||
<p>
|
||||
Pokud máte jakékoliv dotazy týkající se zpracování vašich osobních údajů, můžete nás kontaktovat na adrese <a href="mailto:michal@kvacek.cz">michal@kvacek.cz</a>.
|
||||
</p>
|
||||
|
||||
<!-- -->
|
||||
<!-- <section>-->
|
||||
<!-- <h4>1. ochrana osobních údajů:</h4>-->
|
||||
<!-- <p>Aplikace Poletani.cz respektuje vaše soukromí a ochranu osobních údajů. Veškeré osobní údaje, které nám poskytnete, budou zpracovány v souladu s naší Zásadou Ochrany Osobních Údajů a v souladu s nařízením GDPR.</p>-->
|
||||
<!-- </section>-->
|
||||
|
||||
<!-- <section>-->
|
||||
<!-- <h4>2. shromažďované údaje:</h4>-->
|
||||
<!-- <p>Aplikace může shromažďovat a zpracovávat různé kategorie osobních údajů, včetně, ale neomezujíc se na, jméno, e-mailovou adresu, geografická data a informace o zařízení, které používáte.</p>-->
|
||||
<!-- </section>-->
|
||||
|
||||
<!-- <section>-->
|
||||
<!-- <h4>3. účely zpracování:</h4>-->
|
||||
<!-- <p>Osobní údaje budou zpracovány pro specifické účely, které budou uvedeny při shromažďování těchto údajů. To může zahrnovat poskytování služeb, personalizaci obsahu, analytiku a marketingové účely.</p>-->
|
||||
<!-- </section>-->
|
||||
|
||||
<!-- <section>-->
|
||||
<!-- <h4>4. práva uživatelů podle GDPR:</h4>-->
|
||||
<!-- <p>Podle nařízení GDPR máte právo na přístup k vašim osobním údajům, opravu, výmaz, omezení zpracování, přenositelnost údajů a vznést námitku proti zpracování.</p>-->
|
||||
<!-- </section>-->
|
||||
|
||||
<!-- <section>-->
|
||||
<!-- <h4>5. bezpečnost osobních údajů:</h4>-->
|
||||
<!-- <p>Aplikace přijímá veškerá opatření k zajištění bezpečnosti osobních údajů uživatelů. To zahrnuje šifrování dat, pravidelné bezpečnostní auditování a opatření k zabránění neoprávněného přístupu k údajům.</p>-->
|
||||
<!-- </section>-->
|
||||
|
||||
<!-- <section>-->
|
||||
<!-- <h4>6. sdílení osobních údajů:</h4>-->
|
||||
<!-- <p>Osobní údaje nebudou sdíleny s třetími stranami bez vašeho souhlasu, s výjimkou případů stanovených zákonnou povinností.</p>-->
|
||||
<!-- </section>-->
|
||||
|
||||
<!-- <section>-->
|
||||
<!-- <h4>7. cookies:</h4>-->
|
||||
<!-- <p>Pro lepší funkčnost a personalizaci obsahu může aplikace využívat soubory cookies.</p>-->
|
||||
<!-- </section>-->
|
||||
|
||||
<!-- <section>-->
|
||||
<!-- <h4>8. změny v podmínkách:</h4>-->
|
||||
<!-- <p>Podmínky používání a Zásady Ochrany Osobních Údajů mohou být aktualizovány. Aktualizované verze budou zveřejněny na našem webu.</p>-->
|
||||
<!-- </section>-->
|
||||
</q-card-section>
|
||||
</template>
|
||||
</ContentCard>
|
||||
|
||||
@@ -28,15 +28,13 @@ import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
|
||||
|
||||
const {aircraft, isQueryRunning} = storeToRefs(useAircraftsStore());
|
||||
const {photos, isQueryRunning: arePhotosLoading} = storeToRefs(usePhotosStore());
|
||||
const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
|
||||
const {flights, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
|
||||
|
||||
useAppStore().setPageTitle(aircraft.value.callSign);
|
||||
useMeta(() => metaData({
|
||||
title: aircraft.value.callSign,
|
||||
image: aircraft.value.titleImage?.url,
|
||||
keywords: [
|
||||
"letadlo", ...aircraftKeywords(aircraft.value)
|
||||
],
|
||||
keywords: aircraftKeywords(aircraft.value),
|
||||
description: aircraft.value.description || `Detail letadla ${aircraft.value.callSign}.`
|
||||
}));
|
||||
useAppStore().setBreadcrumbs([
|
||||
@@ -45,10 +43,9 @@ useAppStore().setBreadcrumbs([
|
||||
{label: aircraft.value.callSign}
|
||||
]);
|
||||
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
|
||||
filters.value = {aircraftId: aircraft.value.id};
|
||||
const {handleFiltersChanged, hasMoreData, loadPage} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
handleFiltersChanged({aircraftId: aircraft.value.id})
|
||||
usePhotosStore().fetchPublic({aircraftId: aircraft.value.id});
|
||||
});
|
||||
</script>
|
||||
@@ -74,10 +71,9 @@ onMounted(() => {
|
||||
<FlightsList
|
||||
v-else
|
||||
:items="flights"
|
||||
:total-items-count="totalItemsCount"
|
||||
:is-query-running="areFlightsLoading"
|
||||
:items-per-page="paginationLimit"
|
||||
@load-more="loadPage(true)"
|
||||
:has-more-data="hasMoreData"
|
||||
@load-more="loadPage($event.index, $event.done)"
|
||||
:public="true"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
@@ -39,18 +39,16 @@ useMeta(() => metaData({
|
||||
description: event.value.description
|
||||
}))
|
||||
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
|
||||
filters.value = {username: props.pilot.publicUsername, eventId: event.value.id};
|
||||
const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
|
||||
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
handleFiltersChanged({username: props.pilot.publicUsername, eventId: event.value.id})
|
||||
})
|
||||
|
||||
const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
|
||||
const {flights, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<EventDetailSkeleton v-if="isQueryRunning || !event"/>
|
||||
|
||||
<template v-else>
|
||||
@@ -59,26 +57,23 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
|
||||
<article class="q-my-lg" v-html="event.description"/>
|
||||
|
||||
<q-no-ssr>
|
||||
<EventFlightsMap :event-url-slug="event.urlSlug" :only-public="true"/>
|
||||
<!-- <EventFlightsMap :event-url-slug="event.urlSlug" :only-public="true"/>-->
|
||||
</q-no-ssr>
|
||||
|
||||
<h3>Lety</h3>
|
||||
|
||||
<q-banner v-if="event.flights.length === 0" class="bg-light-blue text-white">
|
||||
<q-banner v-if="flights.length === 0" class="bg-light-blue text-white">
|
||||
Zatím tu nemáš uložené žádné lety.
|
||||
</q-banner>
|
||||
|
||||
<FlightsListSkeleton :items="paginationLimit" v-if="areFlightsLoading && flights?.length === 0"/>
|
||||
<FlightsListSkeleton v-if="areFlightsLoading && flights?.length === 0"/>
|
||||
|
||||
<FlightsList
|
||||
v-else
|
||||
:items="flights"
|
||||
:total-items-count="totalItemsCount"
|
||||
:is-query-running="areFlightsLoading"
|
||||
:items-per-page="paginationLimit"
|
||||
:public="true"
|
||||
@load-more="loadPage(true)"
|
||||
:has-more-data="hasMoreData"
|
||||
@load-more="loadPage($event.index, $event.done)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
<script>
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
import {usePilotsStore} from "stores/Pilots";
|
||||
|
||||
export default {
|
||||
preFetch({store, currentRoute}) {
|
||||
return useFlightsStore(store).fetchPublicDetail({
|
||||
const promises = [
|
||||
usePilotsStore(store).fetchPublicPilotDetail(currentRoute.params.username).catch(e => {
|
||||
}),
|
||||
useFlightsStore(store).fetchPublicDetail({
|
||||
urlSlug: currentRoute.params.flightSlug,
|
||||
username: currentRoute.params.username
|
||||
})
|
||||
]
|
||||
return Promise.all(promises)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import {storeToRefs} from "pinia";
|
||||
import FlightTrackMap from "components/flights/FlightTrackMap.vue";
|
||||
import FlightBasicInfo from "components/flights/FlightBasicInfo.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue";
|
||||
import PhotoGallery from "components/photos/PhotoGallery.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {flightName} from "src/composables/flight";
|
||||
import {useMeta} from "quasar";
|
||||
import {aircraftKeywords, flightEventKeywords, flightKeywords, metaData} from "src/composables/meta";
|
||||
import ContentCard from "components/ContentCard.vue";
|
||||
import {usePhotosStore} from "stores/Photos";
|
||||
import {computed, onMounted} from "vue";
|
||||
import {publicFlightBreadcrumbs} from "src/composables/breadcrumbs";
|
||||
import {computed, onMounted, ref, useTemplateRef} from "vue";
|
||||
import {useAirspacesStore} from "stores/Airspaces";
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue";
|
||||
import {publicFlightBreadcrumbs} from "src/composables/breadcrumbs";
|
||||
|
||||
const {isQueryRunning, flight} = storeToRefs(useFlightsStore());
|
||||
|
||||
const props = defineProps({
|
||||
pilot: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
const {pilot, isQueryRunning: pilotLoading} = storeToRefs(usePilotsStore());
|
||||
|
||||
onMounted(() => {
|
||||
usePhotosStore().fetchPublic({flightId: flight.value.id});
|
||||
@@ -43,7 +48,7 @@ onMounted(() => {
|
||||
useAirportsStore().fetch();
|
||||
|
||||
if (flight.value.flightPlanId) {
|
||||
useFlightPlansStore().fetchPublicDetail({username: props.pilot.publicUsername, id: flight.value.flightPlanId});
|
||||
useFlightPlansStore().fetchPublicDetail({username: pilot.value.publicUsername, id: flight.value.flightPlanId});
|
||||
}
|
||||
})
|
||||
const {isQueryRunning: arePhotosLoading, photos: flightPhotos} = storeToRefs(usePhotosStore());
|
||||
@@ -55,13 +60,15 @@ const flightNameFormatted = flightName(flight.value);
|
||||
const event = computed(() => flight.value.event);
|
||||
|
||||
useAppStore().setPageTitle(flightNameFormatted);
|
||||
useAppStore().setBreadcrumbs(publicFlightBreadcrumbs(props.pilot, flight.value, event.value, false));
|
||||
useAppStore().setBreadcrumbs(publicFlightBreadcrumbs(pilot.value, flight.value, event.value, false));
|
||||
const {breadcrumbs} = storeToRefs(useAppStore());
|
||||
|
||||
|
||||
useMeta(() => metaData({
|
||||
title: `${flightNameFormatted} - ${props.pilot.name}`,
|
||||
title: `${flightNameFormatted} - ${pilot.value.name}`,
|
||||
keywords: [
|
||||
...flightKeywords(flight.value),
|
||||
props.pilot.name,
|
||||
pilot.value.name,
|
||||
...aircraftKeywords(flight.value.aircraft),
|
||||
...flightEventKeywords(event.value)
|
||||
],
|
||||
@@ -69,15 +76,67 @@ useMeta(() => metaData({
|
||||
image: flight.value.socialImageUrl
|
||||
}
|
||||
));
|
||||
|
||||
|
||||
const titleImageBottomAnchor = ref()
|
||||
const scrollToContent = () => {
|
||||
console.log(titleImageBottomAnchor.value)
|
||||
if (!titleImageBottomAnchor.value) {
|
||||
console.log("nemam anchor")
|
||||
return
|
||||
}
|
||||
|
||||
titleImageBottomAnchor.value.scrollIntoView({behavior: "smooth"});
|
||||
}
|
||||
|
||||
const tab = ref("photos")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-img :src="flight.titlePhoto.url" style="height: 100vh" fit="cover">
|
||||
<div class="absolute-full">
|
||||
<div style="height: 20vh; opacity: 0.4">
|
||||
<BreadcrumbsNavigation :items="breadcrumbs || []"/>
|
||||
</div>
|
||||
<div class="flex flex-center text-center">
|
||||
<div>
|
||||
<FlightDetailSkeleton v-if="isQueryRunning || !flight"/>
|
||||
<FlightHeaderInfo :pilot="pilot" :flight="flight">
|
||||
<div class="absolute-bottom">
|
||||
<div class="row">
|
||||
<div class="offset-2 col-grow">
|
||||
<q-tabs
|
||||
v-model="tab"
|
||||
>
|
||||
<q-tab @click="scrollToContent" class="q-px-xl" :ripple="false" name="photos" icon="image" label="Fotky" v-if="flightPhotos.length > 0"/>
|
||||
<q-tab @click="scrollToContent" class="q-px-xl" :ripple="false" name="info" icon="alarm" label="Info"/>
|
||||
<q-tab @click="scrollToContent" class="q-px-xl" :ripple="false" name="track" icon="route" label="Trasa" v-if="flightPlan || flight.track"/>
|
||||
</q-tabs>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<q-btn class="q-ma-md" :to="{name: 'publicFlightDetail', params: {username: pilot.publicUsername}}">
|
||||
<AvatarFromPhoto :size="32" class="q-mr-sm" :src="pilot.avatarImageUrl"/>
|
||||
{{ pilot.name }}
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FlightHeaderInfo>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentCard :title-photo="flight.titlePhoto" :title="flightName(flight)" v-else>
|
||||
<template #content>
|
||||
<q-card-section>
|
||||
<div class="absolute-bottom" ref="titleImageBottomAnchor"></div>
|
||||
</div>
|
||||
|
||||
</q-img>
|
||||
|
||||
|
||||
<q-tab-panels v-model="tab" style="min-height: 80vh">
|
||||
<q-tab-panel name="photos">
|
||||
<q-inner-loading v-if="arePhotosLoading"/>
|
||||
<PhotoGallery :photos="flightPhotos" v-else-if="!arePhotosLoading && flightPhotos.length > 0"/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="info">
|
||||
<FlightBasicInfo
|
||||
:flight="flight"
|
||||
:poi-detail-route="{name: 'publicPOIDetail'}"
|
||||
@@ -85,9 +144,28 @@ useMeta(() => metaData({
|
||||
:event-detail-route="event && event.isPublic ? {name: 'publicEventDetail', params: {username: pilot.publicUsername, eventSlug: event.urlSlug}} : null"
|
||||
:copilot-detail-route="{name: 'publicCopilotDetail', params: {username: pilot.publicUsername}}"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
|
||||
<article class="q-my-lg" v-html="flight.description"/>
|
||||
<q-tab-panel name="track">
|
||||
<FlightTrackMap
|
||||
:airspaces-data="airspaces"
|
||||
:airports="airports"
|
||||
:key="flight.id"
|
||||
:flight="flight"
|
||||
:flight-plan="flightPlan"
|
||||
:show-info-window="true"
|
||||
:showLayerControls="false"
|
||||
:photos="flightPhotos"/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
|
||||
<div v-if="false">
|
||||
<FlightDetailSkeleton v-if="isQueryRunning || !flight"/>
|
||||
|
||||
<ContentCard :title-photo="flight.titlePhoto" :title="flightName(flight)" v-else>
|
||||
<template #content>
|
||||
<q-card-section>
|
||||
<q-no-ssr>
|
||||
<q-inner-loading v-if="arePhotosLoading"/>
|
||||
<PhotoGallery :photos="flightPhotos" v-else-if="!arePhotosLoading && flightPhotos.length > 0"/>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
|
||||
export default {
|
||||
preFetch({store, currentRoute}) {
|
||||
return useFlightsStore(store).fetchPublicDetail({
|
||||
urlSlug: currentRoute.params.flightSlug,
|
||||
username: currentRoute.params.username
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script setup>
|
||||
import FlightTrackMap from "components/flights/FlightTrackMap.vue";
|
||||
import FlightBasicInfo from "components/flights/FlightBasicInfo.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue";
|
||||
import PhotoGallery from "components/photos/PhotoGallery.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {flightName} from "src/composables/flight";
|
||||
import {useMeta} from "quasar";
|
||||
import {aircraftKeywords, flightEventKeywords, flightKeywords, metaData} from "src/composables/meta";
|
||||
import ContentCard from "components/ContentCard.vue";
|
||||
import {usePhotosStore} from "stores/Photos";
|
||||
import {computed, onMounted} from "vue";
|
||||
import {publicFlightBreadcrumbs} from "src/composables/breadcrumbs";
|
||||
import {useAirspacesStore} from "stores/Airspaces";
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import {useFlightPlansStore} from "stores/FlightPlans";
|
||||
|
||||
const {isQueryRunning, flight} = storeToRefs(useFlightsStore());
|
||||
|
||||
const props = defineProps({
|
||||
pilot: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
usePhotosStore().fetchPublic({flightId: flight.value.id});
|
||||
useAirspacesStore().fetch();
|
||||
useAirportsStore().fetch();
|
||||
|
||||
if (flight.value.flightPlanId) {
|
||||
useFlightPlansStore().fetchPublicDetail({username: props.pilot.publicUsername, id: flight.value.flightPlanId});
|
||||
}
|
||||
})
|
||||
const {isQueryRunning: arePhotosLoading, photos: flightPhotos} = storeToRefs(usePhotosStore());
|
||||
const {airspaces} = storeToRefs(useAirspacesStore());
|
||||
const {airports} = storeToRefs(useAirportsStore());
|
||||
const {flightPlan} = storeToRefs(useFlightPlansStore());
|
||||
|
||||
const flightNameFormatted = flightName(flight.value);
|
||||
const event = computed(() => flight.value.event);
|
||||
|
||||
useAppStore().setPageTitle(flightNameFormatted);
|
||||
useAppStore().setBreadcrumbs(publicFlightBreadcrumbs(props.pilot, flight.value, event.value, false));
|
||||
|
||||
useMeta(() => metaData({
|
||||
title: `${flightNameFormatted} - ${props.pilot.name}`,
|
||||
keywords: [
|
||||
...flightKeywords(flight.value),
|
||||
props.pilot.name,
|
||||
...aircraftKeywords(flight.value.aircraft),
|
||||
...flightEventKeywords(event.value)
|
||||
],
|
||||
description: flight.value.description,
|
||||
image: flight.value.socialImageUrl
|
||||
}
|
||||
));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FlightDetailSkeleton v-if="isQueryRunning || !flight"/>
|
||||
|
||||
<ContentCard :title-photo="flight.titlePhoto" :title="flightName(flight)" v-else>
|
||||
<template #content>
|
||||
<q-card-section>
|
||||
<FlightBasicInfo
|
||||
:flight="flight"
|
||||
:poi-detail-route="{name: 'publicPOIDetail'}"
|
||||
:aircraft-detail-route="{name: 'publicAircraftDetail'}"
|
||||
:event-detail-route="event && event.isPublic ? {name: 'publicEventDetail', params: {username: pilot.publicUsername, eventSlug: event.urlSlug}} : null"
|
||||
:copilot-detail-route="{name: 'publicCopilotDetail', params: {username: pilot.publicUsername}}"
|
||||
/>
|
||||
|
||||
<article class="q-my-lg" v-html="flight.description"/>
|
||||
|
||||
<q-no-ssr>
|
||||
<q-inner-loading v-if="arePhotosLoading"/>
|
||||
<PhotoGallery :photos="flightPhotos" v-else-if="!arePhotosLoading && flightPhotos.length > 0"/>
|
||||
<FlightTrackMap
|
||||
:airspaces-data="airspaces"
|
||||
:airports="airports"
|
||||
:key="flight.id"
|
||||
:flight="flight"
|
||||
:flight-plan="flightPlan"
|
||||
:show-info-window="true"
|
||||
:showLayerControls="false"
|
||||
:photos="flightPhotos"/>
|
||||
</q-no-ssr>
|
||||
</q-card-section>
|
||||
</template>
|
||||
</ContentCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,7 +21,7 @@ import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
import {useAirportsStore} from "stores/Airports";
|
||||
import {formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
import {useFlightPlanCalculations, useFlightPlanUtils} from "src/composables/flightPlan";
|
||||
import FlightPlanRouteTable from "components/flightPlans/FlightPlanRouteTable.vue";
|
||||
import FlightPlanRouteTable from "components/flightPlans/FlightPlanNavLogTable.vue";
|
||||
import {useMeta} from "quasar";
|
||||
import {metaData} from "src/composables/meta";
|
||||
|
||||
|
||||
@@ -28,19 +28,18 @@ useMeta(() => metaData({
|
||||
useAppStore().setPageTitle(props.pilot.publicUsername);
|
||||
useAppStore().setBreadcrumbs(publicProfileBreadcrumbs(props.pilot, false))
|
||||
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
|
||||
filters.value = {username: props.pilot.publicUsername};
|
||||
const {loadPage, handleFiltersChanged} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
handleFiltersChanged({username: props.pilot.publicUsername})
|
||||
});
|
||||
|
||||
const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
|
||||
const {flights, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicFlightEventSwitcher :username="pilot?.publicUsername"/>
|
||||
|
||||
<FlightsListSkeleton :items="paginationLimit" v-if="areFlightsLoading && flights?.length === 0"/>
|
||||
<FlightsListSkeleton v-if="areFlightsLoading && flights?.length === 0"/>
|
||||
|
||||
<q-banner class="bg-primary text-white" v-else-if="!areFlightsLoading && flights.length === 0">
|
||||
Tento pilot zatím nepublikoval žádný let.
|
||||
@@ -49,9 +48,6 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
|
||||
<FlightsList
|
||||
v-else
|
||||
:items="flights"
|
||||
:total-items-count="totalItemsCount"
|
||||
:is-query-running="areFlightsLoading"
|
||||
:items-per-page="paginationLimit"
|
||||
:public="true"
|
||||
@load-more="loadPage(true)"
|
||||
/>
|
||||
|
||||
@@ -41,11 +41,11 @@ useAppStore().setBreadcrumbs([
|
||||
{label: pointOfInterest.value.name}
|
||||
]);
|
||||
|
||||
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
|
||||
filters.value = {pointOfInterestId: pointOfInterest.value.id};
|
||||
const {loadPage, handleFiltersChanged, hasMoreData} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
|
||||
|
||||
onMounted(() => {
|
||||
usePhotosStore().fetchPublic({pointOfInterestId: pointOfInterest.value.id});
|
||||
loadPage(false);
|
||||
handleFiltersChanged({pointOfInterestId: pointOfInterest.value.id});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -79,7 +79,8 @@ onMounted(() => {
|
||||
:is-query-running="areFlightsLoading"
|
||||
:items-per-page="paginationLimit"
|
||||
:public="true"
|
||||
@load-more="loadPage(true)"
|
||||
:has-more-data="hasMoreData"
|
||||
@load-more="loadPage($event.index, $event.done)"
|
||||
/>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
@@ -17,21 +17,11 @@ useMeta(() => metaData({
|
||||
title: "Místa"
|
||||
}))
|
||||
|
||||
const {filters, loadPage, paginationOffset, paginationLimit} = usePagination({
|
||||
const {loadPage, paginationLimit, hasMoreData, handleFiltersChanged} = usePagination({
|
||||
fetchFunction: usePointsOfInterestStore().fetchPublic
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadPage(false);
|
||||
});
|
||||
|
||||
const handleFiltersChanged = (newFilters) => {
|
||||
paginationOffset.value = 0;
|
||||
filters.value = newFilters;
|
||||
loadPage(false);
|
||||
}
|
||||
|
||||
const {pointsOfInterest, totalItemsCount, isQueryRunning} = storeToRefs(usePointsOfInterestStore());
|
||||
const {pointsOfInterest, isQueryRunning} = storeToRefs(usePointsOfInterestStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,10 +33,8 @@ const {pointsOfInterest, totalItemsCount, isQueryRunning} = storeToRefs(usePoint
|
||||
v-else
|
||||
:public="true"
|
||||
:items="pointsOfInterest"
|
||||
:total-items-count="totalItemsCount"
|
||||
:is-query-running="isQueryRunning"
|
||||
:items-per-page="paginationLimit"
|
||||
@load-more="loadPage(true)"
|
||||
:has-more-data="hasMoreData"
|
||||
@load-more="loadPage($event.index, $event.done)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup>
|
||||
import {reactive, ref} from "vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import {useMeta, useQuasar} from "quasar";
|
||||
import {errorObject, successObject} from "src/composables/dialog";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {isRequired, isValidEmail, minLength} from "src/composables/form/rules";
|
||||
import {isRequired, isValidEmail} from "src/composables/form/rules";
|
||||
import {api} from "boot/axios";
|
||||
import {useAuthStore} from "stores/Auth";
|
||||
import {metaData} from "src/composables/meta";
|
||||
|
||||
const router = useRouter();
|
||||
const $q = useQuasar();
|
||||
|
||||
useAppStore().setPageTitle("Zapomenuté heslo");
|
||||
@@ -18,19 +15,21 @@ useMeta(() => metaData({
|
||||
index: false
|
||||
}));
|
||||
|
||||
const requestPasswordChange = () => {
|
||||
const requestPasswordChange = async () => {
|
||||
if (!form.value.validate()) return;
|
||||
|
||||
return api.post('/forgotten-password/request', formData).then(response => {
|
||||
$q.notify(successObject("Žádost o změnu hesla odeslána, zkontroluj si svůj e-mail."))
|
||||
}).catch(({response}) => {
|
||||
try {
|
||||
await api.post('/forgotten-password/request', formData);
|
||||
$q.notify(successObject("Žádost o změnu hesla odeslána, zkontroluj si svůj e-mail."));
|
||||
} catch (e) {
|
||||
const {response} = e;
|
||||
if (response.status === 404) {
|
||||
$q.notify(errorObject(`E-mail ${formData.email} není registrovaný.`));
|
||||
} else {
|
||||
console.error(response)
|
||||
$q.notify(errorObject("Při odesílání se stala chyba. Zkus to za chvíli znovu."));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const form = ref(null);
|
||||
@@ -49,26 +48,27 @@ const rules = {
|
||||
<q-page-container>
|
||||
|
||||
<q-page class="row justify-center">
|
||||
<q-card flat square style="width:95%; max-width: 450px; height: 500px">
|
||||
|
||||
<q-form
|
||||
ref="form"
|
||||
class="q-px-sm q-pt-xl q-pb-lg"
|
||||
style="width:95%; max-width: 450px; height: 500px"
|
||||
@submit.prevent="requestPasswordChange"
|
||||
>
|
||||
|
||||
<q-card flat square>
|
||||
<q-card-section class="bg-primary">
|
||||
<h4 class="text-h5 text-white q-my-md">Zapomenuté heslo</h4>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-form
|
||||
ref="form"
|
||||
class="q-px-sm q-pt-xl q-pb-lg"
|
||||
@submit.prevent="requestPasswordChange"
|
||||
>
|
||||
|
||||
<q-input square clearable v-model="formData.email" type="email" label="E-mail" :rules="rules.email">
|
||||
<q-input v-model="formData.email" type="email" label="E-mail" :rules="rules.email">
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="email"/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-card-actions class="q-px-lg">
|
||||
<q-card-actions>
|
||||
<q-btn
|
||||
@click="requestPasswordChange"
|
||||
unelevated
|
||||
@@ -82,6 +82,8 @@ const rules = {
|
||||
<router-link :to="{name: 'login'}" class="text-grey-6">Zpět na login</router-link>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-form>
|
||||
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user