Author SHA1 Message Date
Michal Kváček 160bc1b031 Fix nacitani vsech letu do profilove casti 2026-09-14 12:28:50 +02:00
Michal Kváček ae7072ddee bugfixing 2026-09-14 11:08:22 +02:00
Michal Kváček f7845b963d Snad fix memory leaku kvuli axiosu 2026-09-14 10:48:55 +02:00
Michal Kváček 65ea19664c Bugfixing infinite scrollu u seznamu letu, novy vzhled detailu letu 2026-01-26 16:24:15 +01:00
Michal Kváček 348025afee Nastrel nove podoby detailu letu 2026-01-19 22:26:14 +01:00
Michal Kváček 33e7aa6e36 lepsi infinite scroll 2026-01-06 22:02:24 +01:00
Michal Kváček ea88a29750 Zfunkcneni aplikace, aktualizace zavislosti 2025-09-24 16:10:11 +02:00
Michal Kváček 448d97e310 Fix map 2025-09-18 22:04:37 +02:00
Michal Kváček 018153aff1 Bugfixing 2025-03-17 16:38:28 +01:00
Michal Kváček 87c7f17116 Upgrade quasaru, bugfixing 2025-03-05 13:23:56 +01:00
Michal Kváček 532aee9f32 Commit po dlohe dobe 2025-01-27 16:41:32 +01:00
Michal Kváček 92c9e53ea0 Bugfixing 2024-10-14 06:38:52 +02:00
Michal Kváček be977ee241 Pridani informace o planovaci na homepage 2024-10-09 06:57:21 +02:00
Michal Kváček bffdd96be2 Rozdeleni editace letu do samostatnych views 2024-10-03 22:41:14 +02:00
Michal Kváček 86e8dbf8e7 Rozdeleni planovace do samostatnych routes 2024-09-30 06:43:58 +02:00
Michal Kváček 786c59cc4a Zobrazeni pocasi po trase planu 2024-09-09 17:41:34 +02:00
Michal Kváček 7011aa3223 Nasazeni cookie listyz pres GTM, sentry jen v produkci, doplneni textovych stranek 2024-08-27 18:30:13 +02:00
111 changed files with 4267 additions and 3027 deletions
+2
View File
@@ -28,3 +28,5 @@ yarn-error.log*
*.njsproj *.njsproj
*.sln *.sln
.vscode .vscode
quasar.config.js.temporary.*
+3 -2
View File
@@ -1,5 +1,5 @@
# develop stage # develop stage
FROM node:20-alpine AS develop-stage FROM node:22-alpine AS develop-stage
ENV TZ=Europe/Prague ENV TZ=Europe/Prague
@@ -18,8 +18,9 @@ ENV API_URL "https://api.poletani.cz"
ENV GOOGLE_MAPS_API_KEY "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio" ENV GOOGLE_MAPS_API_KEY "AIzaSyAfTU1VntF9rp2D9TR4CFXIMALF5GLgsio"
RUN quasar build -m ssr RUN quasar build -m ssr
RUN cp /app/dist/ssr/server/server-entry.mjs /app/dist/ssr/server/server-entry.js
FROM node:20-alpine AS production-stage FROM node:22-alpine AS production-stage
ENV NODE_ENV production ENV NODE_ENV production
ENV TZ=Europe/Prague ENV TZ=Europe/Prague
+16
View File
@@ -11,8 +11,24 @@
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico"> <link rel="icon" type="image/ico" href="favicon.ico">
<!-- Google Tag Manager -->
<script v-if="process.env.NODE_ENV === 'production'">
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-WC8BTPJ4');
</script>
<!-- End Google Tag Manager -->
</head> </head>
<body> <body>
<!-- Google Tag Manager (noscript) -->
<noscript v-if="process.env.NODE_ENV === 'production'">
<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-WC8BTPJ4" height="0" width="0" style="display:none;visibility:hidden"></iframe>
</noscript>
<!-- End Google Tag Manager (noscript) -->
<!-- quasar:entry-point --> <!-- quasar:entry-point -->
</body> </body>
</html> </html>
+36 -24
View File
@@ -10,37 +10,49 @@
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore" "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.7.14", "@apollo/client": "^4.0.5",
"@quasar/extras": "^1.15.11", "@quasar/extras": "^1.16.16",
"@sentry/browser": "^8.9.2", "@sentry/browser": "^10.14.0",
"@sentry/integrations": "^7.114.0", "@sentry/integrations": "^7.114.0",
"@sentry/vue": "^8.11.0", "@sentry/vue": "^10.14.0",
"@vue/apollo-composable": "4.0.0-beta.2", "@vue/apollo-composable": "4.2.2",
"@vue/apollo-option": "^4.0.0-beta.5", "@vue/apollo-option": "^4.2.0",
"apollo-upload-client": "^17.0.0", "apollo-upload-client": "^19.0.0",
"axios": "^1.6.2", "axios": "^1.7.9",
"chart.js": "^4.4.1", "chart.js": "^4.4.7",
"graphql": "16.6.0", "graphql": "16.11.0",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"photoswipe": "^5.4.2", "photoswipe": "^5.4.4",
"photoswipe-dynamic-caption-plugin": "^1.2.7", "photoswipe-dynamic-caption-plugin": "^1.2.7",
"pinia": "^2.0.33", "pinia": "^3.0.3",
"quasar": "2.16.2", "quasar": "2.18.2",
"vue": "3.4.27", "rxjs": "^7.8.2",
"vue-chartjs": "^5.3.1", "vue": "3.5.21",
"vue-chartjs": "^5.3.2",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",
"vue-router": "^4.2.5", "vue-router": "^4.5.0",
"vue3-google-map": "^0.20.0" "vue3-google-map": "^0.24.1"
}, },
"devDependencies": { "devDependencies": {
"@quasar/app-vite": "1.9.3", "@quasar/app-vite": "2.4.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.21",
"eslint": "^8.55.0", "eslint": "^9.36.0",
"eslint-config-prettier": "^8.10.0", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^9.19.2", "eslint-plugin-vue": "^10.5.0",
"postcss": "^8.4.14", "postcss": "^8.5.6",
"prettier": "^2.8.8" "prettier": "^3.6.2"
}, },
"browserslist": [
"last 10 Chrome versions",
"last 10 Firefox versions",
"last 4 Edge versions",
"last 7 Safari versions",
"last 8 Android versions",
"last 8 ChromeAndroid versions",
"last 8 FirefoxAndroid versions",
"last 10 iOS versions",
"last 5 Opera versions"
],
"engines": { "engines": {
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
"yarn": ">= 1.21.1" "yarn": ">= 1.21.1"
-27
View File
@@ -1,27 +0,0 @@
/* eslint-disable */
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
plugins: [
// https://github.com/postcss/autoprefixer
require('autoprefixer')({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
})
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
]
}
+6
View File
@@ -3,3 +3,9 @@ apps:
script: /app/index.js script: /app/index.js
exec_mode: cluster exec_mode: cluster
instances: 3 instances: 3
# Pojistka proti bodu 1 (a podobným leakům): pokud paměť workeru
# přeroste limit, PM2 ho sám restartuje dřív, než dojde k OOM na
# celém VPS. Hodnotu je potřeba doladit dle reálné spotřeby jednoho
# SSR workeru po opravě bodu 1 (pm2 monit / pm2 list) - viz
# handoffs/05-pm2-docker-memory-limits.md.
max_memory_restart: '400M'
+7 -3
View File
@@ -8,8 +8,7 @@
// Configuration for your app // Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const {configure} = require('quasar/wrappers'); import { configure } from 'quasar/wrappers'
module.exports = configure(function (ctx) { module.exports = configure(function (ctx) {
return { return {
@@ -29,7 +28,12 @@ module.exports = configure(function (ctx) {
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files // https://v2.quasar.dev/quasar-cli/boot-files
boot: [ boot: [
"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", "cookies",
"axios", "axios",
"apollo", "apollo",
+8 -8
View File
@@ -1,10 +1,10 @@
import { ssrMiddleware } from 'quasar/wrappers' import { defineSsrMiddleware } from '#q-app/wrappers'
// This middleware should execute as last one // This middleware should execute as last one
// since it captures everything and tries to // since it captures everything and tries to
// render the page with Vue // render the page with Vue
export default ssrMiddleware(({ app, resolve, render, serve }) => { export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
// we capture any other Express route and hand it // we capture any other Express route and hand it
// over to Vue and Vue Router to render our page // over to Vue and Vue Router to render our page
app.get(resolve.urlPath('*'), (req, res) => { app.get(resolve.urlPath('*'), (req, res) => {
@@ -28,12 +28,9 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
} else if (err.code === 404) { } else if (err.code === 404) {
// hmm, Vue Router could not find the requested route // hmm, Vue Router could not find the requested route
console.log("404 v renderu")
// Should reach here only if no "catch-all" route // Should reach here only if no "catch-all" route
// is defined in /src/routes // is defined in /src/routes
// res.status(404).send('404 | Page Not Found') res.status(404).send('404 | Page Not Found')
res.redirect(resolve.urlPath('404'))
} else if (process.env.DEV) { } else if (process.env.DEV) {
// well, we treat any other code as error; // well, we treat any other code as error;
// if we're in dev mode, then we can use Quasar CLI // if we're in dev mode, then we can use Quasar CLI
@@ -42,7 +39,6 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
// serve.error is available on dev only // serve.error is available on dev only
serve.error({ err, req, res }) serve.error({ err, req, res })
} else { } else {
// we're in production, so we should have another method // we're in production, so we should have another method
// to display something to the client when we encounter an error // to display something to the client when we encounter an error
@@ -51,7 +47,11 @@ export default ssrMiddleware(({ app, resolve, render, serve }) => {
// Render Error Page on production or // Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it // create a route (/src/routes) for an error page and redirect to it
res.redirect(resolve.urlPath('500')) res.status(500).send('500 | Internal Server Error')
if (process.env.DEBUGGING) {
console.error(err.stack)
}
} }
}) })
}) })
+46 -25
View File
@@ -12,21 +12,22 @@
import express from 'express' import express from 'express'
import compression from 'compression' import compression from 'compression'
import { import {
ssrClose, defineSsrCreate,
ssrCreate, defineSsrInjectDevMiddleware,
ssrListen, defineSsrListen,
ssrRenderPreloadTag, defineSsrClose,
ssrServeStaticContent defineSsrServeStaticContent,
} from 'quasar/wrappers' defineSsrRenderPreloadTag
} from '#q-app/wrappers'
/** /**
* Create your webserver and return its instance. * Create your webserver and return its instance.
* If needed, prepare your webserver to receive * If needed, prepare your webserver to receive
* connect-like middlewares. * connect-like middlewares.
* *
* Should NOT be async! * Can be async: defineSsrCreate(async ({ ... }) => { ... })
*/ */
export const create = ssrCreate((/* { ... } */) => { export const create = defineSsrCreate((/* { ... } */) => {
const app = express() const app = express()
// attackers can use this header to detect apps running Express // attackers can use this header to detect apps running Express
@@ -42,6 +43,19 @@ export const create = ssrCreate((/* { ... } */) => {
return app return app
}) })
/**
* Used by Quasar SSR dev server to inject middleware into the webserver.
* It uses it to handle Vite dev server, handle public paths, etc.
* The given middleware is compatible with `node:http`'s Server, Express, Connect, etc.
*
* Can be async: defineSsrInjectDevMiddleware(async ({ app }) => { ... })
*/
export const injectDevMiddleware = defineSsrInjectDevMiddleware(({ app }) => {
return (middleware) => {
app.use(middleware)
}
})
/** /**
* You need to make the server listen to the indicated port * You need to make the server listen to the indicated port
* and return the listening instance or whatever you need to * and return the listening instance or whatever you need to
@@ -52,10 +66,12 @@ export const create = ssrCreate((/* { ... } */) => {
* *
* For production, you can instead export your * For production, you can instead export your
* handler for serverless use or whatever else fits your needs. * handler for serverless use or whatever else fits your needs.
*
* Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
*/ */
export const listen = ssrListen(async ({ app, port, isReady }) => { export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
await isReady() const server = devHttpsApp || app
return app.listen(port, () => { return server.listen(port, () => {
if (process.env.PROD) { if (process.env.PROD) {
console.log('Server listening at port ' + port) console.log('Server listening at port ' + port)
} }
@@ -70,9 +86,9 @@ export const listen = ssrListen(async ({ app, port, isReady }) => {
* Should you need the result of the "listen()" call above, * Should you need the result of the "listen()" call above,
* you can use the "listenResult" param. * you can use the "listenResult" param.
* *
* Can be async. * Can be async: defineSsrClose(async ({ listenResult }) => { ... })
*/ */
export const close = ssrClose(({ listenResult }) => { export const close = defineSsrClose(({ listenResult }) => {
return listenResult.close() return listenResult.close()
}) })
@@ -81,14 +97,19 @@ const maxAge = process.env.DEV
: 1000 * 60 * 60 * 24 * 30 : 1000 * 60 * 60 * 24 * 30
/** /**
* Should return middleware that serves the indicated path * Should return a function that will be used to configure the webserver
* with static content. * to serve static content at "urlPath" from "pathToServe" folder/file.
*
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
*
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
*/ */
export const serveStaticContent = ssrServeStaticContent((path, opts) => { export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
return express.static(path, { return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
maxAge, const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
...opts app.use(resolve.urlPath(urlPath), serveFn)
}) }
}) })
const jsRE = /\.js$/ const jsRE = /\.js$/
@@ -103,13 +124,13 @@ const pngRE = /\.png$/
* Should return a String with HTML output * Should return a String with HTML output
* (if any) for preloading indicated file * (if any) for preloading indicated file
*/ */
export const renderPreloadTag = ssrRenderPreloadTag((file) => { export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
if (jsRE.test(file) === true) { if (jsRE.test(file) === true) {
return `<link rel="modulepreload" href="${file}" crossorigin>` return `<link rel="modulepreload" href="${file}" crossorigin>`
} }
if (cssRE.test(file) === true) { if (cssRE.test(file) === true) {
return `<link rel="stylesheet" href="${file}">` return `<link rel="stylesheet" href="${file}" crossorigin>`
} }
if (woffRE.test(file) === true) { if (woffRE.test(file) === true) {
@@ -121,15 +142,15 @@ export const renderPreloadTag = ssrRenderPreloadTag((file) => {
} }
if (gifRE.test(file) === true) { if (gifRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/gif">` return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
} }
if (jpgRE.test(file) === true) { if (jpgRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg">` return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
} }
if (pngRE.test(file) === true) { if (pngRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/png">` return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
} }
return '' return ''
-10
View File
@@ -1,10 +0,0 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
ssr: true;
}
}
+6
View File
@@ -4,6 +4,8 @@ import {computed} from "vue";
import ErrorNotFound from "pages/errors/ErrorNotFound.vue"; import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import {useMeta} from "quasar";
import {metaData} from "src/composables/meta";
const route = useRoute(); const route = useRoute();
@@ -13,6 +15,10 @@ const layoutComponent = computed(() => {
}); });
const appStatus = storeToRefs(useAppStore()).appStatus; const appStatus = storeToRefs(useAppStore()).appStatus;
useMeta(() => metaData({
title: useAppStore().pageTitle,
}));
</script> </script>
<template> <template>
+125 -83
View File
@@ -1,107 +1,149 @@
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core' import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client/core'
import {onError} from "@apollo/client/link/error"; import {ErrorLink} from "@apollo/client/link/error";
import {defineStore} from "pinia";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import {Notify} from "quasar"; import {Notify} from "quasar";
import {errorObject, warningObject} from "src/composables/dialog"; import {errorObject, warningObject} from "src/composables/dialog";
import {useAuthStore} from "stores/Auth"; import {useAuthStore} from "stores/Auth";
import {createUploadLink} from "apollo-upload-client"; import UploadHttpLink from "apollo-upload-client/UploadHttpLink.mjs";
// import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
const handleRefreshToken = (store, router, forward, operation) => { const handleRefreshToken = (store, router, forward, operation) => {
if (!operation.query?._refreshUnauthorizedUser) return; if (!operation.query?._refreshUnauthorizedUser) return;
console.log("refreshing token, ", operation.query); console.log("refreshing token, ", operation.query);
return fromPromise(useAuthStore(store).refreshAccessToken() return useAuthStore(store).refreshAccessToken()
.catch((error) => { .catch((error) => {
Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu.")); Notify.create(warningObject("Tvoje přihlášení vypršelo. Přihlaš se prosím znovu."));
router.push({name: "login"}) router.push({name: "login"})
})) })
.filter((value) => Boolean(value)) .then(value => value.flatMap((accessToken) => {
.flatMap((accessToken) => { const oldHeaders = operation.getContext().headers;
const oldHeaders = operation.getContext().headers; operation.setContext({
operation.setContext({ headers: {
headers: { ...oldHeaders,
...oldHeaders, authorization: `Bearer ${accessToken}`,
authorization: `Bearer ${accessToken}`, },
}, });
});
// retry the request, returning the new observable // retry the request, returning the new observable
return forward(operation); return forward(operation);
}); }))
} }
export const apolloWrapper = {}; // SSR: boot() runs again for every request. The client/query/mutate below
export default (({store, router}) => { // used to live on a shared module-level `apolloWrapper` object that every
const uploadLink = createUploadLink({ // request's boot() reassigned in place - under concurrent SSR requests, one
uri: process.env.API_URL + '/graphql', // request could end up calling into another request's (still-being-set-up
credentials: 'include' // or already-overwritten) apolloClient/store, i.e. querying/mutating with
}) // the wrong user's auth context. See
// handoffs/02-apollo-wrapper-race-condition.md for the full analysis.
//
// Fix: make this a Pinia store instead of a plain shared object. `init()`
// is called from boot() with the request's own `store`/`router` explicitly
// (same proven pattern as useAuthStore(store) elsewhere in this file), so
// the closures below always close over the correct request. `query`/
// `mutate` are then read back through Pinia's own SSR-safe per-request
// resolution (useApolloStore() with no arg, called from preFetch/
// onServerPrefetch-triggered store actions - the same mechanism every
// other useXStore() call in this codebase already relies on), instead of
// through a shared mutable object.
export const useApolloStore = defineStore('Apollo', () => {
let client = null
let queryImpl = null
let mutateImpl = null
const authLink = new ApolloLink((operation, forward) => { const init = ({store, router}) => {
const accessToken = useAuthStore(store).getAccessToken(); if (client) return // boot() already initialized this request's store
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
});
}
return forward(operation);
});
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => { const uploadLink = new UploadHttpLink({
if (networkError?.statusCode === 401) { uri: process.env.API_URL + '/graphql',
return handleRefreshToken(store, router, forward, operation); credentials: 'include'
} })
if (graphQLErrors) { const authLink = new ApolloLink((operation, forward) => {
for (const error of graphQLErrors) { const accessToken = useAuthStore(store).getAccessToken();
if (error.message.includes("401") || error.message.includes("Not authorized")) { if (accessToken) {
return handleRefreshToken(store, router, forward, operation); operation.setContext({
} else if (error.message.includes("404") || error.message.includes("Not found")) { headers: {"Authorization": `Bearer ${accessToken}`},
useAppStore(store).appStatus = 404; });
} else { }
Notify.create(errorObject("API error: " + error.message)); return forward(operation);
});
const errorLink = new ErrorLink(({graphQLErrors, networkError, operation, forward}) => {
if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation);
}
if (graphQLErrors) {
for (const error of graphQLErrors) {
if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation);
} else if (error.message.includes("404") || error.message.includes("Not found")) {
useAppStore(store).appStatus = 404;
} else {
Notify.create(errorObject("API error: " + error.message));
}
} }
} }
}
});
const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, uploadLink]),
cache: new InMemoryCache(),
connectToDevTools: true,
ssrMode: true,
});
apolloWrapper.query = (query, variables = {}) => {
return apolloClient.query({
query: query,
fetchPolicy: 'no-cache',
variables: variables
}) })
.then((response) => response)
.catch(err => { client = new ApolloClient({
const errStr = err.toString(); link: ApolloLink.from([authLink, errorLink, uploadLink]),
if (errStr.includes('Not found') || errStr.includes("No row was found")) { cache: new InMemoryCache(),
useAppStore(store).appStatus = 404; connectToDevTools: true,
return Promise.resolve(); ssrMode: true,
} });
else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err) queryImpl = (query, variables = {}) => {
} return client.query({
return Promise.reject(err); query: query,
})
};
apolloWrapper.mutate = (mutation, variables) => {
return apolloClient
.mutate({
mutation: mutation,
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
variables: variables, variables: variables
}) })
.then((response) => response) .then((response) => response)
.catch(err => { .catch(err => {
console.error("Error during mutation: ", err); const errStr = err.toString();
}) if (errStr.includes('Not found') || errStr.includes("No row was found")) {
}; useAppStore(store).appStatus = 404;
return Promise.resolve();
} else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err)
}
return Promise.reject(err);
})
};
mutateImpl = (mutation, variables) => {
return client
.mutate({
mutation: mutation,
fetchPolicy: 'no-cache',
variables: variables,
})
.then((response) => response)
.catch(err => {
console.error("Error during mutation: ", err);
})
};
}
const query = (query, variables) => queryImpl(query, variables)
const mutate = (mutation, variables) => mutateImpl(mutation, variables)
return {init, query, mutate}
})
// Backward-compatible shape so existing call sites
// (`apolloWrapper.query(...)`, `apolloWrapper.mutate(...)` in
// stores/helpers.js and stores/Organizations.js) keep working unchanged,
// now delegating to the request-scoped store above instead of a shared
// mutable singleton.
export const apolloWrapper = {
query: (...args) => useApolloStore().query(...args),
mutate: (...args) => useApolloStore().mutate(...args),
}
export default (({store, router}) => {
useApolloStore(store).init({store, router})
}); });
+18 -2
View File
@@ -13,8 +13,24 @@ const api = axios.create({
}, },
}) })
// SSR: boot() runs again for every request, but `api` above is a
// module-level singleton created once. Without ejecting the previous
// request's interceptors before adding new ones, api.interceptors.*
// would grow without bound for the lifetime of the process, each
// handler closing over that request's (by-then-stale) store - see
// handoffs/01-axios-interceptors-leak.md for the full analysis.
let requestInterceptorId
let responseInterceptorId
export default boot(({store}) => { export default boot(({store}) => {
api.interceptors.request.use((config) => { if (requestInterceptorId !== undefined) {
api.interceptors.request.eject(requestInterceptorId)
}
if (responseInterceptorId !== undefined) {
api.interceptors.response.eject(responseInterceptorId)
}
requestInterceptorId = api.interceptors.request.use((config) => {
const token = useAuthStore(store).getAccessToken(); const token = useAuthStore(store).getAccessToken();
if (token) { if (token) {
config.headers['Authorization'] = `Bearer ${token}` config.headers['Authorization'] = `Bearer ${token}`
@@ -28,7 +44,7 @@ export default boot(({store}) => {
}) })
// Response interceptor for API calls // Response interceptor for API calls
api.interceptors.response.use( responseInterceptorId = api.interceptors.response.use(
(response) => response, (response) => response,
async function (error) { async function (error) {
const originalRequest = error.config; const originalRequest = error.config;
+28 -5
View File
@@ -1,9 +1,32 @@
import {Cookies} from 'quasar' import {Cookies} from 'quasar'
import {boot} from "quasar/wrappers"; import {boot} from "quasar/wrappers";
import {defineStore} from 'pinia'
let cookies = Cookies; // SSR: boot() runs again for every request. `cookies` used to be a single
export default boot(({ssrContext}) => { // shared module-level variable reassigned in place by every request's
cookies = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies // boot() - under concurrent SSR requests, one request's
// Cookies.parseSSR(ssrContext) result (which carries that request's own
// accessToken) could overwrite another still-in-flight request's
// `cookies` before it finished reading it, leaking one user's access
// token to another. See handoffs/03-cookies-shared-state.md.
//
// Fix: make this a Pinia store, initialized explicitly per request from
// boot() with that request's own `store` (same proven pattern as
// boot/apollo.js), instead of a shared mutable variable.
export const useCookiesStore = defineStore('Cookies', () => {
let instance = null
const init = (ssrContext) => {
instance = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
}
const get = (...args) => instance.get(...args)
const set = (...args) => instance.set(...args)
const remove = (...args) => instance.remove(...args)
return {init, get, set, remove}
})
export default boot(({store, ssrContext}) => {
useCookiesStore(store).init(ssrContext)
}); });
export {cookies}
+7 -11
View File
@@ -1,7 +1,14 @@
import {boot} from "quasar/wrappers"; import {boot} from "quasar/wrappers";
import * as Sentry from "@sentry/browser"; 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}) => { export default boot(({app, router}) => {
if (process.env.SERVER) return;
if (process.env.NODE_ENV !== 'production') return;
Sentry.init({ Sentry.init({
app, app,
dsn: "https://b2580fb40f4f0037d24f32e89d3e5df2@o472821.ingest.us.sentry.io/4506051015802880", dsn: "https://b2580fb40f4f0037d24f32e89d3e5df2@o472821.ingest.us.sentry.io/4506051015802880",
@@ -9,19 +16,8 @@ export default boot(({app, router}) => {
Sentry.browserTracingIntegration({router}), Sentry.browserTracingIntegration({router}),
Sentry.replayIntegration(), Sentry.replayIntegration(),
], ],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for tracing.
// We recommend adjusting this value in production
tracesSampleRate: 1.0, 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, replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0, replaysOnErrorSampleRate: 1.0,
}); });
}); });
+5 -2
View File
@@ -13,7 +13,6 @@ const props = defineProps({
}) })
const config = computed(() => { const config = computed(() => {
if (props.mode === "add") { if (props.mode === "add") {
return {icon: "add", label: "Přidat"}; return {icon: "add", label: "Přidat"};
} else if (props.mode === "edit") { } else if (props.mode === "edit") {
@@ -26,7 +25,11 @@ const config = computed(() => {
</script> </script>
<template> <template>
<q-page-sticky location="bottom" :offset="[30, 30]"> <q-page-sticky
location="bottom"
:offset="[30, 30]"
v-if="$q.screen.lt.md"
>
<q-fab :icon="config.icon" color="primary" :label="config.label" @click="$router.push(props.routerLink)"/> <q-fab :icon="config.icon" color="primary" :label="config.label" @click="$router.push(props.routerLink)"/>
</q-page-sticky> </q-page-sticky>
</template> </template>
+14 -5
View File
@@ -38,14 +38,23 @@ onMounted(() => {
if (search) { if (search) {
filters.value.search = search; filters.value.search = search;
} else {
filters.value = {};
} }
}) })
</script> </script>
<template> <template>
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable> <div class="flex">
<template #prepend> <div class="col-grow">
<q-icon name="search"/> <q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
</template> <template #prepend>
</q-input> <q-icon name="search"/>
</template>
</q-input>
</div>
<div class="flex items-center">
<slot name="append" class="full-height"/>
</div>
</div>
</template> </template>
-41
View File
@@ -1,41 +0,0 @@
<script setup>
const props = defineProps({
totalItemsCount: {
type: Number,
required: true
},
itemsLength: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
})
const emits = defineEmits(["load-more"]);
const loadMore = () => {
emits("load-more", props.itemsPerPage);
}
</script>
<template>
<div>
<slot name="items"/>
<slot name="skeleton" v-if="isQueryRunning"/>
<div class="text-center q-my-md">
<q-btn color="primary" :loading="isQueryRunning" rounded size="lg" icon="expand_more" @click="loadMore()" v-if="totalItemsCount > itemsLength">
<slot name="loadMoreText"/>
</q-btn>
</div>
</div>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup>
import {computed} from "vue";
const props = defineProps({
mode: {
type: String,
required: true
},
routerLink: {
type: Object,
required: true
}
})
const config = computed(() => {
if (props.mode === "add") {
return {icon: "add", label: "Přidat"};
} else if (props.mode === "edit") {
return {icon: "edit", label: "Upravit"};
}
return {icon: "unknown", label: `Neznámý typ ${props.mode}`}
})
</script>
<template>
<div>
<q-toolbar class="justify-end q-pr-none" v-if="$q.screen.gt.sm">
<q-btn color="primary" size="lg" :icon="config.icon" :to="props.routerLink">{{ config.label }}</q-btn>
</q-toolbar>
<q-page-sticky
location="bottom"
:offset="[30, 30]"
v-else
>
<q-fab
:icon="config.icon"
color="primary"
:label="config.label"
@click="$router.push(props.routerLink)"
/>
</q-page-sticky>
</div>
</template>
+41 -56
View File
@@ -1,68 +1,53 @@
<script setup> <script setup>
import LoadMoreList from "components/LoadMoreList.vue"; import {useInfiniteScroll} from "src/composables/pagination";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: true required: true
}, },
totalItemsCount: { ...useInfiniteScroll().propsDef
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
}); });
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script> </script>
<template> <template>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length"> <q-infinite-scroll
<template #items> :disable="!hasMoreData"
<q-list bordered separator> @load="emitLoadMore"
<q-item :offset="250"
clickable >
v-ripple <q-list bordered separator>
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}" <q-item
v-for="aircraft of props.items" :key="aircraft" clickable
> v-ripple
<q-item-section avatar> :to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
<q-avatar v-if="aircraft.titlePhoto"> v-for="aircraft of props.items" :key="aircraft"
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/> >
</q-avatar> <q-item-section avatar>
<q-avatar color="teal" text-color="white" icon="flight" v-else/> <q-avatar v-if="aircraft.titlePhoto">
</q-item-section> <img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
<q-item-section> </q-avatar>
<q-item-label label> <q-avatar color="teal" text-color="white" icon="flight" v-else/>
{{ aircraft.callSign }} </q-item-section>
</q-item-label> <q-item-section>
<q-item-label caption> <q-item-label label>
{{ aircraft.model }} {{ aircraft.callSign }}
<template v-if="aircraft.manufacturer"> </q-item-label>
({{ aircraft.manufacturer }}) <q-item-label caption>
</template> {{ aircraft.model }}
</q-item-label> <template v-if="aircraft.manufacturer">
<q-item-label caption v-if="aircraft.organization"> ({{ aircraft.manufacturer }})
Provozovatel: {{ aircraft.organization.name }} </template>
</q-item-label> </q-item-label>
</q-item-section> <q-item-label caption v-if="aircraft.organization">
</q-item> Provozovatel: {{ aircraft.organization.name }}
</q-list> </q-item-label>
</template> </q-item-section>
<template #skeleton> </q-item>
<BasicListSkeleton :items="itemsPerPage"/> </q-list>
</template> </q-infinite-scroll>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
+1 -1
View File
@@ -37,7 +37,7 @@ useEventsStore().fetchFlightsTracks({
totalTime.value += flightTrack.durationMinCalculated; totalTime.value += flightTrack.durationMinCalculated;
if (flightTrack.track) { if (flightTrack.track) {
tracks.value.push(flightTrack.track.coordinates); tracks.value.push(flightTrack.track.points.coordinates);
} }
} }
}); });
@@ -1,9 +1,9 @@
<script setup> <script setup>
import DatetimePicker from "components/inputs/DatetimePicker.vue"; import DatetimePicker from "components/inputs/DatetimePicker.vue";
import {computed, reactive} from "vue"; import {reactive} from "vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {useFlightPlanCalculations, useFlightPlanUtils} from "src/composables/flightPlan"; import {useFlightPlanCalculations} from "src/composables/flightPlan";
const props = defineProps({ const props = defineProps({
flightPlan: { flightPlan: {
@@ -17,18 +17,13 @@ const {getTotalDuration} = useFlightPlanCalculations();
const formData = reactive({ const formData = reactive({
gpxTrackFile: null, gpxTrackFile: null,
takeoffDatetime: props.flightPlan.plannedTakeoffDatetime, 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, copyTrack: true,
createPointsOfInterests: true, createPointsOfInterests: true,
flightPlan: props.flightPlan, flightPlan: props.flightPlan,
}); });
const {getMarkersFrom} = useFlightPlanUtils();
const hasGeocodeInTrack = computed(() => {
return !!getMarkersFrom(props.flightPlan).find(point => point.type === 'geocode');
});
</script> </script>
<template> <template>
@@ -3,7 +3,7 @@ import {commonProps} from "src/composables/inputs";
import DatetimePicker from "components/inputs/DatetimePicker.vue"; import DatetimePicker from "components/inputs/DatetimePicker.vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {reactive} from "vue"; import {computed, reactive} from "vue";
import CopyLinkButton from "components/inputs/CopyLinkButton.vue"; import CopyLinkButton from "components/inputs/CopyLinkButton.vue";
import {useLoggedUserStore} from "stores/LoggedUser"; import {useLoggedUserStore} from "stores/LoggedUser";
import {storeToRefs} from "pinia"; 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({ const formData = reactive({
name: props.flightPlan.name || null, name: props.flightPlan.name || null,
@@ -18,7 +18,6 @@ const props = defineProps({
const columns = [ const columns = [
{ {
// name: 'from',
label: 'Z', label: 'Z',
align: "left", align: "left",
field: 'from' field: 'from'
@@ -29,13 +29,18 @@ const placesFilter = (val, update, abort) => {
searchGeocode(val).then(results => { searchGeocode(val).then(results => {
if (!results) return; if (!results) return;
placesOptions.value = results.map(result => ({ console.log(results)
name: result.formatted_address,
type: "geocode", placesOptions.value = results.map(result => {
id: null, console.log(result, result.geometry.location)
gpsLatitude: result.geometry.location.lat, return {
gpsLongitude: result.geometry.location.lng, name: result.formatted_address,
})) type: "geocode",
id: null,
gpsLatitude: result.geometry.location.lat(),
gpsLongitude: result.geometry.location.lng(),
}
})
}); });
} }
} else { } else {
@@ -31,6 +31,9 @@ const swapMarkers = (idx1, idx2) => {
}; };
const handlePlaceSelect = (modelVal) => { const handlePlaceSelect = (modelVal) => {
model.value.push(modelVal); model.value.push(modelVal);
// TODO: upravit bounds u mapy
} }
watch(() => model.value, () => { watch(() => model.value, () => {
@@ -80,7 +83,7 @@ const allPlacesOptions = computed(() => {
<q-btn <q-btn
v-if="i < model.length - 1" v-if="i < model.length - 1"
text-color="primary" text-color="primary"
flat dense round icon="arrow_downward" flat dense round icon="`arrow_downward`"
size="16px" size="16px"
@click="swapMarkers(i, i+1)" @click="swapMarkers(i, i+1)"
@@ -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>
+36 -24
View File
@@ -1,36 +1,48 @@
<script setup> <script setup>
import {formatDatetime} from "src/composables/datetimeUtils"; import {formatDatetime} from "src/composables/datetimeUtils";
import {useInfiniteScroll} from "src/composables/pagination";
const props = defineProps({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: true required: true
} },
})
...useInfiniteScroll().propsDef
});
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script> </script>
<template> <template>
<q-list bordered separator> <q-infinite-scroll
<q-item :disable="!hasMoreData"
clickable @load="emitLoadMore"
:to="{name: 'editFlightPlan', params: {id: flightPlan.id }}" :offset="250"
v-ripple >
v-for="flightPlan of items" :key="flightPlan"> <q-list bordered separator>
<q-item-section avatar> <q-item
<q-avatar icon="flight" color="primary" text-color="white"/> clickable
</q-item-section> :to="{name: 'editFlightPlan', params: {id: flightPlan.id }}"
<q-item-section> v-ripple
<q-item-label> v-for="flightPlan of items" :key="flightPlan">
{{ flightPlan.name || "Nepojmenovaný plán" }} <q-item-section avatar>
</q-item-label> <q-avatar icon="flight" color="primary" text-color="white"/>
<q-item-label caption v-if="flightPlan.plannedTakeoffDatetime"> </q-item-section>
{{ formatDatetime(flightPlan.plannedTakeoffDatetime) }} <q-item-section>
</q-item-label> <q-item-label>
<q-item-label caption v-if="flightPlan.aircraft"> {{ flightPlan.name || "Nepojmenovaný plán" }}
{{ flightPlan.aircraft.callSign }} </q-item-label>
</q-item-label> <q-item-label caption v-if="flightPlan.plannedTakeoffDatetime">
</q-item-section> {{ formatDatetime(flightPlan.plannedTakeoffDatetime) }}
</q-item> </q-item-label>
</q-list> <q-item-label caption v-if="flightPlan.aircraft">
{{ flightPlan.aircraft.callSign }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-infinite-scroll>
</template> </template>
@@ -0,0 +1,59 @@
<script setup>
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
const props = defineProps({
flight: {type: Object, required: true},
pilot: {type: Object, required: true},
routeDetail: {type: Object, required: true},
})
</script>
<template>
<div class="flight-header-info">
<h2 class="text-h4">
<router-link :to="routeDetail" v-if="routeDetail">
{{ flight.name }}
</router-link>
<span v-else>{{ flight.name }}</span>
</h2>
<div class="q-my-lg">
{{ formatDatetime(flight.takeoffDatetime, FORMAT_DATE) }}
</div>
<div>
<span v-for="(turnPoint, i) in flight.turnPoints">
{{ turnPoint.pointOfInterest.name }}
<span v-if="i < Object.keys(flight.turnPoints).length - 1" class="q-mx-md"> &bull;</span>
</span>
</div>
<div class="q-my-lg">
<router-link :to="{name: 'publicAircraftDetail', params: {callSign: flight.aircraft.callSign}}">
{{ flight.aircraft.callSign }}
</router-link>
</div>
<div class="q-my-lg" v-if="flight.description !== ''">
<div v-html="flight.description"></div>
</div>
<slot class="q-my-lg" name="default"></slot>
</div>
</template>
<style lang="scss">
.flight-header-info a {
&, &:visited {
color: #e6f5ff;
text-decoration: none;
}
&:hover {
color: #c2d2dd;
}
}
</style>
+51 -58
View File
@@ -2,10 +2,7 @@
import {minutesToHumanReadable} from "src/composables/datetimeUtils"; import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {flightName} from "src/composables/flight"; import {flightName} from "src/composables/flight";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue"; import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue"; import {useInfiniteScroll} from "src/composables/pagination";
import {computed} from "vue";
import LoadMoreList from "components/LoadMoreList.vue";
import {pluralize} from "../../composables/utils";
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -16,54 +13,46 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true required: true
}, },
totalItemsCount: { ...useInfiniteScroll().propsDef
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
}); });
const emits = defineEmits([...useInfiniteScroll().emitsDef])
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
const routeDetail = (flight) => { const routeDetail = (flight) => {
if (props.public) if (props.public)
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}} return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
else else
return {name: "flightDetail", params: {id: flight.id}} return {name: "flightDetail", params: {id: flight.id}}
}; };
const loadMoreCount = computed(() => {
return Math.min(props.itemsPerPage, props.totalItemsCount - props.items.length);
});
</script> </script>
<template> <template>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length"> <q-infinite-scroll
<template #items> :disable="!hasMoreData"
<q-list bordered separator> @load="emitLoadMore"
<q-item :offset="250"
v-for="flight of items" :key="flight" >
ripple <q-list bordered separator>
clickable <q-item
:to="routeDetail(flight)" v-for="flight of items" :key="flight"
> ripple
<q-item-section avatar> clickable
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/> :to="routeDetail(flight)"
<q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar> >
</q-item-section> <q-item-section avatar class="gt-xs">
<q-item-section> <AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div> <q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
</q-item-section>
<q-item-section>
<q-img v-if="flight.titlePhoto" class="lt-sm" :src="flight.titlePhoto.thumbnailUrl"/>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<div class="flex justify-between justify-sm-start list-secondary-info">
<div class="text-secondary" v-if="props.public"> <div class="text-secondary" v-if="props.public">
<q-icon name="support_agent"/> <q-icon name="support_agent"/>
@{{ flight.pilot.publicUsername }} {{ flight.pilot.publicUsername }}
</div> </div>
<div class="text-secondary" v-if="flight.copilots"> <div class="text-secondary" v-if="flight.copilots">
@@ -85,27 +74,31 @@ const loadMoreCount = computed(() => {
<q-icon name="event"/> <q-icon name="event"/>
{{ flight.event.name }} {{ flight.event.name }}
</div> </div>
</q-item-section> </div>
</q-item-section>
<q-item-section side top v-if="!props.public"> <q-item-section side top v-if="!props.public">
<!-- Zobraz jen v adminu --> <!-- Zobraz jen v adminu -->
<q-icon name="visibility" v-if="flight.isPublic"/> <q-icon name="visibility" v-if="flight.isPublic"/>
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''"> <q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
<q-tooltip> <q-tooltip>
Let je sice publikovaný, nemá ale nastavenou URL adresu! Let je sice publikovaný, nemá ale nastavenou URL adresu!
</q-tooltip> </q-tooltip>
</q-icon> </q-icon>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
<template v-slot:loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px"/>
</div>
</template> </template>
<template #skeleton> </q-infinite-scroll>
<FlightsListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Načíst {{ pluralize(loadMoreCount, 'jeden let', `další ${loadMoreCount} lety`, `dalších ${loadMoreCount} letů`) }}
</template>
</LoadMoreList>
</template> </template>
<style>
.list-secondary-info div {
padding: 0 1em;
}
</style>
@@ -1,7 +1,7 @@
<script setup> <script setup>
import {ref} from "vue"; import {ref} from "vue";
import {flightName} from "src/composables/flight"; import FlightHeaderInfo from "components/flights/FlightHeaderInfo.vue";
import LatestFlightSliderInfo from "components/flights/slider/LatestFlightSliderInfo.vue"; import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -37,11 +37,25 @@ const slide = ref(0);
height="400px" height="400px"
v-if="flight.titlePhoto" v-if="flight.titlePhoto"
> >
<div class="absolute-bottom"> <div class="absolute-full">
<LatestFlightSliderInfo :flight="flight"/> <FlightHeaderInfo
:route-detail="{name: 'publicFlightDetailNew', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
:pilot="flight.pilot"
:flight="flight"
>
<div class="absolute-bottom-right">
<router-link
class="q-pa-lg"
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
>
{{ flight.pilot.publicUsername }}
</router-link>
</div>
</FlightHeaderInfo>
</div> </div>
</q-img> </q-img>
<LatestFlightSliderInfo v-else :flight="flight" text-color="primary"/>
</q-carousel-slide> </q-carousel-slide>
</q-carousel> </q-carousel>
</div> </div>
@@ -19,42 +19,11 @@ const formData = reactive({
copilots: [], copilots: [],
}) })
const solo = ref(false);
watch(() => solo.value, (newVal) => {
if (!newVal) {
formData.copilots = [];
}
});
watch(() => props.flight, (newVal) => {
if (newVal) {
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
solo.value = formData.copilots.length === 0;
}
}, {deep: true, immediate: true});
</script> </script>
<template> <template>
<FormWrapper v-model="formData"> <FormWrapper v-model="formData">
<q-btn-toggle
spread
no-caps
v-model="solo"
toggle-color="primary"
:options="[{label: 'Solo', value: true}, {label: 'S někým', value: false}]"
/>
<div v-if="!solo">
<ComboboxInput
v-for="i in flight.aircraft.seats - 1"
:key="i"
v-bind="commonProps"
:options="copilots"
v-model="formData.copilots[i-1]"
:clearable="true"
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
/>
</div>
</FormWrapper> </FormWrapper>
</template> </template>
@@ -8,6 +8,7 @@ import ComboboxInput from "components/inputs/ComboboxInput.vue";
import SlugInput from "components/inputs/SlugInput.vue"; import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser"; import {useLoggedUserStore} from "stores/LoggedUser";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -18,6 +19,10 @@ const props = defineProps({
type: Array, type: Array,
required: false, required: false,
}, },
copilots: {
type: Array,
required: false,
},
isRequestPending: { isRequestPending: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -31,7 +36,8 @@ const formData = reactive({
description: "", description: "",
debriefingNotes: "", debriefingNotes: "",
urlSlug: "", urlSlug: "",
isPublic: null isPublic: null,
copilots: []
}) })
@@ -47,6 +53,20 @@ watch(() => props.flight, (newVal) => {
} }
}, {immediate: true}); }, {immediate: true});
const solo = ref(false);
watch(() => solo.value, (newVal) => {
if (!newVal) {
formData.copilots = [];
}
});
watch(() => props.flight, (newVal) => {
if (newVal) {
formData.copilots = newVal.copilots.map(copilot => ({id: copilot.id, name: copilot.name}));
solo.value = formData.copilots.length === 0;
}
}, {deep: true, immediate: true});
const descriptionTab = ref("description"); const descriptionTab = ref("description");
</script> </script>
@@ -66,18 +86,52 @@ const descriptionTab = ref("description");
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/> <q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template> </template>
<div>
Letel jsi
<q-radio
v-model="solo"
:val="true"
label="Sólo"
/>
<q-radio
v-model="solo"
:val="false"
label="S někým"
/>
</div>
<div v-if="!solo">
<ComboboxInput
v-for="i in flight.aircraft.seats - 1"
:key="i"
v-bind="commonProps"
:options="copilots"
v-model="formData.copilots[i-1]"
:clearable="true"
:label="i === 1 ? 'Kopilot' : 'Pasažér'"
/>
</div>
<q-tabs v-model="descriptionTab" active-color="primary"> <q-tabs v-model="descriptionTab" active-color="primary">
<q-tab name="description">Popis</q-tab> <q-tab name="description">Popis</q-tab>
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab> <q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
</q-tabs> </q-tabs>
<q-tab-panels v-model="descriptionTab"> <q-tab-panels v-model="descriptionTab">
<q-tab-panel name="description"> <q-tab-panel name="description">
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/> <RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="debriefing"> <q-tab-panel name="debriefing">
<BasicInfoBanner>
Debriefing by měl obsahovat minimálně následující body:
<ul>
<li><strong>Shrnutí letu:</strong> Trasa, čas a splnění cílů.</li>
<li><strong>Výkon:</strong> Navigace, reakce na podmínky, zvládnutí letu.</li>
<li><strong>Stav letadla:</strong> Funkčnost přístrojů, případné problémy.</li>
<li><strong>Bezpečnostní postupy:</strong> Dodržení bezpečnostních pravidel, řešení neočekávaných situací.</li>
<li><strong>Zpětná vazba:</strong> Pocity z letu, spokojenost pasažéra, návrhy na zlepšení.</li>
</ul>
</BasicInfoBanner>
<RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/> <RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/>
</q-tab-panel> </q-tab-panel>
@@ -25,12 +25,11 @@ onMounted(() => {
}) })
const steps = [ const steps = [
{name: "basic", icon: "flight_takeoff", label: "Základní informace"}, {name: "editFlightBasic", icon: "flight_takeoff", label: "Základní informace"},
{name: "copilot", icon: "person_add", label: "Spolucestující"}, {name: "editFlightTrack", icon: "route", label: "Trasa"},
{name: "track", icon: "route", label: "Trasa"}, {name: "editFlightPhotos", icon: "add_a_photo", label: "Fotky"},
{name: "photos", icon: "add_a_photo", label: "Fotky"}, {name: "editFlightNotes", icon: "edit_note", label: "Poznámky"},
{name: "notes", icon: "edit_note", label: "Poznámky"}, {name: "editFlightSetPlan", icon: "pin_drop", label: "Plán"},
{name: "plan", icon: "pin_drop", label: "Plán"},
]; ];
const emit = defineEmits(["update:modelValue"]) const emit = defineEmits(["update:modelValue"])
@@ -38,14 +37,20 @@ watch(currentStep, (val) => emit("update:modelValue", val));
</script> </script>
<template> <template>
<q-tabs v-if="routerEditMode" active-color="primary" class="q-mb-lg"> <q-tabs
v-if="routerEditMode"
active-color="primary"
class="q-mb-lg"
left-icon="chevron_left"
right-icon="chevron_right"
>
<q-route-tab <q-route-tab
v-for="step in steps" v-for="step in steps"
:key="step.name" :key="step.name"
:icon="step.icon" :icon="step.icon"
:label="step.label" :label="step.label"
replace replace
:to="{name: 'flightEdit', params: {id: flightId, step: step.name}}" :to="{name: step.name, params: {id: flightId}}"
:disable="disabled.includes(step.name)" :disable="disabled.includes(step.name)"
exact exact
/> />
@@ -61,7 +66,6 @@ watch(currentStep, (val) => emit("update:modelValue", val));
active-color="primary" active-color="primary"
class="q-mb-lg" class="q-mb-lg"
> >
<q-tab v-for="step in steps" :key="step.name" v-bind="step" :disable="disabled.includes(step.name)"/> <q-tab v-for="step in steps" :key="step.name" v-bind="step" :disable="disabled.includes(step.name)"/>
</q-tabs> </q-tabs>
</template> </template>
@@ -0,0 +1,23 @@
<script setup></script>
<template>
<div class="row">
<div class="col">
<q-card class="q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
</div>
</div>
<div class="row">
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
</div>
</div>
</template>
@@ -0,0 +1,6 @@
<script setup></script>
<template>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
<q-skeleton type="rect" width="100%" height="500px"/>
</template>
@@ -1,77 +0,0 @@
<script setup>
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import {randomInt} from "src/composables/random";
const props = defineProps({
step: {
type: String,
required: true
},
})
</script>
<template>
<template v-if="step === 'basic'">
<div class="row">
<div class="col">
<q-card class="q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
</div>
</div>
<div class="row">
<div class="col col-12 col-md-6" v-for="i in 2" :key="i">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
</div>
</div>
</template>
<q-card v-else-if="step === 'copilot'">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm"/>
</q-card-section>
</q-card>
</q-card>
<template v-else-if="step === 'track'">
<q-card>
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
</template>
<template v-else-if="step === 'photos'">
<q-card class="q-my-lg">
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm"/>
</q-card-section>
</q-card>
<div class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="i in randomInt(3, 10)" :key="i">
<q-card class="q-ma-md">
<q-skeleton height="300px" type="rect"/>
</q-card>
</div>
</div>
</template>
<div v-else-if="step === 'notes'">
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
<q-skeleton type="rect" width="100%" height="500px"/>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
</script>
<template>
<q-card>
<q-card-section>
<q-skeleton type="QInput" class="q-my-sm" v-for="i in 2" :key="i"/>
</q-card-section>
</q-card>
<BasicListSkeleton :items-count-min="2" :items-count-max="6"/>
</template>
@@ -1,33 +0,0 @@
<script setup>
import {flightName} from "src/composables/flight";
const props = defineProps({
flight: {
type: Object,
required: true
},
textColor: {
type: String,
required: false,
default: "white"
}
})
</script>
<template>
<div class="text-h4">
<router-link
:class="`text-${props.textColor}`"
:to="{name: 'publicFlightDetail', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
>
{{ flightName(flight) }}
</router-link>
</div>
<router-link
:class="`text-${props.textColor}`"
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
>
@{{ flight.pilot.publicUsername }}
</router-link>
</template>
@@ -3,13 +3,16 @@
<template> <template>
<q-timeline color="primary" class="q-my-lg"> <q-timeline color="primary" class="q-my-lg">
<q-timeline-entry title="Naplánuj si let">
<div>
Pomocí jednoduchého plánovače si sestav trasu, označ si zajímavé body, připrav si navigační štítek a zkontroluj počasí.
</div>
</q-timeline-entry>
<q-timeline-entry title="Zaznamenej si let"> <q-timeline-entry title="Zaznamenej si let">
<div> <div>
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání. Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
</div> </div>
</q-timeline-entry>
<q-timeline-entry title="Ulož trasu">
<div> <div>
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně. Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
</div> </div>
+22 -10
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import {Marker} from "vue3-google-map"; import {Marker} from "vue3-google-map";
import {onMounted, ref} from "vue"; import {onMounted, ref, watch} from "vue";
import MapWrapper from "components/map/MapWrapper.vue"; import MapWrapper from "components/map/MapWrapper.vue";
import {useGeocode} from "src/composables/geocoding"; import {useGeocode} from "src/composables/geocoding";
import axios from "axios"; import axios from "axios";
@@ -32,22 +32,34 @@ const markerDropped = (position) => {
} }
const zoom = ref(8); const zoom = ref(8);
onMounted(() => {
if (props.gpsLatitude && props.gpsLongitude) {
center.value.lng = props.gpsLongitude; watch(() => [props.label, props.gpsLongitude, props.gpsLatitude], (val, oldValue) => {
center.value.lat = props.gpsLatitude const [newAddress, newLon, newLat] = val
} else if (props.label) { const [previousAddress, oldLon, oldLat] = oldValue
searchGeocode(props.label).then(results => { if (newAddress) {
if (newAddress === previousAddress) return
searchGeocode(newAddress).then(results => {
if (!results) return; if (!results) return;
center.value = results[0].geometry.location;
const lat = results[0].geometry.location.lat()
const lng = results[0].geometry.location.lng()
center.value = {lng, lat}
emitModels(center.value.lat, center.value.lng); emitModels(center.value.lat, center.value.lng);
zoom.value = 13; zoom.value = 13;
}); });
} else if (newLat && newLon && (props.gpsLatitude !== newLat || props.gpsLongitude !== newLon)) {
center.value.lng = newLon;
center.value.lat = newLat
} else { } else {
console.log(newAddress, newLon, newLat, props.label, props.gpsLongitude, props.gpsLatitude)
console.error("Set either label or gps coordinates."); console.error("Set either label or gps coordinates.");
} }
}) },
// {immediate: true}
)
</script> </script>
<template> <template>
@@ -0,0 +1,19 @@
<script setup>
import {useAirportsStore} from "stores/Airports";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {ref} from "vue";
const props = defineProps({
modelValue: {
type: [Object, Array]
},
})
const model = ref("");
const options = useAirportsStore().fetch();
</script>
<template>
<ComboboxInput :options="options" v-model="model"
</template>
+5
View File
@@ -241,6 +241,11 @@ const isControlsOpened = ref(false);
</script> </script>
<template> <template>
<!-- <q-banner>-->
<!-- https://dev.to/quasar/how-to-use-google-maps-in-a-quasar-project-1c13-->
<!-- </q-banner>-->
<GoogleMap <GoogleMap
:api-key="key" :api-key="key"
v-bind="$attrs" v-bind="$attrs"
@@ -19,6 +19,9 @@ const props = defineProps({
copilots: { copilots: {
type: Array type: Array
}, },
loading: {
type: Boolean
}
}); });
const formData = ref({ const formData = ref({
@@ -44,7 +47,12 @@ const setup = () => {
<q-card-section class="text-center"> <q-card-section class="text-center">
<q-img :src="photo.thumbnailUrl" width="80px" class="q-ma-sm" v-for="photo in photos" :key="photo.thumbnailUrl"/> <q-img :src="photo.thumbnailUrl" width="80px" class="q-ma-sm" v-for="photo in photos" :key="photo.thumbnailUrl"/>
</q-card-section> </q-card-section>
<q-card-section>
<q-card-section v-if="loading">
<q-spinner />
</q-card-section>
<q-card-section v-else>
<FormWrapper :save-button="false"> <FormWrapper :save-button="false">
<ComboboxInput <ComboboxInput
:options="locations" :options="locations"
+8 -2
View File
@@ -18,6 +18,9 @@ const props = defineProps({
}, },
copilots: { copilots: {
type: Array type: Array
},
loading: {
type: Boolean
} }
}); });
@@ -38,7 +41,10 @@ const setup = (() => {
<template> <template>
<DialogWrapper :formData="formData" :object-id="photoId" @show="setup"> <DialogWrapper :formData="formData" :object-id="photoId" @show="setup">
<q-img :src="photo.thumbnailUrl"/> <q-img :src="photo.thumbnailUrl"/>
<q-card-section> <q-card-section v-if="loading">
<q-spinner />
</q-card-section>
<q-card-section v-else>
<FormWrapper :save-button="false"> <FormWrapper :save-button="false">
<ComboboxInput <ComboboxInput
:options="locations" :options="locations"
@@ -67,7 +73,7 @@ const setup = (() => {
option-value="id" option-value="id"
emit-value emit-value
map-options map-options
/> />
<q-input v-model="formData.name" label="Název" v-bind="commonProps"/> <q-input v-model="formData.name" label="Název" v-bind="commonProps"/>
<q-input type="textarea" v-model="formData.description" label="Popis" v-bind="commonProps"/> <q-input type="textarea" v-model="formData.description" label="Popis" v-bind="commonProps"/>
+2 -16
View File
@@ -46,17 +46,6 @@ const initLightbox = () => {
$lightbox.value.init(); $lightbox.value.init();
} }
const isCollapsed = ref(true);
const photosCollapseLimit = computed(() => {
if ($q.screen.width <= $q.screen.sizes.sm) return 2;
else if ($q.screen.width <= $q.screen.sizes.md) return 3;
else return 4;
});
onMounted(() => {
isCollapsed.value = props.photos.length >= photosCollapseLimit.value;
});
onUnmounted(() => { onUnmounted(() => {
if ($lightbox.value) { if ($lightbox.value) {
$lightbox.value.destroy(); $lightbox.value.destroy();
@@ -81,12 +70,12 @@ watch(() => [openedPhoto.value, $lightbox.value], () => {
</script> </script>
<template> <template>
<div :class="{'photogallery--wrapper': true, collapsed: isCollapsed}" :id="`${galleryId}-wrapper`"> <div :class="{'photogallery--wrapper': true}" :id="`${galleryId}-wrapper`">
<div :id="galleryId" class="row photogallery--content"> <div :id="galleryId" class="row photogallery--content">
<div <div
v-for="photo in photos" :key="photo.id" v-for="photo in photos" :key="photo.id"
style="min-height: 100px" style="min-height: 100px"
class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" class="col-6 col-sm-4 col-md-4 col-lg-2 q-pa-sm"
> >
<a <a
:data-pswp-src="photo.url" :data-pswp-src="photo.url"
@@ -111,9 +100,6 @@ watch(() => [openedPhoto.value, $lightbox.value], () => {
</div> </div>
</div> </div>
<a class="photogallery--collapse" v-if="isCollapsed" @click="isCollapsed = false">
<q-btn class="q-mt-lg q-mb-md" color="secondary">Rozbalit</q-btn>
</a>
</div> </div>
</template> </template>
+1 -1
View File
@@ -86,7 +86,7 @@ const showLarge = (displayedPhoto) => {
</div> </div>
<q-btn flat icon="edit" color="blue" @click="openEdit(photo)"/> <q-btn flat icon="edit" color="blue" @click="openEdit(photo)"/>
<q-btn flat icon="tune" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/> <q-btn flat icon="tune" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/>
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"/> <q-btn flat icon="delete" color="red" :disabled="titlePhoto?.id === photo.id" @click="confirmPhotoDelete(photo.id)"/>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</div> </div>
+11 -11
View File
@@ -74,8 +74,18 @@ const handlePositionNotFound = () => {
<template #append v-if="showResetMapButton"> <template #append v-if="showResetMapButton">
<q-btn @click="resetMap" icon="gps_fixed" round flat text-color="green" title="Nastav pozici na mapě"/> <q-btn @click="resetMap" icon="gps_fixed" round flat text-color="green" title="Nastav pozici na mapě"/>
</template> </template>
<template #after>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
</q-input> </q-input>
<MapGPSInput
:label="poiNameForMap"
:zoom="zoom"
v-model:gpsLatitude="formData.gpsLatitude"
v-model:gpsLongitude="formData.gpsLongitude"
@not-found="handlePositionNotFound"
/>
<SlugInput <SlugInput
v-if="formData.isPublic" v-if="formData.isPublic"
v-bind="commonProps" v-bind="commonProps"
@@ -85,21 +95,11 @@ const handlePositionNotFound = () => {
:slug-source="formData.name" :slug-source="formData.name"
:rules="rules.urlSlug" :rules="rules.urlSlug"
/> />
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
<MapGPSInput
v-if="poiNameForMap"
:key="poiNameForMap"
:label="poiNameForMap"
:zoom="zoom"
v-model:gpsLatitude="formData.gpsLatitude"
v-model:gpsLongitude="formData.gpsLongitude"
@not-found="handlePositionNotFound"
/>
<q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis"/> <q-input type="textarea" v-model="formData.description" v-bind="commonProps" label="Popis"/>
<ComboboxInput v-model="formData.type" :options="types" label="Kategorie" v-bind="commonProps"/>
</FormWrapper> </FormWrapper>
</template> </template>
+31 -45
View File
@@ -1,6 +1,5 @@
<script setup> <script setup>
import LoadMoreList from "components/LoadMoreList.vue"; import {useInfiniteScroll} from "src/composables/pagination";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -11,20 +10,7 @@ const props = defineProps({
type: Boolean, type: Boolean,
required: true required: true
}, },
totalItemsCount: { ...useInfiniteScroll().propsDef,
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
}); });
const getRoute = (item) => { const getRoute = (item) => {
@@ -33,35 +19,35 @@ const getRoute = (item) => {
else else
return {name: "poiDetail", params: {id: item.id}} return {name: "poiDetail", params: {id: item.id}}
}; };
const emits = defineEmits(useInfiniteScroll().emitsDef)
const {emitLoadMore, hasMoreData} = useInfiniteScroll(emits, props)
</script> </script>
<template> <template>
<LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length"> <q-infinite-scroll
<template #items> :disable="!hasMoreData"
<q-list bordered separator> @load="emitLoadMore"
<q-item clickable v-for="poi of items" :key="poi" :to="getRoute(poi)"> :offset="250"
<q-item-section avatar> >
<q-avatar v-if="poi.titlePhoto"> <q-list bordered separator>
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/> <q-item clickable v-for="poi of items" :key="poi" :to="getRoute(poi)">
</q-avatar> <q-item-section avatar>
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/> <q-avatar v-if="poi.titlePhoto">
</q-item-section> <img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
<q-item-section> </q-avatar>
{{ poi.name }} <q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
<div class="text-secondary" v-if="poi.type"> </q-item-section>
{{ poi.type.name }} <q-item-section>
</div> {{ poi.name }}
</q-item-section> <div class="text-secondary" v-if="poi.type">
<q-item-section side top v-if="!props.public && poi.isPublic"> {{ poi.type.name }}
<q-icon name="visibility"></q-icon> </div>
</q-item-section> </q-item-section>
</q-item> <q-item-section side top v-if="!props.public && poi.isPublic">
</q-list> <q-icon name="visibility"></q-icon>
</template> </q-item-section>
<template #skeleton> </q-item>
<BasicListSkeleton :items="itemsPerPage"/> </q-list>
</template> </q-infinite-scroll>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
+2 -3
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import {ref} from "vue"; import {ref} from "vue";
import CloudCoverIcon from "components/weather/CloudCoverIcon.vue"; import CloudCoverIcon from "components/weather/CloudCoverIcon.vue";
import {useUnitsConvertors} from "src/composables/units";
const props = defineProps({ const props = defineProps({
weather: { weather: {
@@ -9,9 +10,7 @@ const props = defineProps({
} }
}) })
const kmhToKnots = (speedKmh) => { const {kmhToKnots} = useUnitsConvertors();
return Math.round(speedKmh * 0.53996);
}
const openDetail = ref(false); const openDetail = ref(false);
</script> </script>
+57 -27
View File
@@ -1,43 +1,73 @@
import {ref} from "vue"; import {ref} from "vue";
import axios from "axios";
export const useGeocode = () => { export const useGeocode = () => {
const center = ref({lat: 50.1314, lng: 14.5256}); const center = ref({lat: 50.1314, lng: 14.5256});
const getGeocoder = (limitMs = 5000) => {
let interval = null
const poolingWindowMs = 100
const searchGeocode = (address) => { const tries = limitMs / poolingWindowMs
return axios.get(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURI(address)}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => { let currentTry = 0
if (response.data.status !== 'OK') {
return null;
}
return response.data.results; return new Promise((resolve, reject) => {
}); let instance = null
} interval = setInterval(() => {
try {
instance = new google.maps.Geocoder()
const searchPlaceName = (lat, lng) => { clearInterval(interval)
return axios.get(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${process.env.GOOGLE_MAPS_API_KEY}`).then(response => { return resolve(instance)
if (response.data.status !== 'OK' || !response.data.results?.length) {
return null;
}
const results = response.data.results; } catch (e) {
const components = {}; if (++currentTry >= tries) {
for (const addressComponent of results[0].address_components) { clearInterval(interval)
for (const namePartType of ["locality", "postal_town", 'administrative_area_level_2']) { return reject(instance)
if (addressComponent.types.includes(namePartType)) {
components[namePartType] = addressComponent.long_name
} }
} }
} }, poolingWindowMs)
})
}
if (!Object.keys(components).length) { const searchGeocode = (address) => new Promise((resolve, reject) => {
return "Bod na mapě"; getGeocoder().then(geocoder => geocoder
} .geocode({address})
.then(({results}) => resolve(results))
.catch((e) => reject(e))).catch(() => {
console.log("Cannot get geocoder")
})
})
return Object.values(components).join(", ");
}); const searchPlaceName = (lat, lng) => {
const latlng = {
lat: parseFloat(lat), lng: parseFloat(lng),
};
return new Promise((resolve, reject) => {
getGeocoder().then((geocoder) => geocoder
.geocode({location: latlng})
.then((response) => {
const results = response.results;
const components = {};
for (const addressComponent of results[0].address_components) {
for (const namePartType of ["locality", "postal_town", 'administrative_area_level_2']) {
if (addressComponent.types.includes(namePartType)) {
components[namePartType] = addressComponent.long_name
}
}
}
if (!Object.keys(components).length) {
return resolve("Bod na mapě");
}
return resolve(Object.values(components).join(", "));
})
.catch((e) => reject(e))
)
})
} }
return {center, searchGeocode, searchPlaceName} return {center, searchGeocode, searchPlaceName}
+1 -1
View File
@@ -13,7 +13,7 @@ export const commonKeywords = [
export const aircraftKeywords = (aircraft) => { export const aircraftKeywords = (aircraft) => {
return [aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model] return ["letadlo", "letoun", aircraft.organization?.name, aircraft.callSign, aircraft.manufacturer, aircraft.model]
}; };
export const flightEventKeywords = (event) => { export const flightEventKeywords = (event) => {
+61 -8
View File
@@ -1,4 +1,4 @@
import {ref} from "vue"; import {computed, ref} from "vue";
export const usePagination = ({fetchFunction, itemsPerPage = 10}) => { export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
@@ -9,19 +9,72 @@ export const usePagination = ({fetchFunction, itemsPerPage = 10}) => {
const paginationLimit = ref(itemsPerPage); const paginationLimit = ref(itemsPerPage);
const paginationOffset = ref(0); const paginationOffset = ref(0);
const filters = ref({}); const filters = ref({});
const hasMoreData = ref(true)
const isQueryRunning = ref(false)
const loadPage = (appendItems) => {
const loadFirstPage = () => {
paginationOffset.value = 0
isQueryRunning.value = true
return fetchFunction({ return fetchFunction({
...filters.value, ...filters.value, limit: paginationLimit.value, offset: 0, appendItems: false
limit: paginationLimit.value, }).then((data) => {
offset: paginationOffset.value,
appendItems
}).then(() => {
paginationOffset.value += paginationLimit.value; paginationOffset.value += paginationLimit.value;
hasMoreData.value = data.length === paginationLimit.value;
}) })
.finally(() => {
isQueryRunning.value = false
})
}
const loadPage = (index, done) => {
if (isQueryRunning.value) return
isQueryRunning.value = true
return fetchFunction({
...filters.value, appendItems: true, limit: paginationLimit.value, offset: paginationOffset.value,
}).then((data) => {
paginationOffset.value += paginationLimit.value;
hasMoreData.value = data.length === paginationLimit.value;
if (done) done()
})
.finally(() => {
isQueryRunning.value = false
})
}
const handleFiltersChanged = (newFilters) => {
filters.value = newFilters;
loadFirstPage()
} }
return { return {
paginationOffset, paginationLimit, filters, loadPage paginationOffset, paginationLimit, handleFiltersChanged, filters, loadPage, loadFirstPage, hasMoreData
} }
} }
export const useLoading = (promiseGetter) => {
const loading = ref(false)
const load = () => {
loading.value = true
return promiseGetter().finally(() => loading.value = false)
}
return {loading, load}
}
export const useInfiniteScroll = (emits, props) => {
const emitsDef = ["load-more"]
const propsDef = {
hasMoreData: {
type: Boolean, required: false, default: true
}
}
const hasMoreData = computed(() => props.hasMoreData)
const emitLoadMore = (index, done) => emits("load-more", {index, done});
return {emitsDef, propsDef, hasMoreData, emitLoadMore}
}
+12 -2
View File
@@ -1,3 +1,13 @@
export const ktToKph = (speedInKt) => { export const useUnitsConvertors = () => {
return speedInKt;
const kmhToKnots = (speedKmh) => {
return Math.round(speedKmh * 0.53996);
}
const ftToM = (distanceInFt) => {
return Math.round(distanceInFt * 0.3048 * 10) / 10;
}
return {kmhToKnots, ftToM}
} }
+8 -8
View File
@@ -1,14 +1,14 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
export const FRAGMENT_AIRCRAFT_BASIC_INFO = gql` export const FRAGMENT_AIRCRAFT_BASIC_INFO = gql`
fragment aircraftBasicInfo on Aircraft { fragment aircraftBasicInfo on Aircraft {
id
callSign
manufacturer
organization {
id id
name callSign
manufacturer
organization {
id
name
}
} }
}
`; `;
+12
View File
@@ -1,5 +1,17 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
export const FRAGMENT_AIRPORT_LIST_INFO = gql`
fragment airportListInfo on Airport {
id
name
icaoCode
gpsLatitude
gpsLongitude
elevation
iconUrl
}
`;
export const FRAGMENT_AIRPORT_INFO = gql` export const FRAGMENT_AIRPORT_INFO = gql`
fragment airportInfo on Airport { fragment airportInfo on Airport {
id id
+1 -2
View File
@@ -7,7 +7,7 @@ import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
export const FRAGMENT_WEATHER_FULL_INFO = gql` export const FRAGMENT_WEATHER_FULL_INFO = gql`
fragment weatherFullInfo on WeatherInfo { fragment weatherFullInfo on WeatherInfo {
datetime dateTime
qnh qnh
temperatureSurface temperatureSurface
dewpointSurface dewpointSurface
@@ -30,7 +30,6 @@ fragment flightFullInfo on Flight {
durationMinCalculated durationMinCalculated
isPublic isPublic
flightPlanId flightPlanId
socialImageUrl socialImageUrl
titlePhoto { titlePhoto {
thumbnailUrl thumbnailUrl
+7
View File
@@ -1,4 +1,5 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
import {FRAGMENT_WEATHER_FULL_INFO} from "src/graphql/fragments/flight";
export const FRAGMENT_FLIGHT_PLAN_MARKER = gql` export const FRAGMENT_FLIGHT_PLAN_MARKER = gql`
fragment flightPlanMarker on FlightPlanMarker { fragment flightPlanMarker on FlightPlanMarker {
@@ -9,7 +10,13 @@ fragment flightPlanMarker on FlightPlanMarker {
gpsLongitude gpsLongitude
pointOfInterestId pointOfInterestId
airportId airportId
weatherInfo {
...weatherFullInfo
}
} }
${FRAGMENT_WEATHER_FULL_INFO}
` `
export const FRAGMENT_FLIGHT_PLAN_FULL_INFO = gql` export const FRAGMENT_FLIGHT_PLAN_FULL_INFO = gql`
+4 -4
View File
@@ -1,5 +1,5 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
import {FRAGMENT_AIRPORT_INFO} from "src/graphql/fragments/airport"; import {FRAGMENT_AIRPORT_LIST_INFO} from "src/graphql/fragments/airport";
import {FRAGMENT_AIRCRAFT_BASIC_INFO} from "src/graphql/fragments/aircraft_basic"; import {FRAGMENT_AIRCRAFT_BASIC_INFO} from "src/graphql/fragments/aircraft_basic";
export const FRAGMENT_FLIGHT_LIST_INFO = gql` export const FRAGMENT_FLIGHT_LIST_INFO = gql`
@@ -33,11 +33,11 @@ fragment flightListInfo on Flight {
} }
takeoffAirport { takeoffAirport {
...airportInfo ...airportListInfo
} }
landingAirport { landingAirport {
...airportInfo ...airportListInfo
} }
aircraft { aircraft {
@@ -45,7 +45,7 @@ fragment flightListInfo on Flight {
} }
} }
${FRAGMENT_AIRPORT_INFO} ${FRAGMENT_AIRPORT_LIST_INFO}
${FRAGMENT_AIRCRAFT_BASIC_INFO} ${FRAGMENT_AIRCRAFT_BASIC_INFO}
`; `;
+2 -4
View File
@@ -2,19 +2,17 @@ import gql from "graphql-tag";
import {FRAGMENT_AIRCRAFT_FULL_INFO, FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO, FRAGMENT_AIRCRAFT_LIST_INFO} from "src/graphql/fragments/aircraft"; import {FRAGMENT_AIRCRAFT_FULL_INFO, FRAGMENT_AIRCRAFT_FULL_PRIVATE_INFO, FRAGMENT_AIRCRAFT_LIST_INFO} from "src/graphql/fragments/aircraft";
export const AIRCRAFTS_LIST = gql` export const AIRCRAFTS_LIST = gql`
query AircraftsList ($limit: Int!, $offset: Int) { query AircraftsList ($search: String, $limit: Int!, $offset: Int) {
aircrafts(limit: $limit, offset: $offset) { aircrafts(search: $search, limit: $limit, offset: $offset) {
totalItemsCount totalItemsCount
items { items {
...aircraftListInfo ...aircraftListInfo
} }
} }
} }
${FRAGMENT_AIRCRAFT_LIST_INFO} ${FRAGMENT_AIRCRAFT_LIST_INFO}
`; `;
export const AIRCRAFT_DETAIL = gql` export const AIRCRAFT_DETAIL = gql`
query AircraftDetail($id: Int!) { query AircraftDetail($id: Int!) {
aircraft(id: $id) { aircraft(id: $id) {
+6 -4
View File
@@ -25,10 +25,12 @@ query eventsFlightsTracks($eventId: Int, $eventUrlSlug: String, $onlyPublic: Boo
event(id: $eventId, urlSlug: $eventUrlSlug, public: $onlyPublic) { event(id: $eventId, urlSlug: $eventUrlSlug, public: $onlyPublic) {
flights(onlyPublic: $onlyPublic) { flights(onlyPublic: $onlyPublic) {
durationMinCalculated durationMinCalculated
tracks { track {
coordinates { points {
lat coordinates {
lng lat
lng
}
} }
} }
} }
+8 -5
View File
@@ -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"; import {FRAGMENT_FLIGHT_PLAN_FULL_INFO, FRAGMENT_FLIGHT_PLAN_LIST_INFO} from "src/graphql/fragments/flightPlan";
export const FLIGHT_PLANS_LIST = gql` export const FLIGHT_PLANS_LIST = gql`
query flightPlansList { query flightPlansList($search: String, $limit: Int!, $offset: Int) {
flightPlans { flightPlans(search: $search, limit: $limit, offset: $offset) {
...flightPlanListInfo totalItemsCount
items {
...flightPlanListInfo
}
}
} }
}
${FRAGMENT_FLIGHT_PLAN_LIST_INFO} ${FRAGMENT_FLIGHT_PLAN_LIST_INFO}
`; `;
export const FLIGHT_PLAN_DETAIL = gql` export const FLIGHT_PLAN_DETAIL = gql`
+2 -1
View File
@@ -5,12 +5,13 @@ import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot"; import {FRAGMENT_COPILOT_PUBLIC_INFO} from "src/graphql/fragments/copilot";
export const FLIGHTS_LIST = gql` export const FLIGHTS_LIST = gql`
query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $limit: Int!, $offset: Int) { query FlightsList($eventId: Int, $aircraftId: Int, $copilotId: Int, $pointOfInterestId: Int, $search: String, $limit: Int!, $offset: Int) {
flights( flights(
eventId: $eventId, eventId: $eventId,
aircraftId: $aircraftId, aircraftId: $aircraftId,
copilotId: $copilotId, copilotId: $copilotId,
pointOfInterestId: $pointOfInterestId, pointOfInterestId: $pointOfInterestId,
search: $search,
limit: $limit, limit: $limit,
offset: $offset offset: $offset
) { ) {
@@ -60,10 +60,10 @@ const togglePublicVisibility = (variables) => toggleVisibility({
}); });
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore()); const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch}); const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
filters.value = {aircraftId: aircraftId}; filters.value = {aircraftId: aircraftId};
onMounted(() => { onMounted(() => {
loadPage(false); loadFirstPage();
}) })
</script> </script>
+13 -15
View File
@@ -1,31 +1,34 @@
<script setup> <script setup>
import AircraftsList from "components/aircrafts/AircraftsList.vue"; import AircraftsList from "components/aircrafts/AircraftsList.vue";
import FAB from "components/FAB.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue"; import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue"; import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useAircraftsStore} from "stores/Aircrafts"; import {useAircraftsStore} from "stores/Aircrafts";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import {usePagination} from "src/composables/pagination"; import {usePagination} from "src/composables/pagination";
import {onMounted} from "vue"; import ListFilters from "components/ListFilters.vue";
import PrimaryAction from "components/PrimaryAction.vue";
useAppStore().setPageTitle("Letadla"); useAppStore().setPageTitle("Letadla");
useAppStore().setBreadcrumbs([]) useAppStore().setBreadcrumbs([])
const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({fetchFunction: useAircraftsStore().fetch});
const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsStore()); const {aircrafts, totalItemsCount, isQueryRunning} = storeToRefs(useAircraftsStore());
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useAircraftsStore().fetch});
onMounted(() => {
loadPage(false);
})
</script> </script>
<template> <template>
<BasicListSkeleton :items="paginationLimit" v-if="isQueryRunning && !totalItemsCount"/> <ListFilters @filters-changed="handleFiltersChanged">
<template #append>
<PrimaryAction mode="add" :router-link="{name: 'addAircraft'}"/>
</template>
</ListFilters>
<BasicListSkeleton :items="5" v-if="isQueryRunning && !totalItemsCount"/>
<BasicInfoBanner <BasicInfoBanner
v-if="!isQueryRunning && totalItemsCount === 0" v-if="!isQueryRunning && totalItemsCount === 0"
:route-add="{name: 'addAircraft'}" :route-add="{name: 'addAircraft'}"
label-add="Vytvořit letadlo" label-add="Přidat letadlo"
> >
Aktuálně nemáš přidané žádné letadlo. Přidej se k existující organizaci nebo si sem ulož vlastní letadlo. Aktuálně nemáš přidané žádné letadlo. Přidej se k existující organizaci nebo si sem ulož vlastní letadlo.
<br> <br>
@@ -35,13 +38,8 @@ onMounted(() => {
<AircraftsList <AircraftsList
v-else v-else
:items="aircrafts" :items="aircrafts"
:total-items-count="totalItemsCount"
:is-query-running="isQueryRunning"
:items-per-page="paginationLimit"
:public="true" :public="true"
class="q-mb-xl" :has-more-data="hasMoreData"
@load-more="loadPage(true)" @load-more="loadPage($event.index, $event.done)"
/> />
<FAB mode="add" :router-link="{name: 'addAircraft'}"/>
</template> </template>
@@ -40,10 +40,10 @@ const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: useCopilotsStore(), object: copilot.value, variables $q, store: useCopilotsStore(), object: copilot.value, variables
}); });
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch}); const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
filters.value = {copilotId: copilotId}; filters.value = {copilotId: copilotId};
onMounted(() => { onMounted(() => {
loadPage(false); loadFirstPage();
}); });
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore()); const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
+2 -2
View File
@@ -26,7 +26,7 @@ onMounted(() => {
{label: event.value.name} {label: event.value.name}
]) ])
}); });
loadPage(false); loadFirstPage();
}) })
@@ -37,7 +37,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
const {isQueryRunning, event} = storeToRefs(useEventsStore()); const {isQueryRunning, event} = storeToRefs(useEventsStore());
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch}); const {filters, loadPage, loadFirstPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch});
filters.value = {eventId: eventId} filters.value = {eventId: eventId}
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore()); const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
+6 -214
View File
@@ -7,53 +7,19 @@ export default {
return useFlightsStore().fetchDetail(flightId); return useFlightsStore().fetchDetail(flightId);
} }
}; };
</script> </script>
<script setup> <script setup>
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue"; import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
import {onMounted, ref, toRefs, watch} from "vue";
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
import {useAircraftsStore} from "stores/Aircrafts";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {useAirportsStore} from "stores/Airports";
import {useQuasar} from "quasar"; import {useQuasar} from "quasar";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import FlightFormCopilot from "components/flights/form/FlightFormCopilot.vue"; import {successObject} from "src/composables/dialog";
import {useCopilotsStore} from "stores/Copilots";
import FlightFormNotes from "components/flights/form/FlightFormNotes.vue";
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import {errorObject, successObject, warningObject} from "src/composables/dialog";
import PhotoFormDialog from "components/photos/PhotoFormDialog.vue";
import FlightFormTrack from "components/flights/form/FlightFormTrack.vue";
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue";
import BatchPhotoFormDialog from "components/photos/BatchPhotoFormDialog.vue";
import {useEventsStore} from "stores/Events";
import FlightEditCopilotSkeleton from "components/flights/skeletons/FlightEditCopilotSkeleton.vue";
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
import {usePhotosStore} from "stores/Photos";
import {flightName} from "src/composables/flight"; import {flightName} from "src/composables/flight";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
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 {flight, isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
const formStep = ref("basic");
watch(() => $route.params.step, (val) => formStep.value = val);
const flightNameFormatted = flightName(flight.value); const flightNameFormatted = flightName(flight.value);
useAppStore().setPageTitle(`Editace - ${flightNameFormatted}`); useAppStore().setPageTitle(`Editace - ${flightNameFormatted}`);
useAppStore().setBreadcrumbs([ useAppStore().setBreadcrumbs([
@@ -62,192 +28,18 @@ useAppStore().setBreadcrumbs([
{label: "Editace"} {label: "Editace"}
]); ]);
const $q = useQuasar();
onMounted(() => { const $route = useRoute();
photos.value = flight.value.photos;
formStep.value = $route.params.step || "basic";
useAircraftsStore().fetch({});
usePointsOfInterestStore().fetch({});
useAirportsStore().fetch();
useCopilotsStore().fetch();
useEventsStore().fetch({});
useFlightPlansStore().fetch();
if (flight.value.flightPlanId) {
useFlightPlansStore().fetchDetail(flight.value.flightPlanId);
}
});
const handleFlightEdit = (data) => { const handleFlightEdit = (data) => {
const flightId = parseInt($route.params.id);
useFlightsStore().update(flightId, data).then((data) => { useFlightsStore().update(flightId, data).then((data) => {
$q.notify(successObject("Úspěšně upraveno")); $q.notify(successObject("Úspěšně upraveno"));
}) })
} }
const {
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
} = useBasicPhotoOperations({});
const setTitlePhoto = (photo) => {
useFlightsStore().update(flight.value.id, {titlePhotoId: photo.id}).then(f => {
$q.notify(successObject("Úvodní fotografie úspěšně nastavena"));
});
}
const handlePhotoUpdate = (flightId, formData) => {
const photoId = formData.id;
delete formData.id;
return updateSinglePhoto(photoId, formData);
};
const openEdit = ref(false);
const editPhoto = ref(null);
const openPhotoEdit = (photo) => {
openEdit.value = true;
editPhoto.value = photo;
};
// batch edit
const openBatchEdit = ref(false);
const editBatchPhotos = ref([]);
const openBatchPhotoForm = (photos) => {
openBatchEdit.value = true;
editBatchPhotos.value = photos;
}
const handleBatchPhotoEdit = async (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> </script>
<template> <template>
<FlightFormSteps :router-edit-mode="true" :flightId="flightId"/> <FlightFormSteps :router-edit-mode="true" :flightId="flight.id"/>
<q-tab-panels v-model="formStep"> <router-view :flight="flight" @save="handleFlightEdit"/>
<q-tab-panel name="basic">
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
<FlightFormBasic
v-else
@save="handleFlightEdit"
:flight="flight"
:locations="pointsOfInterest"
:airports="airports"
:aircrafts="aircrafts"
/>
</q-tab-panel>
<q-tab-panel name="copilot">
<FlightEditCopilotSkeleton v-if="isQueryRunning || !flight"/>
<FlightFormCopilot
v-else
@save="handleFlightEdit"
:flight="flight"
:copilots="copilots"/>
</q-tab-panel>
<q-tab-panel name="track">
<FlightEditSkeleton :step="formStep" v-if="isQueryRunning || !flight"/>
<FlightFormTrack
v-if="flight && pointsOfInterest"
:flight="flight"
:locations="pointsOfInterest"
:airports="airports"
@save="handleFlightEdit"
/>
</q-tab-panel>
<q-tab-panel name="photos">
<FlightFormPhotos
:flight="flight"
:is-request-pending="uploadInProgress"
@save="handlePhotosUpload"
/>
<template v-if="flight">
<PhotosEditorList
:title-photo="flight.titlePhoto"
:photos="photos"
:show-actions="true"
@edit="openPhotoEdit"
@batch-edit="openBatchPhotoForm"
@batch-rotate="batchRotatePhotos"
@delete="deletePhoto"
@set-title-photo="setTitlePhoto"
/>
<!-- Dialogs for photo edit -->
<PhotoFormDialog
v-model="openEdit"
:copilots="flight.copilots"
:aircrafts="aircrafts"
:locations="pointsOfInterest"
:photo="editPhoto"
@save="handlePhotoUpdate(flightId, $event)"
/>
<BatchPhotoFormDialog
v-model="openBatchEdit"
:locations="pointsOfInterest"
:photos="editBatchPhotos"
:copilots="flight.copilots"
:aircrafts="aircrafts"
@save="handleBatchPhotoEdit"
@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>
</template> </template>
@@ -101,7 +101,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
<template #content> <template #content>
<q-card-section> <q-card-section>
<ContentCardEditButton <ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'basic'}}" :to="{name: 'editFlightBasic', params: {id: flightId}}"
label="Upravit informace" label="Upravit informace"
/> />
<FlightBasicInfo <FlightBasicInfo
@@ -114,7 +114,7 @@ const togglePublicVisibility = (variables) => toggleVisibility({
/> />
<ContentCardEditButton <ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'notes'}}" :to="{name: 'editFlightNotes', params: {id: flightId}}"
label="Upravit text" label="Upravit text"
/> />
<article class="q-my-lg" v-html="flight.description"/> <article class="q-my-lg" v-html="flight.description"/>
@@ -129,12 +129,12 @@ const togglePublicVisibility = (variables) => toggleVisibility({
<q-separator/> <q-separator/>
<ContentCardEditButton <ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}" :to="{name: 'editFlightPhotos', params: {id: flightId}}"
label="Upravit fotografie" label="Upravit fotografie"
/> />
<BasicInfoBanner <BasicInfoBanner
v-if="flight.photos.length === 0" v-if="flight.photos.length === 0"
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}" :route-add="{name: 'editFlightPhotos', params: {id: flightId,}}"
> >
U tohoto letu nemáš nahrané žádné fotografie. U tohoto letu nemáš nahrané žádné fotografie.
</BasicInfoBanner> </BasicInfoBanner>
@@ -145,12 +145,12 @@ const togglePublicVisibility = (variables) => toggleVisibility({
<q-separator/> <q-separator/>
<ContentCardEditButton <ContentCardEditButton
:to="{name: 'flightEdit', params: {id: flightId, step: 'track'}}" :to="{name: 'editFlightTrack', params: {id: flightId}}"
label="Upravit trasu" label="Upravit trasu"
/> />
<BasicInfoBanner <BasicInfoBanner
v-if="flight.turnPoints.length === 0 && !flight.track" v-if="flight.turnPoints.length === 0 && !flight.track"
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'track'}}" :route-add="{name: 'editFlightTrack', params: {id: flightId}}"
> >
Nemáš nastavenou žádnou trasu. Nemáš nastavenou žádnou trasu.
</BasicInfoBanner> </BasicInfoBanner>
+13 -14
View File
@@ -1,29 +1,32 @@
<script setup> <script setup>
import FlightsList from "components/flights/FlightsList.vue"; import FlightsList from "components/flights/FlightsList.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue"; import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import FAB from "components/FAB.vue";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue"; import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import {useFlightsStore} from "stores/Flights"; import {useFlightsStore} from "stores/Flights";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {usePagination} from "src/composables/pagination"; import {usePagination} from "src/composables/pagination";
import {onMounted} from "vue"; import PrimaryAction from "components/PrimaryAction.vue";
import ListFilters from "components/ListFilters.vue";
useAppStore().setPageTitle("Moje lety"); useAppStore().setPageTitle("Moje lety");
useAppStore().setBreadcrumbs([]); useAppStore().setBreadcrumbs([]);
const {loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetch}); const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({fetchFunction: useFlightsStore().fetch});
onMounted(() => { const {flights, isQueryRunning} = storeToRefs(useFlightsStore());
loadPage(false);
});
const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore());
</script> </script>
<template> <template>
<ListFilters @filters-changed="handleFiltersChanged">
<template #append>
<PrimaryAction mode="add" :router-link="{name: 'addFlight'}"/>
</template>
</ListFilters>
<FlightsListSkeleton v-if="isQueryRunning && flights.length === 0"/> <FlightsListSkeleton v-if="isQueryRunning && flights.length === 0"/>
<BasicInfoBanner <BasicInfoBanner
v-else-if="!isQueryRunning && totalItemsCount === 0" v-else-if="!isQueryRunning && flights.length === 0"
:route-add="{name: 'addFlight'}" :route-add="{name: 'addFlight'}"
label-add="Přidat let" label-add="Přidat let"
> >
@@ -34,11 +37,7 @@ const {flights, totalItemsCount, isQueryRunning} = storeToRefs(useFlightsStore()
v-else v-else
:items="flights" :items="flights"
:public="false" :public="false"
:is-query-running="isQueryRunning" :has-more-data="hasMoreData"
:items-per-page="paginationLimit" @load-more="loadPage($event.index, $event.done)"
:total-items-count="totalItemsCount"
@load-more="loadPage(true)"
/> />
<FAB mode="add" :router-link="{name: 'addFlight'}"/>
</template> </template>
+12 -13
View File
@@ -9,7 +9,7 @@ import {useAirportsStore} from "stores/Airports";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import FlightFormSteps from "components/flights/form/FlightFormSteps.vue"; import FlightFormSteps from "components/flights/form/FlightFormSteps.vue";
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue"; import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
import FlightEditSkeleton from "components/flights/skeletons/FlightEditSkeleton.vue"; import FlightEditBasicInfoSkeleton from "components/flights/skeletons/FlightEditBasicInfoSkeleton.vue";
useAppStore().setPageTitle("Nový let"); useAppStore().setPageTitle("Nový let");
useAppStore().setBreadcrumbs([ useAppStore().setBreadcrumbs([
@@ -32,10 +32,10 @@ const loading = computed(() => [
useAircraftsStore().isQueryRunning, usePointsOfInterestStore().isQueryRunning, useAirportsStore().isQueryRunning useAircraftsStore().isQueryRunning, usePointsOfInterestStore().isQueryRunning, useAirportsStore().isQueryRunning
].some(i => i.value)) ].some(i => i.value))
const router = useRouter(); const $router = useRouter();
const handleSave = (formData) => { const handleSave = (formData) => {
useFlightsStore().create(formData).then((flight) => { useFlightsStore().create(formData).then((flight) => {
router.push({name: "flightEdit", params: {id: flight.id, step: "copilot"}}); $router.push({name: "editFlightPhotos", params: {id: flight.id}});
}); });
} }
</script> </script>
@@ -43,14 +43,13 @@ const handleSave = (formData) => {
<template> <template>
<FlightFormSteps :disabled="['copilot', 'track', 'photos', 'notes']"/> <FlightFormSteps :disabled="['copilot', 'track', 'photos', 'notes']"/>
<FlightEditSkeleton step="basic" v-if="loading"/> <FlightEditBasicInfoSkeleton v-if="loading"/>
<template v-else> <FlightFormBasic
<FlightFormBasic v-else
@save="handleSave" :is-request-pending="isMutationRunning"
:is-request-pending="isMutationRunning" :aircrafts="aircrafts"
:aircrafts="aircrafts" :airports="airports"
:airports="airports" :locations="pointsOfInterest"
:locations="pointsOfInterest" @save="handleSave"
/> />
</template>
</template> </template>
@@ -0,0 +1,40 @@
<script setup>
import FlightFormBasic from "components/flights/form/FlightFormBasic.vue";
import {onMounted} from "vue";
import {useAircraftsStore} from "stores/Aircrafts";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {useAirportsStore} from "stores/Airports";
import {storeToRefs} from "pinia";
import {useFlightsStore} from "stores/Flights";
import FlightEditBasicInfoSkeleton from "components/flights/skeletons/FlightEditBasicInfoSkeleton.vue";
const props = defineProps({
flight: {
type: Object,
required: true,
}
});
onMounted(() => {
useAircraftsStore().fetch({});
usePointsOfInterestStore().fetch({});
useAirportsStore().fetch();
});
const emits = defineEmits(["save"]);
const {aircrafts} = storeToRefs(useAircraftsStore());
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
const {airports} = storeToRefs(useAirportsStore());
const {isQueryRunning, isRequestPending: isMutationRunning} = storeToRefs(useFlightsStore());
</script>
<template>
<FlightEditBasicInfoSkeleton v-if="isQueryRunning || !flight"/>
<FlightFormBasic
v-else
:flight="flight"
:locations="pointsOfInterest"
:airports="airports"
:aircrafts="aircrafts"
@save="emits('save', $event)"
/>
</template>
@@ -0,0 +1,38 @@
<script setup>
import FlightFormNotes from "components/flights/form/FlightFormNotes.vue";
import {computed, onMounted} from "vue";
import {useEventsStore} from "stores/Events";
import {storeToRefs} from "pinia";
import {useCopilotsStore} from "stores/Copilots";
import FlightEditExtraInfoSkeleton from "components/flights/skeletons/FlightEditExtraInfoSkeleton.vue";
const props = defineProps({
flight: {
type: Object,
required: true,
}
});
const emits = defineEmits(["save"]);
onMounted(() => {
useEventsStore().fetch({});
useCopilotsStore().fetch();
});
const {events, isQueryRunning: loadingEvents} = storeToRefs(useEventsStore());
const {copilots, isQueryRunning: loadingCopilots} = storeToRefs(useCopilotsStore())
const isLoading = computed(() => loadingCopilots.value || loadingEvents.value);
</script>
<template>
<FlightEditExtraInfoSkeleton v-if="isLoading" />
<FlightFormNotes
v-else
:events="events"
:flight="flight"
:copilots="copilots"
@save="emits('save', $event)"
/>
</template>
@@ -0,0 +1,158 @@
<script setup>
import BatchPhotoFormDialog from "components/photos/BatchPhotoFormDialog.vue";
import PhotoFormDialog from "components/photos/PhotoFormDialog.vue";
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
import PhotosEditorList from "components/photos/PhotosEditorList.vue";
import {errorObject, successObject} from "src/composables/dialog";
import {computed, onMounted, ref, watch} from "vue";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {useFlightsStore} from "stores/Flights";
import {useBasicPhotoOperations} from "src/composables/photos/basicOperations";
import {storeToRefs} from "pinia";
import {useAircraftsStore} from "stores/Aircrafts";
import {usePhotosStore} from "stores/Photos";
import {useQuasar} from "quasar";
const props = defineProps({
flight: {
type: Object,
required: true,
}
});
const emits = defineEmits(["save"]);
onMounted(() => {
photos.value = props.flight.photos;
})
const {aircrafts, isQueryRunning: aircraftsLoading} = storeToRefs(useAircraftsStore());
const {pointsOfInterest, isQueryRunning: poisLoading} = storeToRefs(usePointsOfInterestStore());
const {photos, isQueryRunning: photosLoading} = storeToRefs(usePhotosStore());
const isLoading = computed(() => aircraftsLoading.value || poisLoading.value || photosLoading.value);
const $q = useQuasar();
const setTitlePhoto = (photo) => {
useFlightsStore().update(props.flight.id, {titlePhotoId: photo.id}).then(f => {
$q.notify(successObject("Úvodní fotografie úspěšně nastavena"));
});
}
const handlePhotoUpdate = (flightId, formData) => {
const photoId = formData.id;
delete formData.id;
return updateSinglePhoto(photoId, formData);
};
const openEdit = ref(false);
const editPhoto = ref(null);
const openPhotoEdit = (photo) => {
openEdit.value = true;
editPhoto.value = photo;
};
const {
uploadInProgress, handlePhotosUpload, batchRotatePhotos, deletePhoto, updateSinglePhoto
} = useBasicPhotoOperations({});
// batch edit
const openBatchEdit = ref(false);
const editBatchPhotos = ref([]);
const openBatchPhotoForm = (photos) => {
openBatchEdit.value = true;
editBatchPhotos.value = photos;
}
const handleBatchPhotoEdit = async (data) => {
const photoIds = data.photoIds;
delete data.photoIds;
delete data.id;
if (data.pointOfInterest && !data.pointOfInterest.id) {
const poiData = {
...data.pointOfInterest,
isPublic: false,
description: "",
urlSlug: "",
}
await usePointsOfInterestStore().create(poiData).then(poi => data.pointOfInterest = {id: poi.id, name: poi.name});
}
const promises = [];
for (const id of photoIds) {
const updateData = {
isAircraft: data.isAircraft,
copilots: data.copilots.map(c => ({id: c.id, name: c.name}))
};
if (data.name !== '') updateData.name = data.name;
if (data.description !== '') updateData.description = data.description;
if (data.pointOfInterest) updateData.pointOfInterest = data.pointOfInterest;
promises.push(updateSinglePhoto(id, data));
}
let success = 0;
Promise.allSettled(promises).then(result => {
for (const item of result) {
if (item.status === 'fulfilled') success++;
}
const error = result.length - success;
if (success > 0) $q.notify(successObject("Upraveno " + success + " fotografií"));
if (error > 0) $q.notify(errorObject(`Nepovedlo se uložit ${error} fotografií`))
});
}
watch(() => [openEdit.value, openBatchEdit.value], (newVal) => {
if (newVal) {
// load new data on each dialog open
useAircraftsStore().fetch({});
usePointsOfInterestStore().fetch({});
}
});
</script>
<template>
<FlightFormPhotos
:flight="flight"
:is-request-pending="uploadInProgress"
@save="handlePhotosUpload"
/>
<PhotosEditorList
:title-photo="flight.titlePhoto"
:photos="flight.photos"
:show-actions="true"
@edit="openPhotoEdit"
@batch-edit="openBatchPhotoForm"
@batch-rotate="batchRotatePhotos"
@delete="deletePhoto"
@set-title-photo="setTitlePhoto"
/>
<PhotoFormDialog
v-model="openEdit"
:copilots="flight.copilots"
:loading="isLoading"
:aircrafts="aircrafts"
:locations="pointsOfInterest"
:photo="editPhoto"
@save="handlePhotoUpdate(flight.id, $event)"
/>
<BatchPhotoFormDialog
v-model="openBatchEdit"
:loading="isLoading"
:locations="pointsOfInterest"
:photos="editBatchPhotos"
:copilots="flight.copilots"
:aircrafts="aircrafts"
@save="handleBatchPhotoEdit"
@delete="deletePhoto"
/>
</template>
@@ -0,0 +1,31 @@
<script setup>
import {onMounted} from "vue";
import {useFlightPlansStore} from "stores/FlightPlans";
import {storeToRefs} from "pinia";
const props = defineProps({
flight: {
type: Object,
required: true,
}
});
const emits = defineEmits(["save"]);
onMounted(() => {
if (props.flight.flightPlanId) {
useFlightPlansStore().fetchDetail(props.flight.flightPlanId);
}
});
const {flightPlan} = storeToRefs(useFlightPlansStore());
</script>
<template>
<template v-if="flight.flightPlanId">
{{ flightPlan.name }}
</template>
<template v-else>
Bez planu
</template>
</template>
@@ -0,0 +1,37 @@
<script setup>
import FlightFormTrack from "components/flights/form/FlightFormTrack.vue";
import {storeToRefs} from "pinia";
import {computed, onMounted} from "vue";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {useAirportsStore} from "stores/Airports";
import FlightEditTrackSkeleton from "components/flights/skeletons/FlightEditTrackSkeleton.vue";
const props = defineProps({
flight: {
type: Object,
required: true,
}
});
const emits = defineEmits(["save"]);
onMounted(() => {
usePointsOfInterestStore().fetch({});
useAirportsStore().fetch();
});
const {pointsOfInterest, isQueryRunning: poisLoading} = storeToRefs(usePointsOfInterestStore());
const {airports, isQueryRunning: airportsLoading} = storeToRefs(useAirportsStore());
const isLoading = computed(() => poisLoading.value || airportsLoading.value);
</script>
<template>
<FlightEditTrackSkeleton v-if="isLoading"/>
<FlightFormTrack
v-else
:flight="flight"
:locations="pointsOfInterest"
:airports="airports"
@save="emits('save', $event)"
/>
</template>
@@ -9,266 +9,34 @@ export default {
</script> </script>
<script setup> <script setup>
import {computed, onMounted, ref} from "vue"; import {computed} from "vue";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useAirspacesStore} from "stores/Airspaces";
import {useAirportsStore} from "stores/Airports";
import {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 {flightPlan} = storeToRefs(useFlightPlansStore());
const flightPlanName = computed(() => flightPlan.value.name || "Nepojmenovaný plán"); const flightPlanName = computed(() => flightPlan.value.name || "Nepojmenovaný plán");
useAppStore().setPageTitle(`${flightPlanName.value} - plánovnání`);
useAppStore().setBreadcrumbs([
{to: {name: "flightPlans"}, label: "Moje plány"},
{label: flightPlanName.value},
]);
onMounted(() => { const displayedTabs = computed(() => {
useAirspacesStore().fetch(); const tabs = [
useAircraftsStore().fetch({}); {label: "Trasa", route: "editFlightPlan", icon: "route"},
usePointsOfInterestStore().fetch({limit: 10000}); {label: "Informace", route: "editFlightPlanInfo", icon: "info"},
useAirportsStore().fetch(); {label: "Počasí", route: "editFlightPlanWeather", icon: "mdi-weather-windy"}, // TODO: jen pokud se povedlo nejake pocasi stahnout
{label: "Navigační štítek", route: "editFlightPlanNavlog", icon: "navigation"},
];
if (flightPlan.value.aircraft) {
tabs.push({label: "Let", route: "flightPlanFlight", icon: "flight"})
}
return tabs;
}); });
const {pointsOfInterest} = storeToRefs(usePointsOfInterestStore());
const {airspaces: airspacesData, areAirspacesLoading: isQueryRunning} = storeToRefs(useAirspacesStore())
const {airports} = storeToRefs(useAirportsStore());
const {aircrafts: aircraftsData} = storeToRefs(useAircraftsStore());
const aircrafts = computed(() => aircraftsData.value.map(a => ({id: a.id, name: a.callSign})));
const {getMarkersFrom} = useFlightPlanUtils();
const {markerObject} = useFlightPlanObjects();
const 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> </script>
<template> <template>
<q-tabs v-model="openedTab"> <q-tabs>
<q-tab name="route"> <q-route-tab v-for="tab in displayedTabs" :key="tab.route" replace exact :to="{name: tab.route, params: {id: flightPlan.id}}">
<q-icon name="route"/> <q-icon :name="tab.icon"/>
Trasa {{ tab.label }}
</q-tab> </q-route-tab>
<q-tab name="info">
<q-icon name="info"/>
Informace
</q-tab>
<q-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-tabs>
<q-tab-panels v-model="openedTab"> <router-view :flight-plan-name="flightPlanName" :flight-plan="flightPlan"/>
<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>
</template> </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> <script setup>
import {useFlightPlansStore} from "stores/FlightPlans"; import {useFlightPlansStore} from "stores/FlightPlans";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {onMounted} from "vue";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue"; import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue"; import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
import FAB from "components/FAB.vue";
import FlightPlansList from "components/flightPlans/FlightPlansList.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().setPageTitle("Nový plán");
useAppStore().setBreadcrumbs([ useAppStore().setBreadcrumbs([
@@ -14,14 +15,20 @@ useAppStore().setBreadcrumbs([
{label: "Plánované lety"}, {label: "Plánované lety"},
]); ]);
onMounted(() => { const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({
useFlightPlansStore().fetch(); fetchFunction: useFlightPlansStore().fetch,
itemsPerPage: 20
}); });
const {flightPlans, isQueryRunning} = storeToRefs(useFlightPlansStore()); const {flightPlans, isQueryRunning} = storeToRefs(useFlightPlansStore());
</script> </script>
<template> <template>
<ListFilters @filters-changed="handleFiltersChanged">
<template #append>
<PrimaryAction mode="add" :router-link="{name: 'newFlightPlan'}"/>
</template>
</ListFilters>
<BasicListSkeleton :items=5 v-if="isQueryRunning || !flightPlans"/> <BasicListSkeleton :items=5 v-if="isQueryRunning || !flightPlans"/>
<BasicInfoBanner <BasicInfoBanner
@@ -31,6 +38,10 @@ const {flightPlans, isQueryRunning} = storeToRefs(useFlightPlansStore());
Aktuálně nemáš žádné naplánované lety. Aktuálně nemáš žádné naplánované lety.
</BasicInfoBanner> </BasicInfoBanner>
<FlightPlansList :items="flightPlans" v-else/> <FlightPlansList
<FAB :router-link="{name: 'newFlightPlan'}" mode="add"/> v-else
:items="flightPlans"
:has-more-data="hasMoreData"
@load-more="loadPage($event.index, $event.done)"
/>
</template> </template>
@@ -6,6 +6,7 @@ import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {successObject} from "src/composables/dialog"; import {successObject} from "src/composables/dialog";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {usePointsOfInterestTypeStore} from "stores/PointOfInterestType";
const $q = useQuasar(); const $q = useQuasar();
const router = useRouter(); const router = useRouter();
@@ -22,10 +23,16 @@ useAppStore().setBreadcrumbs([
{label: "Nové"}, {label: "Nové"},
]) ])
const {isMutationRunning} = storeToRefs(usePointsOfInterestStore()); usePointsOfInterestTypeStore().fetch();
const {isMutationRunning} = storeToRefs(usePointsOfInterestStore());
const {types} = storeToRefs(usePointsOfInterestTypeStore());
</script> </script>
<template> <template>
<POIForm @save="handleSave" :is-request-pending="isMutationRunning"/> <POIForm
:types="types"
:is-request-pending="isMutationRunning"
@save="handleSave"
/>
</template> </template>
@@ -38,9 +38,9 @@ const togglePublicVisibility = (variables) => toggleVisibility({
$q, store: usePointsOfInterestStore(), object: pointOfInterest.value, variables $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}; filters.value = {pointOfInterestId: poiId};
loadPage(false); loadFirstPage();
const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore()); const {isQueryRunning: areFlightsLoading, totalItemsCount, flights} = storeToRefs(useFlightsStore());
const {isQueryRunning, pointOfInterest} = storeToRefs(usePointsOfInterestStore()); const {isQueryRunning, pointOfInterest} = storeToRefs(usePointsOfInterestStore());
</script> </script>
+12 -17
View File
@@ -9,27 +9,25 @@ import {storeToRefs} from "pinia";
import {usePagination} from "src/composables/pagination"; import {usePagination} from "src/composables/pagination";
import ListFilters from "components/ListFilters.vue"; import ListFilters from "components/ListFilters.vue";
import {onMounted} from "vue"; import {onMounted} from "vue";
import PrimaryAction from "components/PrimaryAction.vue";
useAppStore().setPageTitle("Zajímavá místa"); useAppStore().setPageTitle("Zajímavá místa");
useAppStore().setBreadcrumbs([]); useAppStore().setBreadcrumbs([]);
const {filters, loadPage, paginationLimit, paginationOffset} = usePagination({ const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({
fetchFunction: usePointsOfInterestStore().fetch 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> </script>
<template> <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"/> <BasicListSkeleton :items="10" v-if="isQueryRunning && !pointsOfInterest.length"/>
<BasicInfoBanner <BasicInfoBanner
@@ -45,10 +43,7 @@ const {isQueryRunning, totalItemsCount, pointsOfInterest} = storeToRefs(usePoint
v-else v-else
:public="false" :public="false"
:items="pointsOfInterest" :items="pointsOfInterest"
:total-items-count="totalItemsCount" :has-more-data="hasMoreData"
:is-query-running="isQueryRunning" @load-more="loadPage($event.index, $event.done)"
:items-per-page="paginationLimit"
@load-more="loadPage(true)"
/> />
<FAB :router-link="{name: 'addPOI'}" mode="add"/>
</template> </template>
+1 -4
View File
@@ -10,9 +10,6 @@ useAppStore().setBreadcrumbs([
...homepageBreadcrumbs(), ...homepageBreadcrumbs(),
{label: "O projektu"} {label: "O projektu"}
]); ]);
useMeta(() => metaData({
title: "O projektu",
}));
</script> </script>
<template> <template>
@@ -23,7 +20,7 @@ useMeta(() => metaData({
<p> <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 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 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> </p>
<h3>Externí data</h3> <h3>Externí data</h3>
+31 -7
View File
@@ -2,27 +2,51 @@
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import ContentCard from "components/ContentCard.vue"; import ContentCard from "components/ContentCard.vue";
import {homepageBreadcrumbs} from "src/composables/breadcrumbs"; 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().setPageTitle("Co Polétání.cz umí?");
useAppStore().setBreadcrumbs([ useAppStore().setBreadcrumbs([
...homepageBreadcrumbs(), ...homepageBreadcrumbs(),
{label: "Funkce"} {label: "Funkce"}
]); ]);
</script> </script>
<template> <template>
<ContentCard title="Co Polétání.cz umí?"> <ContentCard title="Co Polétání.cz umí?">
<template #content> <template #content>
<q-card-section> <q-card-section>
Sdílet zážitky s kamarády <h3>Ukládat informace o letech</h3>
Upravovat fotky <p>
Zobrazovat je v kontextu polohy 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.
Planovat let </p>
Debriefing letu: https://www.easa.europa.eu/community/topics/post-flight-debrief
<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> </q-card-section>
</template> </template>
</ContentCard> </ContentCard>
</template> </template>
+1 -1
View File
@@ -2,7 +2,7 @@
import {useMeta} from "quasar"; import {useMeta} from "quasar";
import {metaData} from "src/composables/meta"; import {metaData} from "src/composables/meta";
import AppFooter from "components/AppFooter.vue"; 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 {onMounted} from "vue";
import {useFlightsStore} from "stores/Flights"; import {useFlightsStore} from "stores/Flights";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
+118 -33
View File
@@ -11,48 +11,133 @@ useAppStore().setBreadcrumbs([
</script> </script>
<template> <template>
<ContentCard title="Informace o správě osobních údajů"> <ContentCard title="Zásady ochrany osobních údajů">
<template #content> <template #content>
<q-card-section> <q-card-section>
<section> <p>
<h4>1. ochrana osobních údajů:</h4> 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>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> </p>
</section>
<section> <h4>1. Správce osobních údajů</h4>
<h4>2. shromažďované údaje:</h4> <p>
<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> Správcem osobních údajů je Michal Kvacek, který je dostupný na emailové adrese <a href="mailto:michal@kvacek.cz">michal@kvacek.cz</a>.
</section> </p>
<section> <h4>2. Shromažďované osobní údaje</h4>
<h4>3. účely zpracování:</h4> <p>
<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> Aplikace shromažďuje následující osobní údaje:
</section> </p>
<ul>
<li>Jméno</li>
<li>Emailová adresa</li>
</ul>
<section> <h4>3. Účel zpracování osobních údajů</h4>
<h4>4. práva uživatelů podle GDPR:</h4> <p>
<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> Shromážděné osobní údaje jsou využívány pro následující účely:
</section> </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>4. Právní základ pro zpracování</h4>
<h4>5. bezpečnost osobních údajů:</h4> <p>
<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> Zpracování osobních údajů je prováděno na základě následujících právních základů:
</section> </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>5. Doba uchovávání osobních údajů</h4>
<h4>6. sdílení osobních údajů:</h4> <p>
<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> 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ů.
</section> </p>
<section> <h4>6. Sdílení osobních údajů</h4>
<h4>7. cookies:</h4> <p>
<p>Pro lepší funkčnost a personalizaci obsahu může aplikace využívat soubory cookies.</p> Osobní údaje nejsou sdíleny s žádnými třetími stranami.
</section> </p>
<section> <h4>7. Práva uživatelů</h4>
<h4>8. změny v podmínkách:</h4> <p>
<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> Uživatelé mají následující práva týkající se jejich osobních údajů:
</section> </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> </q-card-section>
</template> </template>
</ContentCard> </ContentCard>
@@ -28,27 +28,24 @@ import {homepageBreadcrumbs} from "src/composables/breadcrumbs";
const {aircraft, isQueryRunning} = storeToRefs(useAircraftsStore()); const {aircraft, isQueryRunning} = storeToRefs(useAircraftsStore());
const {photos, isQueryRunning: arePhotosLoading} = storeToRefs(usePhotosStore()); const {photos, isQueryRunning: arePhotosLoading} = storeToRefs(usePhotosStore());
const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore()); const {flights, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
useAppStore().setPageTitle(aircraft.value.callSign); useAppStore().setPageTitle(aircraft.value.callSign);
useMeta(() => metaData({ useMeta(() => metaData({
title: aircraft.value.callSign, title: aircraft.value.callSign,
image: aircraft.value.titleImage?.url, image: aircraft.value.titleImage?.url,
keywords: [ keywords: aircraftKeywords(aircraft.value),
"letadlo", ...aircraftKeywords(aircraft.value)
],
description: aircraft.value.description || `Detail letadla ${aircraft.value.callSign}.` description: aircraft.value.description || `Detail letadla ${aircraft.value.callSign}.`
})); }));
useAppStore().setBreadcrumbs([ useAppStore().setBreadcrumbs([
...homepageBreadcrumbs(), ...homepageBreadcrumbs(),
{label: "Letadla"}, {label: "Letadla"},
{label: aircraft.value.callSign} {label: aircraft.value.callSign}
]); ]);
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic}); const {handleFiltersChanged, hasMoreData, loadPage} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {aircraftId: aircraft.value.id};
onMounted(() => { onMounted(() => {
loadPage(false); handleFiltersChanged({aircraftId: aircraft.value.id})
usePhotosStore().fetchPublic({aircraftId: aircraft.value.id}); usePhotosStore().fetchPublic({aircraftId: aircraft.value.id});
}); });
</script> </script>
@@ -74,10 +71,9 @@ onMounted(() => {
<FlightsList <FlightsList
v-else v-else
:items="flights" :items="flights"
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading" :is-query-running="areFlightsLoading"
:items-per-page="paginationLimit" :has-more-data="hasMoreData"
@load-more="loadPage(true)" @load-more="loadPage($event.index, $event.done)"
:public="true" :public="true"
/> />
</q-card-section> </q-card-section>
@@ -39,46 +39,41 @@ useMeta(() => metaData({
description: event.value.description description: event.value.description
})) }))
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic}); const {loadPage, hasMoreData, handleFiltersChanged} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {username: props.pilot.publicUsername, eventId: event.value.id};
onMounted(() => { 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> </script>
<template> <template>
<div> <EventDetailSkeleton v-if="isQueryRunning || !event"/>
<EventDetailSkeleton v-if="isQueryRunning || !event"/>
<template v-else> <template v-else>
<EventBasicInfo :event="event"/> <EventBasicInfo :event="event"/>
<article class="q-my-lg" v-html="event.description"/> <article class="q-my-lg" v-html="event.description"/>
<q-no-ssr> <q-no-ssr>
<EventFlightsMap :event-url-slug="event.urlSlug" :only-public="true"/> <!-- <EventFlightsMap :event-url-slug="event.urlSlug" :only-public="true"/>-->
</q-no-ssr> </q-no-ssr>
<h3>Lety</h3> <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. Zatím tu nemáš uložené žádné lety.
</q-banner> </q-banner>
<FlightsListSkeleton :items="paginationLimit" v-if="areFlightsLoading && flights?.length === 0"/> <FlightsListSkeleton v-if="areFlightsLoading && flights?.length === 0"/>
<FlightsList <FlightsList
v-else v-else
:items="flights" :items="flights"
:total-items-count="totalItemsCount" :public="true"
:is-query-running="areFlightsLoading" :has-more-data="hasMoreData"
:items-per-page="paginationLimit" @load-more="loadPage($event.index, $event.done)"
:public="true" />
@load-more="loadPage(true)" </template>
/>
</template>
</div>
</template> </template>
@@ -1,41 +1,46 @@
<script> <script>
import {useFlightsStore} from "stores/Flights"; import {useFlightsStore} from "stores/Flights";
import {usePilotsStore} from "stores/Pilots";
export default { export default {
preFetch({store, currentRoute}) { preFetch({store, currentRoute}) {
return useFlightsStore(store).fetchPublicDetail({ const promises = [
urlSlug: currentRoute.params.flightSlug, usePilotsStore(store).fetchPublicPilotDetail(currentRoute.params.username).catch(e => {
username: currentRoute.params.username }),
}) useFlightsStore(store).fetchPublicDetail({
urlSlug: currentRoute.params.flightSlug,
username: currentRoute.params.username
})
]
return Promise.all(promises)
} }
} }
</script> </script>
<script setup> <script setup>
import {storeToRefs} from "pinia";
import FlightTrackMap from "components/flights/FlightTrackMap.vue"; import FlightTrackMap from "components/flights/FlightTrackMap.vue";
import FlightBasicInfo from "components/flights/FlightBasicInfo.vue"; import FlightBasicInfo from "components/flights/FlightBasicInfo.vue";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
import FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue"; import FlightDetailSkeleton from "components/flights/skeletons/FlightDetailSkeleton.vue";
import PhotoGallery from "components/photos/PhotoGallery.vue"; import PhotoGallery from "components/photos/PhotoGallery.vue";
import {storeToRefs} from "pinia";
import {flightName} from "src/composables/flight"; import {flightName} from "src/composables/flight";
import {useMeta} from "quasar"; import {useMeta} from "quasar";
import {aircraftKeywords, flightEventKeywords, flightKeywords, metaData} from "src/composables/meta"; import {aircraftKeywords, flightEventKeywords, flightKeywords, metaData} from "src/composables/meta";
import ContentCard from "components/ContentCard.vue"; import ContentCard from "components/ContentCard.vue";
import {usePhotosStore} from "stores/Photos"; import {usePhotosStore} from "stores/Photos";
import {computed, onMounted} from "vue"; import {computed, onMounted, ref, useTemplateRef} from "vue";
import {publicFlightBreadcrumbs} from "src/composables/breadcrumbs";
import {useAirspacesStore} from "stores/Airspaces"; import {useAirspacesStore} from "stores/Airspaces";
import {useAirportsStore} from "stores/Airports"; import {useAirportsStore} from "stores/Airports";
import {useFlightPlansStore} from "stores/FlightPlans"; 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 {isQueryRunning, flight} = storeToRefs(useFlightsStore());
const props = defineProps({ const {pilot, isQueryRunning: pilotLoading} = storeToRefs(usePilotsStore());
pilot: {
type: Object,
required: true
}
});
onMounted(() => { onMounted(() => {
usePhotosStore().fetchPublic({flightId: flight.value.id}); usePhotosStore().fetchPublic({flightId: flight.value.id});
@@ -43,7 +48,7 @@ onMounted(() => {
useAirportsStore().fetch(); useAirportsStore().fetch();
if (flight.value.flightPlanId) { 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()); const {isQueryRunning: arePhotosLoading, photos: flightPhotos} = storeToRefs(usePhotosStore());
@@ -55,13 +60,15 @@ const flightNameFormatted = flightName(flight.value);
const event = computed(() => flight.value.event); const event = computed(() => flight.value.event);
useAppStore().setPageTitle(flightNameFormatted); 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({ useMeta(() => metaData({
title: `${flightNameFormatted} - ${props.pilot.name}`, title: `${flightNameFormatted} - ${pilot.value.name}`,
keywords: [ keywords: [
...flightKeywords(flight.value), ...flightKeywords(flight.value),
props.pilot.name, pilot.value.name,
...aircraftKeywords(flight.value.aircraft), ...aircraftKeywords(flight.value.aircraft),
...flightEventKeywords(event.value) ...flightEventKeywords(event.value)
], ],
@@ -69,25 +76,96 @@ useMeta(() => metaData({
image: flight.value.socialImageUrl 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> </script>
<template> <template>
<div> <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>
<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>
<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'}"
: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}}"
/>
</q-tab-panel>
<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"/> <FlightDetailSkeleton v-if="isQueryRunning || !flight"/>
<ContentCard :title-photo="flight.titlePhoto" :title="flightName(flight)" v-else> <ContentCard :title-photo="flight.titlePhoto" :title="flightName(flight)" v-else>
<template #content> <template #content>
<q-card-section> <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-no-ssr>
<q-inner-loading v-if="arePhotosLoading"/> <q-inner-loading v-if="arePhotosLoading"/>
<PhotoGallery :photos="flightPhotos" v-else-if="!arePhotosLoading && flightPhotos.length > 0"/> <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 {useAirportsStore} from "stores/Airports";
import {formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils"; import {formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
import {useFlightPlanCalculations, useFlightPlanUtils} from "src/composables/flightPlan"; 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 {useMeta} from "quasar";
import {metaData} from "src/composables/meta"; import {metaData} from "src/composables/meta";
@@ -28,19 +28,18 @@ useMeta(() => metaData({
useAppStore().setPageTitle(props.pilot.publicUsername); useAppStore().setPageTitle(props.pilot.publicUsername);
useAppStore().setBreadcrumbs(publicProfileBreadcrumbs(props.pilot, false)) useAppStore().setBreadcrumbs(publicProfileBreadcrumbs(props.pilot, false))
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic}); const {loadPage, handleFiltersChanged} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {username: props.pilot.publicUsername};
onMounted(() => { onMounted(() => {
loadPage(false); handleFiltersChanged({username: props.pilot.publicUsername})
}); });
const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore()); const {flights, isQueryRunning: areFlightsLoading} = storeToRefs(useFlightsStore());
</script> </script>
<template> <template>
<PublicFlightEventSwitcher :username="pilot?.publicUsername"/> <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"> <q-banner class="bg-primary text-white" v-else-if="!areFlightsLoading && flights.length === 0">
Tento pilot zatím nepublikoval žádný let. Tento pilot zatím nepublikoval žádný let.
@@ -49,9 +48,6 @@ const {flights, totalItemsCount, isQueryRunning: areFlightsLoading} = storeToRef
<FlightsList <FlightsList
v-else v-else
:items="flights" :items="flights"
:total-items-count="totalItemsCount"
:is-query-running="areFlightsLoading"
:items-per-page="paginationLimit"
:public="true" :public="true"
@load-more="loadPage(true)" @load-more="loadPage(true)"
/> />
@@ -41,11 +41,11 @@ useAppStore().setBreadcrumbs([
{label: pointOfInterest.value.name} {label: pointOfInterest.value.name}
]); ]);
const {filters, loadPage, paginationLimit} = usePagination({fetchFunction: useFlightsStore().fetchPublic}); const {loadPage, handleFiltersChanged, hasMoreData} = usePagination({fetchFunction: useFlightsStore().fetchPublic});
filters.value = {pointOfInterestId: pointOfInterest.value.id};
onMounted(() => { onMounted(() => {
usePhotosStore().fetchPublic({pointOfInterestId: pointOfInterest.value.id}); usePhotosStore().fetchPublic({pointOfInterestId: pointOfInterest.value.id});
loadPage(false); handleFiltersChanged({pointOfInterestId: pointOfInterest.value.id});
}); });
</script> </script>
@@ -79,7 +79,8 @@ onMounted(() => {
:is-query-running="areFlightsLoading" :is-query-running="areFlightsLoading"
:items-per-page="paginationLimit" :items-per-page="paginationLimit"
:public="true" :public="true"
@load-more="loadPage(true)" :has-more-data="hasMoreData"
@load-more="loadPage($event.index, $event.done)"
/> />
</q-card-section> </q-card-section>
</template> </template>
+4 -16
View File
@@ -17,21 +17,11 @@ useMeta(() => metaData({
title: "Místa" title: "Místa"
})) }))
const {filters, loadPage, paginationOffset, paginationLimit} = usePagination({ const {loadPage, paginationLimit, hasMoreData, handleFiltersChanged} = usePagination({
fetchFunction: usePointsOfInterestStore().fetchPublic fetchFunction: usePointsOfInterestStore().fetchPublic
}); });
onMounted(() => { const {pointsOfInterest, isQueryRunning} = storeToRefs(usePointsOfInterestStore());
loadPage(false);
});
const handleFiltersChanged = (newFilters) => {
paginationOffset.value = 0;
filters.value = newFilters;
loadPage(false);
}
const {pointsOfInterest, totalItemsCount, isQueryRunning} = storeToRefs(usePointsOfInterestStore());
</script> </script>
<template> <template>
@@ -43,10 +33,8 @@ const {pointsOfInterest, totalItemsCount, isQueryRunning} = storeToRefs(usePoint
v-else v-else
:public="true" :public="true"
:items="pointsOfInterest" :items="pointsOfInterest"
:total-items-count="totalItemsCount" :has-more-data="hasMoreData"
:is-query-running="isQueryRunning" @load-more="loadPage($event.index, $event.done)"
:items-per-page="paginationLimit"
@load-more="loadPage(true)"
/> />
</div> </div>
</template> </template>
+38 -36
View File
@@ -1,15 +1,12 @@
<script setup> <script setup>
import {reactive, ref} from "vue"; import {reactive, ref} from "vue";
import {useRouter} from "vue-router";
import {useMeta, useQuasar} from "quasar"; import {useMeta, useQuasar} from "quasar";
import {errorObject, successObject} from "src/composables/dialog"; import {errorObject, successObject} from "src/composables/dialog";
import {useAppStore} from "stores/App"; 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 {api} from "boot/axios";
import {useAuthStore} from "stores/Auth";
import {metaData} from "src/composables/meta"; import {metaData} from "src/composables/meta";
const router = useRouter();
const $q = useQuasar(); const $q = useQuasar();
useAppStore().setPageTitle("Zapomenuté heslo"); useAppStore().setPageTitle("Zapomenuté heslo");
@@ -18,19 +15,21 @@ useMeta(() => metaData({
index: false index: false
})); }));
const requestPasswordChange = () => { const requestPasswordChange = async () => {
if (!form.value.validate()) return; if (!form.value.validate()) return;
return api.post('/forgotten-password/request', formData).then(response => { try {
$q.notify(successObject("Žádost o změnu hesla odeslána, zkontroluj si svůj e-mail.")) await api.post('/forgotten-password/request', formData);
}).catch(({response}) => { $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) { if (response.status === 404) {
$q.notify(errorObject(`E-mail ${formData.email} není registrovaný.`)); $q.notify(errorObject(`E-mail ${formData.email} není registrovaný.`));
} else { } else {
console.error(response) console.error(response)
$q.notify(errorObject("Při odesílání se stala chyba. Zkus to za chvíli znovu.")); $q.notify(errorObject("Při odesílání se stala chyba. Zkus to za chvíli znovu."));
} }
}) }
} }
const form = ref(null); const form = ref(null);
@@ -49,39 +48,42 @@ const rules = {
<q-page-container> <q-page-container>
<q-page class="row justify-center"> <q-page class="row justify-center">
<q-card flat square style="width:95%; max-width: 450px; height: 500px">
<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-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-input v-model="formData.email" type="email" label="E-mail" :rules="rules.email">
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="email"/> <q-icon name="email"/>
</template> </template>
</q-input> </q-input>
</q-form> </q-card-section>
</q-card-section> <q-card-actions>
<q-card-actions class="q-px-lg"> <q-btn
<q-btn @click="requestPasswordChange"
@click="requestPasswordChange" unelevated
unelevated size="lg"
size="lg" color="primary"
color="primary" class="full-width"
class="full-width" label="Resetovat heslo"
label="Resetovat heslo" />
/> </q-card-actions>
</q-card-actions> <q-card-section class="text-center q-pa-sm">
<q-card-section class="text-center q-pa-sm"> <router-link :to="{name: 'login'}" class="text-grey-6">Zpět na login</router-link>
<router-link :to="{name: 'login'}" class="text-grey-6">Zpět na login</router-link> </q-card-section>
</q-card-section> </q-card>
</q-card> </q-form>
</q-page> </q-page>
</q-page-container> </q-page-container>
</q-layout> </q-layout>
+19 -19
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {reactive, ref} from "vue"; import {reactive} from "vue";
import {useRouter} from "vue-router"; import {useRouter} from "vue-router";
import {isRequired, isValidEmail, minLength} from "src/composables/form/rules"; import {isRequired, isValidEmail, minLength} from "src/composables/form/rules";
import {useMeta, useQuasar} from "quasar"; import {useMeta, useQuasar} from "quasar";
@@ -40,33 +40,33 @@ const formData = reactive({
<q-page-container> <q-page-container>
<q-page class="window-height window-width row justify-center"> <q-page class="window-height window-width row justify-center">
<q-card square flat style="width:95%; max-width: 450px;" class="q-mt-lg"> <q-form class="q-px-sm q-pt-xl" style="width:95%; max-width: 450px;" @submit.prevent="logUser()">
<q-card-section class="bg-primary">
<h4 class="text-h5 q-my-md text-white">Login</h4> <q-card square flat class="q-mt-lg">
</q-card-section> <q-card-section class="bg-primary">
<q-card-section> <h4 class="text-h5 q-my-md text-white">Login</h4>
<q-form class="q-px-sm q-pt-xl" @submit.prevent="logUser()"> </q-card-section>
<q-input square clearable v-model="formData.email" :rules="rules.email" type="email" label="E-mail"> <q-card-section>
<q-input v-model="formData.email" :rules="rules.email" type="email" label="E-mail">
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="email"/> <q-icon name="email"/>
</template> </template>
</q-input> </q-input>
<q-input square clearable v-model="formData.password" :rules="rules.password" type="password" label="Password"> <q-input v-model="formData.password" :rules="rules.password" type="password" label="Heslo">
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="lock"/> <q-icon name="lock"/>
</template> </template>
</q-input> </q-input>
</q-form> </q-card-section>
</q-card-section>
<q-card-actions class="q-px-lg">
<q-btn unelevated size="lg" color="primary" class="full-width" label="Přihlásit se" @click.prevent="logUser()"/>
</q-card-actions>
<q-card-section class="text-center q-pa-sm">
<router-link :to="{name: 'forgottenPassword'}" class="text-grey-6">Zapomenuté heslo</router-link>
</q-card-section>
</q-card>
<q-card-actions>
<q-btn type="submit" unelevated size="lg" color="primary" class="full-width" label="Přihlásit se" @click.prevent="logUser()"/>
</q-card-actions>
<q-card-section class="text-center q-pa-sm">
<router-link :to="{name: 'forgottenPassword'}" class="text-grey-6">Zapomenuté heslo</router-link>
</q-card-section>
</q-card>
</q-form>
</q-page> </q-page>
</q-page-container> </q-page-container>
</q-layout> </q-layout>

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