Author SHA1 Message Date
Michal Kváček 9b38abd0de Cookies v SSR 2023-12-10 22:10:26 +01:00
Michal Kváček 71389caecc Reorganizace skeletonu, aktualizace app-vite a quasaru 2023-12-10 10:19:03 +01:00
Michal Kváček 68659ca924 Bugfixing, dotazeni uprav s uvodni fotkou 2023-12-05 12:15:12 +01:00
Michal Kváček 3bd4d7ba52 Lepsi verejna cast, ssr, lepsi prace s fotkamaa 2023-12-04 22:46:23 +01:00
Michal Kváček 11cc6ad107 Vylepseni SSR, pridani meta informaci, zatim dost WIP 2023-11-28 11:35:53 +01:00
Michal Kváček 86605ad53e Merge branch 'master' of kvacek.cz:poletani.cz/app into ssr 2023-11-21 16:27:49 +01:00
Michal Kváček 9a14fdfcfb Fix chybejiciho importu props 2023-11-20 16:48:58 +01:00
Michal Kváček 00d4de08e4 Fix chybejiciho importu props 2023-11-20 16:48:35 +01:00
Michal Kváček 9181118e69 Fix loginu a spatneho importu 2023-11-20 16:43:19 +01:00
Michal Kváček 4aeef2e8a6 Merge branch 'master' of kvacek.cz:poletani.cz/app into ssr 2023-11-20 16:28:02 +01:00
Michal Kváček af74801693 Bugfixing 2023-11-20 10:24:08 +01:00
Michal Kváček f9d70a5d57 Ne uplne otestovany prepis storu 2023-11-19 00:44:01 +01:00
Michal Kváček 331ebbc4e4 Zaklad ssr 2023-11-14 16:20:01 +01:00
Michal Kváček e65491e00b Bugfix 2023-11-09 06:17:20 +01:00
Michal Kváček 8beb7bb9d6 Odstraneni vue-inner-image-zoom, oprava na dotazovani na verejny seznam letu u kopilota 2023-11-07 17:47:23 +01:00
Michal Kváček 284f8d2c53 Nahled pred uploadem 2023-11-06 21:35:43 +01:00
Michal Kváček 6b1e220d42 Pridani stranky pro verejny prehled letu s danym kopilotem 2023-11-02 09:03:12 +01:00
Michal Kváček 40f21d159e Pridani skeletonu 2023-11-01 06:19:26 +01:00
Michal Kváček 3e7ee9e686 Uprava editoru 2023-10-31 17:29:51 +01:00
Michal Kváček 1ed35552d5 Drobnosti 2023-10-30 09:13:54 +01:00
Michal Kváček e2ad6e39b8 Implementace photoswipe 2023-10-30 09:08:58 +01:00
Michal Kváček a63efc44d8 Prepracovane zobrazeni seznamu fotke 2023-10-27 23:01:11 +02:00
Michal Kváček 100e1fa33b Pridan fotoeditor 2023-10-27 22:30:47 +02:00
Michal Kváček 4c8952117b Refaktoring verejneho profilu, pridani zobrazeni udalosti 2023-10-14 22:43:16 +02:00
Michal Kváček 5b47c56c6f Bugfixing a refaktoring 2023-10-13 23:25:06 +02:00
Michal Kváček bf8df8bb34 Fix vykreslovani mapy 2023-10-09 14:57:15 +02:00
Michal Kváček e76d34fab4 Refaktoring a pridani letiste mezipristani do mapy 2023-10-07 22:11:56 +02:00
Michal Kváček 901c652641 Pridani letiste pro mezipristani 2023-10-07 21:37:16 +02:00
Michal Kváček 36b0178b97 Pridani od/do k eventum, fix datetime pickeru pro vyber pouze data, pridano URL k otevrene fotce 2023-09-25 06:25:53 +02:00
Michal Kváček b64c426179 Otaceni fotek 2023-09-21 09:39:22 +02:00
Michal Kváček d1baec342f Drobny bugfixing 2023-09-20 14:43:53 +02:00
Michal Kváček 8c3c5ee48d Sprava eventu a organizaci 2023-09-20 13:26:13 +02:00
Michal Kváček 6fbaef4a1e Sprava eventu 2023-09-19 06:33:17 +02:00
Michal Kváček 0c91ff7838 Fix vypisu spolecnych letu s kopilotem, uprava zobrazeni photo carouselu 2023-09-18 20:03:53 +02:00
Michal Kváček b6a6fc3558 Description projektu, drobna uprava skeletonu pro editaci letu 2023-09-18 09:00:03 +02:00
Michal Kváček e203a7d229 Refaktoring 2023-09-13 10:03:41 +02:00
185 changed files with 6475 additions and 8467 deletions
+22 -12
View File
@@ -1,24 +1,34 @@
# develop stage
FROM node:20-alpine as develop-stage
ENV TZ=Europe/Prague
WORKDIR /app
COPY package*.json ./
RUN yarn global add @quasar/cli && yarn --production=false
RUN yarn global add @quasar/cli
COPY package.json yarn.lock ./
RUN yarn --production=false
RUN npx update-browserslist-db@latest
COPY . .
# build stage
FROM develop-stage as build-stage
ENV NODE_ENV production
#ARG API_URL
#ENV VUE_API_URL $API_URL
RUN quasar build -m ssr
RUN yarn --production=false
RUN quasar build
FROM node:20-alpine as production-stage
# production stage
FROM nginx:1.25-alpine as production-stage
COPY conf/nginx.conf /etc/nginx/nginx.conf
COPY --from=build-stage /app/dist/spa /app/html
EXPOSE 80
ENV NODE_ENV production
ENV TZ=Europe/Prague
CMD ["nginx", "-g", "daemon off;"]
EXPOSE 3000
WORKDIR /app
COPY process.yml /app
COPY --from=build-stage /app/dist/ssr/package.json /app
RUN yarn global add pm2 && yarn && yarn cache clean
COPY --from=build-stage /app/dist/ssr /app
CMD ["pm2-runtime", "/app/process.yml"]
+7 -2
View File
@@ -1,7 +1,12 @@
version: "3.3"
version: "3.4"
services:
poletani-app:
image: "docker.kvacek.cz/poletani/app:latest"
# build:
# context: .
# dockerfile: Dockerfile
# target: "production-stage"
restart: always
ports:
- 9000:80
# - 9000:80
- 9000:3000
+3 -1
View File
@@ -24,16 +24,18 @@ services:
## command: ls -l /app
app:
network_mode: host
build:
context: .
target: "develop-stage"
ports:
- "9000:9000"
- "9001:9100"
volumes:
- ./src:/app/src
- ./public:/app/public
- ./node_modules:/app/node_modules:delegated
command: quasar dev
command: quasar dev -m ssr
environment:
API_URL: "http://localhost:8000"
-3
View File
@@ -1,10 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
-5400
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -1,7 +1,7 @@
{
"name": "app",
"version": "0.0.1",
"description": "A Quasar Project",
"description": "Fotogalerie pro tvoje zážitky ze vzduchu",
"productName": "Polétání.cz",
"author": "Michal Kváček <michal@kvacek.cz>",
"private": true,
@@ -19,23 +19,24 @@
"chart.js": "^4.4.0",
"graphql": "16.6.0",
"graphql-tag": "2.12.6",
"photoswipe": "^5.4.2",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"pinia": "^2.0.33",
"quasar": "^2.12.0",
"quasar": "^2.14.1",
"vue": "^3.2.47",
"vue-chartjs": "^5.2.0",
"vue-inner-image-zoom": "^2.0.0",
"vue-picture-cropper": "^0.7.0",
"vue-router": "^4.1.6",
"vue3-google-map": "^0.15.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.3.0",
"@quasar/app-vite": "^1.7.1",
"autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-vue": "^9.0.0",
"postcss": "^8.4.14",
"prettier": "^2.5.1",
"vite-plugin-rewrite-all": "^1.0.1"
"prettier": "^2.5.1"
},
"engines": {
"npm": ">= 6.13.4",
+5
View File
@@ -0,0 +1,5 @@
apps:
- name: poletani
script: /app/index.js
exec_mode: cluster
instances: 3
+11 -11
View File
@@ -23,15 +23,16 @@ module.exports = configure(function (ctx) {
},
// https://v2.quasar.dev/quasar-cli/prefetch-feature
// preFetch: true,
preFetch: true,
// app boot file (/poletani/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files
boot: [
"cookies",
"axios",
"vueInnerImageZoom",
"chartJs",
"apollo",
"chartJs"
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
@@ -56,7 +57,7 @@ module.exports = configure(function (ctx) {
build: {
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node18'
node: 'node20'
},
env: {
@@ -76,17 +77,14 @@ module.exports = configure(function (ctx) {
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
minify: true,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
vitePlugins: [
[ require("vite-plugin-rewrite-all").default ] // slouzi k podpore tecky v params v routeru
]
// vitePlugins: []
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
@@ -98,6 +96,7 @@ module.exports = configure(function (ctx) {
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {},
cssAddon: true,
iconSet: 'mdi-v6', // Quasar icon set
// lang: 'cs-CZ', // Quasar language pack
@@ -111,6 +110,7 @@ module.exports = configure(function (ctx) {
// Quasar plugins
plugins: [
'Cookies',
'Dialog',
'Meta',
'Notify'
@@ -135,10 +135,10 @@ module.exports = configure(function (ctx) {
// https://v2.quasar.dev/quasar-cli/developing-ssr/configuring-ssr
ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
// extendSSRWebserverConf (esbuildConf) {},
extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {},
pwa: false,
+55
View File
@@ -0,0 +1,55 @@
import { ssrMiddleware } from 'quasar/wrappers'
// This middleware should execute as last one
// since it captures everything and tries to
// render the page with Vue
export default ssrMiddleware(({ app, resolve, render, serve }) => {
// we capture any other Express route and hand it
// over to Vue and Vue Router to render our page
app.get(resolve.urlPath('*'), (req, res) => {
res.setHeader('Content-Type', 'text/html')
render(/* the ssrContext: */ { req, res })
.then(html => {
// now let's send the rendered html to the client
res.send(html)
})
.catch(err => {
// oops, we had an error while rendering the page
// we were told to redirect to another URL
if (err.url) {
if (err.code) {
res.redirect(err.code, err.url)
} else {
res.redirect(err.url)
}
} else if (err.code === 404) {
// hmm, Vue Router could not find the requested route
// Should reach here only if no "catch-all" route
// is defined in /src/routes
res.status(404).send('404 | Page Not Found')
} else if (process.env.DEV) {
// well, we treat any other code as error;
// if we're in dev mode, then we can use Quasar CLI
// to display a nice error page that contains the stack
// and other useful information
// serve.error is available on dev only
serve.error({ err, req, res })
} else {
// we're in production, so we should have another method
// to display something to the client when we encounter an error
// (for security reasons, it's not ok to display the same wealth
// of information as we do in development)
// Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it
res.status(500).send('500 | Internal Server Error')
// console.error(err.stack)
}
})
})
})
+136
View File
@@ -0,0 +1,136 @@
/**
* More info about this file:
* https://v2.quasar.dev/quasar-cli-vite/developing-ssr/ssr-webserver
*
* Runs in Node context.
*/
/**
* Make sure to yarn add / npm install (in your project root)
* anything you import here (except for express and compression).
*/
import express from 'express'
import compression from 'compression'
import {
ssrClose,
ssrCreate,
ssrListen,
ssrRenderPreloadTag,
ssrServeStaticContent
} from 'quasar/wrappers'
/**
* Create your webserver and return its instance.
* If needed, prepare your webserver to receive
* connect-like middlewares.
*
* Should NOT be async!
*/
export const create = ssrCreate((/* { ... } */) => {
const app = express()
// attackers can use this header to detect apps running Express
// and then launch specifically-targeted attacks
app.disable('x-powered-by')
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD) {
app.use(compression())
}
return app
})
/**
* You need to make the server listen to the indicated port
* and return the listening instance or whatever you need to
* close the server with.
*
* The "listenResult" param for the "close()" definition below
* is what you return here.
*
* For production, you can instead export your
* handler for serverless use or whatever else fits your needs.
*/
export const listen = ssrListen(async ({ app, port, isReady }) => {
await isReady()
return app.listen(port, () => {
if (process.env.PROD) {
console.log('Server listening at port ' + port)
}
})
})
/**
* Should close the server and free up any resources.
* Will be used on development only when the server needs
* to be rebooted.
*
* Should you need the result of the "listen()" call above,
* you can use the "listenResult" param.
*
* Can be async.
*/
export const close = ssrClose(({ listenResult }) => {
return listenResult.close()
})
const maxAge = process.env.DEV
? 0
: 1000 * 60 * 60 * 24 * 30
/**
* Should return middleware that serves the indicated path
* with static content.
*/
export const serveStaticContent = ssrServeStaticContent((path, opts) => {
return express.static(path, {
maxAge,
...opts
})
})
const jsRE = /\.js$/
const cssRE = /\.css$/
const woffRE = /\.woff$/
const woff2RE = /\.woff2$/
const gifRE = /\.gif$/
const jpgRE = /\.jpe?g$/
const pngRE = /\.png$/
/**
* Should return a String with HTML output
* (if any) for preloading indicated file
*/
export const renderPreloadTag = ssrRenderPreloadTag((file) => {
if (jsRE.test(file) === true) {
return `<link rel="modulepreload" href="${file}" crossorigin>`
}
if (cssRE.test(file) === true) {
return `<link rel="stylesheet" href="${file}">`
}
if (woffRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
}
if (woff2RE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
}
if (gifRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/gif">`
}
if (jpgRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg">`
}
if (pngRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/png">`
}
return ''
})
+10
View File
@@ -0,0 +1,10 @@
/* 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;
}
}
-1
View File
@@ -7,7 +7,6 @@ import {useAppStore} from "stores/App";
const route = useRoute();
const layoutComponent = computed(() => {
if (!route.meta) return null;
return route.meta.layout;
+100
View File
@@ -0,0 +1,100 @@
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core'
import {onError} from "@apollo/client/link/error";
import {useUserStore} from "stores/User";
import {useAppStore} from "stores/App";
import {Notify} from "quasar";
import {errorObject, warningObject} from "src/composables/dialog";
import {createUploadLink} from "apollo-upload-client";
// import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
const handleRefreshToken = (store, router, forward, operation) => {
if (!operation.query?._refreshUnauthorizedUser) return;
console.log("refreshing token, ", operation.query);
return fromPromise(useUserStore(store).refreshAccessToken()
.catch((error) => {
Notify.create(warningObject("Tvoje přihlášení vypřešlo. Přihlaš se prosím znovu."));
router.push({name: "login"})
}))
.filter((value) => Boolean(value))
.flatMap((accessToken) => {
const oldHeaders = operation.getContext().headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${accessToken}`,
},
});
// retry the request, returning the new observable
return forward(operation);
});
}
export const apolloWrapper = {};
export default (({store, router}) => {
const uploadLink = createUploadLink({
uri: process.env.API_URL + '/graphql',
credentials: 'include'
})
const authLink = new ApolloLink((operation, forward) => {
const accessToken = useUserStore(store).getAccessToken();
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
});
}
return forward(operation);
});
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
console.log(networkError, graphQLErrors);
if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation);
}
if (graphQLErrors) {
for (const error of graphQLErrors) {
if (error.message === "Not found") {
useAppStore(store).appStatus = 404;
} else if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation);
} else {
Notify.create(errorObject("API error: " + error.message));
}
}
}
});
const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, uploadLink]), cache: new InMemoryCache(), connectToDevTools: true,
});
apolloWrapper.query = (query, variables = {}) => {
return apolloClient.query({
query: query,
fetchPolicy: 'no-cache',
variables: variables
})
.then((response) => response)
.catch(err => {
if (!err.toString().includes("401") && !err.toString().includes("Not authorized"))
console.error("Error during query: ", err)
return err;
})
};
apolloWrapper.mutate = (mutation, variables) => {
return apolloClient
.mutate({
mutation: mutation,
fetchPolicy: 'no-cache',
variables: variables,
})
.then((response) => response)
.catch(err => {
console.error("Error during mutation: ", err);
})
};
});
+11 -21
View File
@@ -2,6 +2,7 @@ import {boot} from 'quasar/wrappers'
import axios from 'axios'
import {useUserStore} from "stores/User";
const api = axios.create({
baseURL: process.env.API_URL,
withCredentials: true,
@@ -12,8 +13,9 @@ const api = axios.create({
},
})
api.interceptors.request.use((config) => {
const token = useUserStore().getAccessToken();
export default boot(({store}) => {
api.interceptors.request.use((config) => {
const token = useUserStore(store).getAccessToken();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
@@ -23,15 +25,15 @@ api.interceptors.request.use((config) => {
}
return config;
})
})
// Response interceptor for API calls
api.interceptors.response.use((response) => {
return response
}, async function (error) {
// Response interceptor for API calls
api.interceptors.response.use(
(response) => response,
async function (error) {
const originalRequest = error.config;
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
const newToken = await useUserStore().refreshAccessToken().catch(err => {
const newToken = await useUserStore(store).refreshAccessToken().catch(err => {
console.error(err);
return Promise.reject("Logged out");
})
@@ -39,19 +41,7 @@ api.interceptors.response.use((response) => {
return api(originalRequest);
}
return Promise.reject(error);
});
export default boot(({app}) => {
// for use inside Vue files (Options API) through this.$axios and this.$api
// app.config.globalProperties.$axios = axios
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
// so you won't necessarily have to import axios in each vue file
app.config.globalProperties.$api = api
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
// so you can easily perform requests against your app's API
});
})
export {axios, api}
+2 -2
View File
@@ -2,5 +2,5 @@ import {boot} from "quasar/wrappers";
import {BarElement, CategoryScale, Chart as ChartJS, Filler, Legend, LinearScale, LineElement, PointElement, Title, Tooltip} from "chart.js";
export default boot(({app}) => {
ChartJS.register(Title, Tooltip, Legend, PointElement, LineElement, Filler, BarElement, CategoryScale, LinearScale)
})
ChartJS.register(Title, Tooltip, Legend, PointElement, LineElement, Filler, BarElement, CategoryScale, LinearScale);
});
+9
View File
@@ -0,0 +1,9 @@
import {Cookies} from 'quasar'
import {boot} from "quasar/wrappers";
let cookies = Cookies;
export default boot(({ssrContext}) => {
cookies = process.env.SERVER ? Cookies.parseSSR(ssrContext) : Cookies
});
export {cookies}
-6
View File
@@ -1,6 +0,0 @@
import {boot} from "quasar/wrappers";
import InnerImageZoom from 'vue-inner-image-zoom';
export default boot(({app}) => {
app.component("inner-image-zoom", InnerImageZoom);
})
+34
View File
@@ -0,0 +1,34 @@
<script setup>
import TitlePhotoWithGallery from "components/photos/TitlePhotoWithGallery.vue";
const props = defineProps({
titlePhoto: {
type: Object,
required: false
},
title: {
type: String,
required: false
}
})
</script>
<template>
<q-card class="flight-detail-card q-mb-lg">
<TitlePhotoWithGallery
v-if="titlePhoto"
:photo="titlePhoto"
:title="title"
/>
<q-card-section v-else-if="title && title !== ''">
<h3>{{ title }}</h3>
</q-card-section>
<q-card-actions>
<slot name="actions"/>
</q-card-actions>
<slot name="content"/>
</q-card>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
const props = defineProps({
to: {
type: Object,
required: true
},
label: {
type: String,
required: false,
default: null
}
});
</script>
<template>
<div class="text-right">
<q-btn flat class="q-my-sm" :to="props.to" icon="edit" color="primary">
{{ props.label }}
</q-btn>
</div>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup>
import {computed} from "vue";
import {useQuasar} from "quasar";
const props = defineProps({
isRequestPending: {
type: Boolean,
required: false,
default: false,
},
saveButton: {
type: [Object, Boolean],
required: false,
default: () => ({
label: "Uložit",
icon: "save",
align: "left"
})
}
})
const $q = useQuasar();
const fullwidthBtn = computed(() => $q.screen.width <= $q.screen.sizes.md);
</script>
<template>
<div class="text-right">
<q-btn
color="primary"
type="submit"
size="lg"
:class="{'q-my-lg': true, 'btn--fullwidth': fullwidthBtn}"
:icon="props.saveButton.icon"
:loading="props.isRequestPending"
>
{{ props.saveButton.label }}
<template v-slot:loading>
<q-spinner/>
</template>
</q-btn>
</div>
</template>
<style>
.btn--fullwidth {
width: 100%;
}
</style>
+7 -32
View File
@@ -1,13 +1,15 @@
<script setup>
import {isRequestPending} from "src/composables/form/props";
import {computed, useSlots} from "vue";
import {useQuasar} from "quasar";
import FormSubmitButton from "components/FormSubmitButton.vue";
const props = defineProps({
modelValue: {
type: Object,
},
isRequestPending,
isRequestPending: {
type: Boolean,
required: false,
default: false,
},
saveButton: {
type: [Object, Boolean],
required: false,
@@ -23,38 +25,11 @@ const emits = defineEmits(["save"]);
const saveForm = () => {
emits("save", props.modelValue);
}
const $q = useQuasar();
const fullwidthBtn = computed(() => $q.screen.width <= $q.screen.sizes.md);
</script>
<template>
<q-form @submit.prevent="saveForm" ref="form">
<slot name="default"/>
<div class="text-right">
<q-btn
color="primary"
type="submit"
size="lg"
:class="{'q-my-lg': true, 'btn--fullwidth': fullwidthBtn}"
v-if="saveButton"
:icon="props.saveButton.icon"
:loading="props.isRequestPending"
>
{{ props.saveButton.label }}
<template v-slot:loading>
<q-spinner />
</template>
</q-btn>
</div>
<FormSubmitButton v-if="saveButton" :save-button="saveButton"/>
</q-form>
</template>
<style>
.btn--fullwidth {
width: 100%;
}
</style>
@@ -1,59 +0,0 @@
<script setup>
import {aircraft} from "src/composables/objects/aircraft";
import {confirmObject} from "src/composables/dialog";
import {useQuasar} from "quasar";
const props = defineProps({
aircraft: {
type: Object,
required: true
}
});
const emits = defineEmits(["delete"]);
const $q = useQuasar();
const confirmDelete = (aircraftId) => {
$q.dialog(confirmObject('Opravdu chceš smazat toto letadlo?'))
.onOk(() => {
emits("delete", aircraftId);
});
};
</script>
<template>
<q-card class="q-mx-md q-my-md flight-detail-card">
<q-img :src="aircraft.photoUrl" v-if="aircraft.photoUrl"/>
<q-card-actions class="q-my-md">
<div class="col-grow">
<q-btn
flat
:to='{name: "aircraftEdit", params: {id: aircraft.id}}'
icon="edit"
text-color="primary"
label="Upravit"
/>
</div>
<q-btn @click="confirmDelete(aircraft.id)" icon="delete" flat text-color="red" label="Smazat"/>
</q-card-actions>
<q-card-section>
<q-list>
<q-item v-if="aircraft.manufacturer">
Výrobce: {{ aircraft.manufacturer }}
</q-item>
<q-item v-if="aircraft.model">
Model: {{ aircraft.model }}
</q-item>
<q-item v-if="aircraft.seats">
Počet míst: {{ aircraft.seats }}
</q-item>
</q-list>
</q-card-section>
<q-card-section>
<article v-html="aircraft.description" class="q-my-md"/>
</q-card-section>
</q-card>
</template>
+41 -20
View File
@@ -2,9 +2,9 @@
import {onMounted, reactive} from "vue";
import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling";
import {defaultValues, isRequestPending} from "src/composables/form/props";
import {isNumber, isRequired, maxLength, minLength} from "src/composables/form/rules";
import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
const formData = reactive({
id: null,
@@ -13,13 +13,25 @@ const formData = reactive({
seats: null,
manufacturer: "",
description: "",
photo: null,
organizationId: null
organization: null,
isPublic: false
});
const props = defineProps({
defaultValues,
isRequestPending
defaultValues: {
type: Object,
required: false,
default: () => ({})
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
},
organizations: {
type: Array,
required: true,
}
});
const rules = {
@@ -28,8 +40,12 @@ const rules = {
};
onMounted(() => {
if (Object.keys(props.defaultValues).length > 0)
if (Object.keys(props.defaultValues).length > 0) {
setDefaultData(formData, props.defaultValues);
if (props.defaultValues.organization) {
formData.organization = {id: props.defaultValues.organization.id, name: props.defaultValues.organization.name};
}
}
})
</script>
@@ -45,6 +61,23 @@ onMounted(() => {
v-bind="commonProps"
/>
<q-input
type="number"
v-model.number="formData.seats"
label="Počet míst (včetně pilota)"
min="1"
:rules="rules.seats"
v-bind="commonProps"
/>
<ComboboxInput
:options="organizations"
label="Organizace"
v-model="formData.organization"
v-bind="commonProps"
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
/>
<q-input
type="text"
v-model="formData.manufacturer"
@@ -58,15 +91,6 @@ onMounted(() => {
v-bind="commonProps"
/>
<q-input
type="number"
v-model.number="formData.seats"
label="Počet míst (včetně pilota)"
min="1"
:rules="rules.seats"
v-bind="commonProps"
/>
<q-input
type="textarea"
v-model="formData.description"
@@ -74,10 +98,7 @@ onMounted(() => {
v-bind="commonProps"
/>
<q-file v-model="formData.photo" label="Fotografie" v-bind="commonProps">
<template #prepend>
<q-icon name="upload"/>
</template>
</q-file>
<q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/>
</FormWrapper>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import {useQuasar} from "quasar";
const props = defineProps({
aircraft: {
type: Object,
required: true
}
});
</script>
<template>
<q-list>
<q-item v-if="aircraft.organization">
Provozovatel: {{ aircraft.organization.name }}
</q-item>
<q-item v-if="aircraft.manufacturer">
Výrobce: {{ aircraft.manufacturer }}
</q-item>
<q-item v-if="aircraft.model">
Model: {{ aircraft.model }}
</q-item>
<q-item v-if="aircraft.seats">
Počet míst: {{ aircraft.seats }}
</q-item>
</q-list>
<article v-html="aircraft.description" class="q-my-md"/>
</template>
+6 -2
View File
@@ -21,8 +21,8 @@ useAppStore().setPageTitle("Letadla");
v-for="aircraft of props.items" :key="aircraft"
>
<q-item-section avatar>
<q-avatar v-if="aircraft.photoUrl">
<img :src="aircraft.photoUrl" alt=""/>
<q-avatar v-if="aircraft.titlePhoto">
<img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar color="teal" text-color="white" icon="flight" v-else/>
</q-item-section>
@@ -36,7 +36,11 @@ useAppStore().setPageTitle("Letadla");
({{ aircraft.manufacturer }})
</template>
</q-item-label>
<q-item-label caption v-if="aircraft.organization">
Provozovatel: {{ aircraft.organization.name }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
@@ -13,11 +13,11 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</q-card-section>
<q-card-section class="q-my-md">
<BasicTextSkeleton :paragraphs="1" :lines="randomInt(1, 3)"/>
<BasicTextSkeleton :paragraphs="1" :lines="2"/>
</q-card-section>
</q-card>
<q-card class="flight-detail-card" flat>
<BasicListSkeleton :items-count-min="1" :items-count-max="4"/>
<h3>Lety</h3>
<BasicListSkeleton :items="5" />
</q-card>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup>
const props = defineProps({
avatars: {
type: Array,
required: true
}
});
</script>
<template>
<div style="height: 60px">
<q-avatar
v-for="(avatar, idx) in avatars"
:key="idx"
class="overlapping"
:style="`left: ${(idx+1) * 20}px`"
>
<img :src="avatar"/>
</q-avatar>
</div>
</template>
<style lang="sass" scoped>
.overlapping
position: absolute
</style>
@@ -1,6 +1,4 @@
<script setup>
import {poi} from "src/composables/objects/poi";
const props = defineProps({
routeBack: {
type: Object,
@@ -1,5 +1,6 @@
<script setup>
import {randomInt} from "src/composables/random";
import {computed} from "vue";
const props = defineProps({
itemsCountMin: {
@@ -11,14 +12,19 @@ const props = defineProps({
type: Number,
required:false,
default: 5
},
items: {
type: Number,
required: false,
}
})
});
const renderedItems = computed(() => props.items || randomInt(props.itemsCountMin, props.itemsCountMax));
</script>
<template>
<q-list bordered separator>
<q-item v-ripple v-for="i in randomInt(itemsCountMin, itemsCountMax)" :key="i">
<q-item v-ripple v-for="i in renderedItems" :key="i">
<q-item-section avatar>
<q-skeleton type="QAvatar" />
</q-item-section>
+5 -2
View File
@@ -4,14 +4,17 @@ import {onMounted, reactive} from "vue";
import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs";
import {isRequestPending} from "src/composables/form/props";
import {isRequired} from "src/composables/form/rules";
const props = defineProps({
copilot: {
type: Object
},
isRequestPending
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
+9 -4
View File
@@ -11,14 +11,19 @@ const props = defineProps({
<template>
<q-list bordered separator>
<q-item clickable v-ripple v-for="pilot of items" :key="pilot">
<q-item
clickable
:to="{name: 'copilotDetail', params: {id: pilot.id }}"
v-ripple
v-for="pilot of items" :key="pilot">
<q-item-section avatar>
<q-avatar icon="person" color="primary" text-color="white"/>
<q-avatar v-if="pilot.titlePhoto">
<img :src="pilot.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar v-else icon="person" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
<router-link :to="{name: 'copilotDetail', params: {id: pilot.id }}">
{{ pilot.name }}
</router-link>
</q-item-section>
</q-item>
</q-list>
@@ -0,0 +1,8 @@
<script setup>
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
</script>
<template>
<q-skeleton type="rect" class="q-my-lg" style="width: 200px; height: 40px" />
<BasicListSkeleton :items="4"/>
</template>
@@ -1,5 +1,5 @@
<script setup></script>
<template>
<em>Copilot skeleton</em>
<q-skeleton type="QInput" />
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import {onMounted, reactive} from "vue";
import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules";
import RichTextEditor from "components/inputs/RichTextEditor.vue";
import DatetimePicker from "components/inputs/DatetimePicker.vue";
import {FORMAT_DATE, FORMAT_DATETIME_LONG} from "src/composables/datetimeUtils";
const props = defineProps({
event: {
type: Object
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
name: null,
isPublic: false,
description: "",
dateFrom: null,
dateTo: null,
});
const rules = {
name: [isRequired]
}
onMounted(() => {
if (props.event) {
setDefaultData(formData, props.event);
}
})
</script>
<template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<q-input
v-bind="commonProps"
label="Jméno"
:rules="rules.name"
v-model="formData.name"
/>
<div class="row">
<div class="col q-pr-sm">
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateFrom" label="Od"/>
</div>
<div class="col q-pl-sm">
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateTo" label="Do"/>
</div>
</div>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</FormWrapper>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup>
import AvatarsGroup from "components/avatars/AvatarsGroup.vue";
const props = defineProps({
items: {
type: Array,
required: true
},
routeDetail: {
type: Object,
required: false,
default: () => ({name: 'eventDetail', params: {}})
},
});
const getCoverPhotos = (item) => item.flights.filter(f => !!f.titlePhoto).map(f => f.titlePhoto.thumbnailUrl);
</script>
<template>
<q-list bordered separator>
<q-item
v-for="event of items" :key="event"
clickable
:to="{name: routeDetail.name, params: {...routeDetail.params, id: event.id}}"
v-ripple
>
<q-item-section>
<q-item-label>
<q-avatar icon="event" color="primary" text-color="white" v-if="!event.flights || event.flights.length === 0"/>
<AvatarsGroup :avatars="getCoverPhotos(event)" v-else/>
{{ event.name }}
</q-item-label>
<q-item-label lines="4" caption>
<article v-html="event.description"/>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
@@ -0,0 +1,17 @@
<script setup>
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue";
</script>
<template>
<q-list bordered separator class="q-my-lg">
<ListItemSkeleton/>
<ListItemSkeleton/>
</q-list>
<BasicTextSkeleton :lines="3" :paragraphs="2"/>
<BasicListSkeleton :items="4"/>
</template>
@@ -0,0 +1,18 @@
<script setup>
</script>
<template>
<q-skeleton type="QInput" class="q-my-lg"/>
<div class="row">
<div class="col q-pr-sm">
<q-skeleton type="QInput"/>
</div>
<div class="col q-pl-sm">
<q-skeleton type="QInput"/>
</div>
</div>
<q-skeleton type="rect" class="q-my-lg" height="400px"/>
</template>
+42 -7
View File
@@ -1,5 +1,4 @@
<script setup>
import {flight} from "src/composables/objects/flight";
import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
import WeatherInfo from "components/weather/WeatherInfo.vue";
@@ -8,11 +7,22 @@ const props = defineProps({
type: Object,
required: true
},
showCopilots: {
type: Boolean,
copilotDetailRoute: {
type: Object,
required: false,
default: false
default: null
},
aircraftDetailRoute: {
type: Object,
required: false,
default: null
},
poiDetailRoute: {
type: Object,
required: false,
default: null
}
})
</script>
@@ -23,7 +33,15 @@ const props = defineProps({
<q-icon name="flight"/>
</q-item-section>
<q-item-section>
<router-link
:to="{...aircraftDetailRoute, params: {...aircraftDetailRoute.params, id: flight.aircraft.id}}"
v-if="aircraftDetailRoute"
>
{{ flight.aircraft.callSign }}
</router-link>
<template v-else>
{{ flight.aircraft.callSign }}
</template>
</q-item-section>
</q-item>
@@ -44,7 +62,7 @@ const props = defineProps({
</q-item-section>
<q-item-section>
<div>
<strong>{{flight.takeoffAirport.icaoCode}}</strong> ({{ flight.takeoffAirport.name }}),
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}),
čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/>
@@ -54,10 +72,23 @@ const props = defineProps({
<q-item v-for="poi in flight.track" :key="poi.id">
<q-item-section avatar>
<q-icon name="place"/>
<q-icon name="place" v-if="poi.pointOfInterest"/>
<q-icon name="local_airport" v-if="poi.airport"/>
</q-item-section>
<q-item-section>
<template v-if="poi.pointOfInterest">
<router-link
:to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, id: poi.pointOfInterest.id}}"
v-if="poi.pointOfInterest.isPublic"
>
{{ poi.pointOfInterest.name }}
</router-link>
<template v-else>{{ poi.pointOfInterest.name }}</template>
</template>
<span v-if="poi.airport">
<strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}),
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }}
</span>
</q-item-section>
</q-item>
@@ -91,9 +122,13 @@ const props = defineProps({
<q-icon name="people"/>
</q-item-section>
<q-item-section>
<router-link :to="{name: 'copilotDetail', params: {id: copilot.id}}">
<router-link
v-if="copilotDetailRoute"
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, id: copilot.id}}"
>
{{ copilot.name }}
</router-link>
<template v-else> {{ copilot.name }}</template>
</q-item-section>
</q-item>
</q-list>
@@ -1,20 +0,0 @@
<script setup>
import {randomInt} from "src/composables/random";
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
</script>
<template>
<q-card class="flight-detail-card">
<q-skeleton type="rect" height="400px"/>
<q-card-section>
<q-skeleton type="QInput" v-for="i in 3" :key="i" />
</q-card-section>
</q-card>
<q-card class="flight-detail-card" flat>
<BasicListSkeleton :items-count-min="1" :items-count-max="4"/>
</q-card></template>
+17 -39
View File
@@ -1,59 +1,37 @@
<script setup>
import {flight} from "src/composables/objects/flight";
import MapWithMarkers from "components/map/MapWithMarkers.vue";
import {computed, ref} from "vue";
import {computed, reactive, ref} from "vue";
import {Line} from "vue-chartjs"
const props = defineProps({
flight: {
type: Object,
required: true
},
photos: {
type: Array,
required: false,
}
})
const mapPhotos = computed(() => flight.value.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
const poisWithGPS = computed(() => {
return flight.value.track.filter(poi => poi.pointOfInterest.gpsLatitude && poi.pointOfInterest.gpsLongitude)
});
const mapPhotos = computed(() => props.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
const poisWithGPS = computed(() => props.flight.track.map(poi => poi.pointOfInterest || poi.airport));
const pointsOfInterests = computed(() => {
const path = [{
label: flight.value.takeoffAirport.name,
position:
{
lat: flight.value.takeoffAirport.gpsLatitude,
lng: flight.value.takeoffAirport.gpsLongitude
}
}];
for (const poi of poisWithGPS.value) {
path.push({
label: poi.pointOfInterest.name,
position: {lat: poi.pointOfInterest.gpsLatitude, lng: poi.pointOfInterest.gpsLongitude}
});
}
path.push({
label: flight.value.landingAirport.name,
position: {
lat: flight.value.landingAirport.gpsLatitude,
lng: flight.value.landingAirport.gpsLongitude
}
});
return path;
});
const gpxTrack = flight.value.gpxTrack;
const mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]);
const pointsOfInterests = computed(() => mapMarkers
.filter(marker => marker.gpsLatitude && marker.gpsLongitude)
.map(
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
));
const gpxTrack = props.flight.gpxTrack ? reactive(props.flight.gpxTrack) : null;
const displayMap = computed(() => {
return (
!!gpxTrack ||
mapPhotos.value.length > 0 ||
poisWithGPS.value.length > 0 ||
flight.value.landingAirport.id !== flight.value.takeoffAirport.id
props.flight.landingAirport.id !== props.flight.takeoffAirport.id
)
});
@@ -101,13 +79,13 @@ if (gpxTrack) {
const currentPositionMarker = ref(null);
const drawCurrentPositionMarker = (e) => {
const res = e.chart.getElementsAtEventForMode(e, 'nearest', {intersect: false},);
const res = e.chart.getElementsAtEventForMode(e, 'nearest', {intersect: false});
if (res.length === 0) {
currentPositionMarker.value = null;
return;
}
const idx = res[0].index;
currentPositionMarker.value = flight.value.gpxTrack.coordinates[idx];
currentPositionMarker.value = props.flight.gpxTrack.coordinates[idx];
}
const chartOptions = {
+26 -14
View File
@@ -1,25 +1,25 @@
<script setup>
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {flightName} from "src/composables/objects/flight";
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
import {flightName} from "src/composables/flight";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
const props = defineProps({
items: {
type: Array,
required: true
},
routeDetail: {
type: Object,
required: false,
default: () => ({name: 'flightDetail', params: {}})
},
displayVisibilityInfo: {
public: {
type: Boolean,
required: false,
default: true
}
required: true
},
});
const routeDetail = (flight) => {
if (props.public)
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, id: flight.id}}
else
return {name: "flightDetail", params: {id: flight.id}}
};
</script>
<template>
@@ -28,16 +28,22 @@ const props = defineProps({
v-for="flight of items" :key="flight"
ripple
clickable
:to="{name: routeDetail.name, params:{...routeDetail.params, id: flight.id}}"
:to="routeDetail(flight)"
>
<q-item-section avatar>
<AvatarFromPhoto :size="120" v-if="flight.coverPhoto" :src="flight.coverPhoto.thumbnailUrl"/>
<AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
</q-item-section>
<q-item-section>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<div class="text-secondary" v-if="props.public">
<q-icon name="support_agent"/>
@{{flight.pilot.publicUsername}}
</div>
<div class="text-secondary" v-if="flight.copilot">
<q-icon name="person"/>
{{ flight.pilot.name }}
{{ flight.copilot.name }}
</div>
@@ -49,9 +55,15 @@ const props = defineProps({
<q-icon name="flight"/>
{{ flight.aircraft.callSign }}
</div>
<div class="text-secondary" v-if="flight.event">
<q-icon name="event"/>
{{ flight.event.name }}
</div>
</q-item-section>
<q-item-section side top v-if="flight.isPublic && displayVisibilityInfo">
<q-item-section side top v-if="flight.isPublic && !props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
-58
View File
@@ -1,58 +0,0 @@
<script setup>
import PhotoDetail from "components/photos/PhotoDetail.vue";
import { ref, watch} from "vue";
const props = defineProps({
photos: {
type: Array,
required: true
},
coverPhotoUrl: {
type: String,
required: false
},
defaultTitle: {
type: String,
required: false
}
})
const displayedPhoto = ref(null);
const openPhotoDetail = ref(false);
const photoData = ref(null);
const openDetail = ({photo, idx, allPhotos}) => {
openPhotoDetail.value = true;
photoData.value = {photo, idx, allPhotos}
}
watch(() => props.photos, (newVal) => {
if (newVal.length === 0) return;
displayedPhoto.value = props.coverPhotoUrl || newVal[0].url;
}, {immediate: true});
</script>
<template>
<q-carousel
v-if="photos.length > 0"
animated
v-model="displayedPhoto"
arrows
swipeable
height="auto"
infinite
v-bind="$attrs"
>
<q-carousel-slide style="padding: 0" v-for="(photo, idx) in photos" :key="photo.url" :name="photo.url">
<q-img :src="photo.url" @click="openDetail({photo, idx, allPhotos: photos})"/>
</q-carousel-slide>
</q-carousel>
<PhotoDetail
v-if="photoData"
v-model="openPhotoDetail"
:default-title="defaultTitle"
:photo="photoData.photo"
:photos="photoData.allPhotos"
:opened-index="photoData.idx"
/>
</template>
+16 -18
View File
@@ -6,7 +6,6 @@ import DatetimePicker from "components/inputs/DatetimePicker.vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules";
import {isRequestPending} from "src/composables/form/props";
const props = defineProps({
aircrafts: {
@@ -21,19 +20,24 @@ const props = defineProps({
type: Object,
required: false
},
isRequestPending,
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
});
const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
const airports = computed(() => props.airports.map(a => ({id: a.id, name: airportName(a)})));
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
const formData = reactive({
aircraft: null,
takeoffAirport: null,
landingAirport: null,
takeoffDatetime: null,
landingDatetime: null,
landings: 1,
isPublic: false,
name: "",
description: "",
});
const rules = {
@@ -43,19 +47,15 @@ const rules = {
landingDatetime: [isRequired],
landingAirport: [isRequired],
};
watch(() => props.flight, (newVal) => {
if (newVal) {
setDefaultData(formData, newVal);
formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign};
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: newVal.takeoffAirport.name}
formData.landingAirport = {id: newVal.landingAirport.id, name: newVal.landingAirport.name}
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)}
}
}, {immediate: true});
const aircrafts = computed(() => {
return props.aircrafts.map(a => ({id: a.id, name: a.callSign}));
});
watch(() => formData.takeoffDatetime, () => {
if (!formData.landingDatetime) {
formData.landingDatetime = structuredClone(formData.takeoffDatetime);
@@ -73,7 +73,7 @@ watch(() => formData.takeoffAirport, () => {
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<div class="row">
<div class="col">
<q-card class="q-mx-md q-my-md">
<q-card class="q-my-sm">
<q-card-section>
<div class="text-h6">Vyber letadlo</div>
</q-card-section>
@@ -92,7 +92,7 @@ watch(() => formData.takeoffAirport, () => {
</div>
<div class="row">
<div class="col col-12 col-md-6">
<q-card class="q-mx-md q-my-md">
<q-card class="q-mr-md-sm q-my-sm">
<q-card-section>
<div class="text-h6">Vzlet</div>
</q-card-section>
@@ -110,7 +110,6 @@ watch(() => formData.takeoffAirport, () => {
v-model="formData.takeoffAirport"
label="Letiště vzletu"
v-bind="commonProps"
:enable-new-values="false"
/>
</q-card-section>
</q-card>
@@ -118,7 +117,7 @@ watch(() => formData.takeoffAirport, () => {
<div class="col col-12 col-md-6">
<q-card class="q-mx-md q-my-md">
<q-card class="q-ml-md-sm q-my-sm">
<q-card-section>
<div class="text-h6">Přistání</div>
</q-card-section>
@@ -137,7 +136,6 @@ watch(() => formData.takeoffAirport, () => {
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
:enable-new-values="false"
/>
</q-card-section>
</q-card>
+25 -11
View File
@@ -1,35 +1,49 @@
<script setup>
import {onMounted, ref} from "vue";
import {reactive, watch} from "vue";
import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling";
import RichTextEditor from "components/inputs/RichTextEditor.vue";
import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
const props = defineProps({
flight: {
type: Object,
required: false
},
events: {
type: Array,
required: false,
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
});
const formData = ref({
const formData = reactive({
name: "",
event: null,
description: ""
})
const isMounted = ref(false);
onMounted(() => {
if (props.flight)
setDefaultData(formData.value, props.flight);
watch(() => props.flight, (newVal) => {
if (newVal) {
setDefaultData(formData, newVal);
isMounted.value = true;
})
if (newVal.event) {
formData.event = {id: newVal.event.id, name: newVal.event.name};
}
}
}, {immediate: true});
</script>
<template>
<FormWrapper v-model="formData" v-if="isMounted">
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps" />
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..." />
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/>
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
</FormWrapper>
</template>
@@ -2,6 +2,7 @@
import FormWrapper from "components/FormWrapper.vue";
import {reactive} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {roundWithPrecision} from "../../../composables/utils";
const props = defineProps({
flight: {
@@ -17,6 +18,10 @@ const props = defineProps({
const formData = reactive({
photos: []
});
const inlinePreview = (photo) => {
return URL.createObjectURL(photo);
}
</script>
<template>
@@ -26,6 +31,18 @@ const formData = reactive({
align: "left"
}'>
<div class="flex flex-center">
<div v-for="photo in formData.photos" :key="photo.name" style="width: 70px" class="q-ma-sm">
<q-img
:src="inlinePreview(photo)"
fit="fill"
>
<div class="absolute-top absolute-bottom text-center" v-if="isRequestPending">
<q-spinner/>
</div>
</q-img>
</div>
</div>
<q-file
class="q-my-lg"
outlined
+70 -17
View File
@@ -1,6 +1,6 @@
<script setup>
import FormWrapper from "components/FormWrapper.vue";
import {onMounted, reactive, ref, watch} from "vue";
import {reactive, ref, watch} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs";
@@ -12,6 +12,10 @@ const props = defineProps({
type: Array,
required: true
},
airports: {
type: Array,
required: true
},
flight: {
type: Object,
required: false
@@ -27,31 +31,50 @@ const removeTrackItem = (index) => {
}
const poi = ref(null);
const addTrackItem = (item) => {
formData.track.push(item);
const airport = ref(null);
const landingDuration = ref(null);
const addTrackPOIItem = (item) => {
formData.track.push({pointOfInterest: item});
poi.value = null;
}
const addAirportToTrack = (airportData, duration) => {
formData.track.push({airport: airportData, landingDuration: parseInt(duration)})
airport.value = null;
}
watch(poi, (newVal) => {
if (newVal?.name) {
addTrackItem(newVal);
addTrackPOIItem(newVal);
}
});
onMounted(() => {
for (const i in props.flight.track) {
const poiInfo = props.flight.track[i].pointOfInterest;
formData.track.push({
id: poiInfo.id,
name: poiInfo.name
});
watch(() => props.flight.track, (newVal) => {
for (const i in newVal) {
const trackInfo = newVal[i];
const info = {landingDuration: trackInfo.landingDuration};
if (trackInfo.pointOfInterest) {
info['pointOfInterest'] = {
id: trackInfo.pointOfInterest.id,
name: trackInfo.pointOfInterest.name,
};
}
});
if (trackInfo.airport) {
info['airport'] = {
id: trackInfo.airport.id,
name: trackInfo.airport.name,
}
}
formData.track.push(info);
}
}, {immediate: true});
</script>
<template>
<FormWrapper v-model="formData">
<q-banner class="bg-green text-white" v-if="flight.gpxTrackUrl">
<q-banner class="bg-green text-white" v-if="flight.gpxTrack">
<q-icon name="check"/>
Trasa nahrána
</q-banner>
@@ -73,12 +96,39 @@ onMounted(() => {
v-model="poi"
:key="formData.track.length"
:options="locations"
@select="addTrackItem"
@select="addTrackPOIItem"
clearable
label="Přidat otočný bod"
hint="Nastav významné otočné body"
/>
<div class="row">
<div class="col">
<ComboboxInput
v-bind="commonProps"
v-model="airport"
:key="formData.track.length"
:options="airports"
clearable
label="Letiště pro mezipřistání"
hint="Nastav letiště a dobu mezispřistání"
/>
</div>
<div class="col" v-if="airport?.name">
<q-input
type="number"
v-bind="commonProps"
v-model="landingDuration"
label="Délka mezipřistání (minuty)"
class="q-mx-md"
>
<template #after>
<q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn>
</template>
</q-input>
</div>
</div>
<q-list bordered>
<q-item-label header>Trasa</q-item-label>
<q-item>
@@ -95,13 +145,16 @@ onMounted(() => {
<q-avatar size="32px" color="secondary" text-color="white">{{ index + 1 }}.</q-avatar>
</q-item-section>
<q-item-section>
<span>
{{ item.name }}
<span v-if="item.pointOfInterest">
{{ item.pointOfInterest.name }}
<q-badge color="green" v-if="!item.id">
<q-badge color="green" v-if="!item.pointOfInterest.id">
<q-icon name="add"/>
</q-badge>
</span>
<span v-if="item.airport">
{{ item.airport.name }}, mezipřistání: {{ item.landingDuration }} minut
</span>
</q-item-section>
<q-item-section top side>
@@ -1,5 +1,4 @@
<script setup>
import {randomInt} from "src/composables/random";
import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue";
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</script>
@@ -16,7 +15,7 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
<q-item>
<q-list>
<ListItemSkeleton v-for="i in randomInt(1, 3)" :key="i"/>
<ListItemSkeleton v-for="i in 3" :key="i"/>
</q-list>
</q-item>
@@ -26,7 +25,7 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</q-list>
<article class="q-my-lg">
<BasicTextSkeleton :lines="randomInt(2, 5)" :paragraphs="randomInt(1, 2)" />
<BasicTextSkeleton :lines="7" :paragraphs="2" />
</article>
<q-skeleton height="400px" square class="q-mt-lg"/>
@@ -1,6 +1,7 @@
<script setup>
</script>
<template>
<em>POI edit skeleton</em>
edit copilot skeleton
</template>
@@ -0,0 +1,77 @@
<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>
@@ -3,8 +3,8 @@ import {randomInt} from "src/composables/random";
</script>
<template>
<q-list bordered separator class="q-mx-md q-my-md">
<q-item v-for="i in randomInt(5, 10)" :key="i" ripple>
<q-list bordered separator>
<q-item v-for="i in 10" :key="i" ripple>
<q-item-section avatar>
<q-avatar size="100px">
<q-skeleton type="QAvatar" size="100px"></q-skeleton>
@@ -12,7 +12,6 @@ import {randomInt} from "src/composables/random";
</q-item-section>
<q-item-section>
<q-skeleton :width="randomInt(30,50)+'%'"/>
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/>
<q-skeleton type="text" :width="randomInt(5,10)+'%'"/>
<q-skeleton type="text" width="8%"/>
+6 -7
View File
@@ -10,11 +10,6 @@ const props = defineProps({
required: false,
default: () => []
},
enableNewValues: {
type: Boolean,
required: false,
default: true,
},
options: {
type: Array,
required: true,
@@ -101,11 +96,11 @@ const filterFn = (val, update, abort) => {
:options="availableOptions"
:option-label="optionLabel"
:option-value="optionValue"
:new-value-mode="enableNewValues ? 'add' : 'toggle'"
new-value-mode="add"
clear-icon="clear"
:v-bind="$attrs"
>
<template v-slot:no-option v-if="enableNewValues">
<template v-slot:no-option>
<q-item v-if="inputValue">
<q-item-section avatar>
<q-avatar text-color="green" size="32px" icon="add"/>
@@ -115,5 +110,9 @@ const filterFn = (val, update, abort) => {
</q-item-section>
</q-item>
</template>
<template #append>
<slot name="append" />
</template>
</q-select>
</template>
+61 -21
View File
@@ -1,6 +1,6 @@
<script setup>
import {computed, onMounted, ref, watch} from "vue";
import {FORMAT_DATETIME_LONG, FORMAT_ISO, formatDatetime, parseDate} from "src/composables/datetimeUtils";
import {FORMAT_DATE, FORMAT_DATE_ISO, FORMAT_DATETIME_LONG, FORMAT_ISO, formatDatetime, parseDate} from "src/composables/datetimeUtils";
import {commonProps} from "src/composables/inputs";
const props = defineProps({
@@ -23,9 +23,14 @@ const props = defineProps({
}
})
let dateModel = ref("");
let timeModel = ref("");
const emit = defineEmits(['update:modelValue']);
const dateModel = ref("");
const timeModel = ref("00:00:00");
const tab = ref("date");
let originalTime, originalDate;
const popupProxy = ref(null);
const publicModel = computed(() => {
if (!dateModel.value) {
@@ -35,12 +40,22 @@ const publicModel = computed(() => {
return formatDatetime(dateObject, props.mask);
});
const popupProxy = ref(null);
const model = computed(() => {
if (dateModel.value === '') return null;
let modelVal = dateModel.value;
if (showTimePicker.value) {
modelVal += "T" + timeModel.value;
} else {
modelVal += "T00:00:00"
}
return modelVal
});
const model = computed(() => dateModel.value + "T" + timeModel.value);
const emit = defineEmits(['update:modelValue']);
const emitModelUpdate = () => {
if (dateModel.value === '' || timeModel.value === '') return;
if (dateModel.value === '' || timeModel.value === '') {
return;
}
const val = parseDate(model.value);
emit("update:modelValue", val);
@@ -49,6 +64,12 @@ const emitModelUpdate = () => {
popupProxy.value.hide();
}
const showTimePicker = computed(() => containsAnyOf(["H", "h", "m"], props.mask));
watch(() => dateModel.value, (newVal, oldVal) => {
if (showTimePicker.value)
tab.value = "time";
});
const containsAnyOf = (needles, haystack) => {
for (const i in needles) {
if (haystack.includes(needles[i])) return true;
@@ -57,9 +78,6 @@ const containsAnyOf = (needles, haystack) => {
return false;
}
const showDatePicker = computed(() => containsAnyOf(["YY", "M", "D"], props.mask));
const showTimePicker = computed(() => containsAnyOf(["H", "h", "m"], props.mask));
const setOriginalValues = () => {
originalDate = dateModel.value;
originalTime = timeModel.value;
@@ -120,21 +138,43 @@ watch(() => props.modelValue, (newVal) => {
<template #default>
<q-popup-proxy ref="popupProxy" @hide="emitModelUpdate" @show="setOriginalValues">
<div class="proxy-wrapper">
<div class="row">
<div class="col-12 col-sm-6" v-if="showDatePicker">
<q-date square v-model="dateModel" :options="isDateValid" :today-btn="true" flat mask="YYYY-MM-DD" :locale="myLocale"/>
</div>
<div class="col-12 col-sm-6" v-if="showTimePicker">
<q-time square v-model="timeModel" format24h flat mask="HH:mm:ss"/>
</div>
</div>
<div class="row q-mb-sm">
<div class="proxy-wrapper relative-position" :style="`min-height: ${showTimePicker ? '500' : '300'}px`">
<q-tabs v-model="tab" v-if="showTimePicker" class="bg-primary text-white">
<q-tab name="date" icon="event" :label="dateModel ? formatDatetime(dateModel, 'D. M.', FORMAT_DATE_ISO) : 'datum'"/>
<q-tab name="time" v-if="showTimePicker" icon="schedule" :label="model ? formatDatetime(model, 'HH:mm') : 'čas'"/>
</q-tabs>
<q-separator/>
<q-tab-panels v-model="tab" animated>
<q-tab-panel name="date">
<q-date
minimal
flat
v-model="dateModel"
:options="isDateValid"
mask="YYYY-MM-DD"
:locale="myLocale"
/>
</q-tab-panel>
<q-tab-panel name="time" v-if="showTimePicker">
<q-time
v-model="timeModel"
format24h
flat
mask="HH:mm:ss"
/>
</q-tab-panel>
</q-tab-panels>
<div class="row q-mb-sm absolute-bottom">
<div class="col">
<q-btn color="secondary" class="q-ml-sm" @click="cancelSelection">Zrušit</q-btn>
</div>
<div class="col text-right">
<q-btn color="primary" class="q-mr-sm" @click="emitModelUpdate">Nastavit</q-btn>
<q-btn color="primary" class="q-mr-sm" :disable="!dateModel || !timeModel" @click="emitModelUpdate">Nastavit</q-btn>
</div>
</div>
</div>
-3
View File
@@ -27,9 +27,6 @@ const props = defineProps({
})
const track = ref([]);
const speed = ref([]);
const elevation = ref([]);
const bounds = computed(() => {
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
const allData = [...props.markers, ...track.value, ...photosTransformed];
@@ -8,7 +8,7 @@ const props = defineProps({
</script>
<template>
<q-breadcrumbs>
<q-breadcrumbs class="q-mx-md q-my-md">
<q-breadcrumbs-el v-for="item in items" :key="item.label" :to="item.to"> {{ item.label }} </q-breadcrumbs-el>
</q-breadcrumbs>
</template>
@@ -0,0 +1,14 @@
<script setup>
import {randomInt} from "src/composables/random";
</script>
<template>
<q-breadcrumbs class="q-mx-md q-my-md">
<q-breadcrumbs-el> <q-skeleton :width="`${randomInt(30, 60)}px`" type="rect" /> </q-breadcrumbs-el>
<q-breadcrumbs-el> <q-skeleton :width="`${randomInt(40, 80)}px`" type="rect" /> </q-breadcrumbs-el>
</q-breadcrumbs>
</template>
@@ -0,0 +1,27 @@
<script setup>
const props = defineProps({
route: {
type: Object,
required: true
}
});
</script>
<template>
<q-tabs active-color="primary" class="q-mb-lg">
<q-route-tab
icon="edit"
label="Informace"
replace
:to="{name: route.name, params: {...route.params, step: 'info'}}"
exact
/>
<q-route-tab
icon="add_a_photo"
label="Fotky"
replace
:to="{name: route.name, params: {...route.params, step: 'photo'}}"
exact100
/>
</q-tabs>
</template>
+16 -1
View File
@@ -1,6 +1,7 @@
<template>
<q-item
clickable
:active="isButtonActive"
tag="router-link"
:to="to"
>
@@ -19,6 +20,8 @@
</template>
<script setup>
import {computed} from "vue";
import {useRoute, useRouter} from "vue-router";
const props = defineProps({
title: {
@@ -40,5 +43,17 @@ const props = defineProps({
type: String,
default: ''
}
})
});
const $route = useRoute();
const $router = useRouter();
const isButtonActive = computed(() => {
const btnResolvedPath = $router.resolve(props.to).path;
if (props.to.name === 'myFlights') {
return $route.name.toLowerCase().includes("flight");
} else {
return $route.fullPath === btnResolvedPath || $route.fullPath.startsWith(btnResolvedPath);
}
});
</script>
@@ -0,0 +1,15 @@
<script setup>
const props = defineProps({
username: {
type: String,
required: true
}
})
</script>
<template>
<q-tabs class="q-mb-md">
<q-route-tab icon="flight_takeoff" :to="{name: 'publicProfileDetail', params: {username: username}}">Lety</q-route-tab>
<q-route-tab icon="event" :to="{name: 'publicEventsList', params: {username: username}}">Události</q-route-tab>
</q-tabs>
</template>
@@ -0,0 +1,54 @@
<script setup>
import NavigationButton from "components/navigation/NavigationButton.vue";
const linksList = [
{
title: 'Moje lety',
icon: "flight_takeoff",
to: {name: "myFlights"}
},
{separator: true},
{
title: 'Letadla',
icon: "connecting_airports",
to: {name: "aircrafts"}
},
{
title: 'Zajímavá místa',
icon: "place",
to: {name: "poi"}
},
{
title: 'Spolucestující',
icon: "people",
to: {name: "copilots"}
},
{separator: true},
{
title: 'Události',
icon: 'event',
to: {name: "events"}
},
{separator: true},
{
title: 'Organizace',
icon: "groups",
to: {name: "organizations"}
},
{separator: true},
]
</script>
<template>
<q-list class="q-my-lg">
<template v-for="link in linksList" :key="link.title">
<q-separator v-if="link.separator"/>
<NavigationButton
v-if="!link.separator"
v-bind="link"
/>
</template>
</q-list>
</template>
@@ -0,0 +1,43 @@
<script setup>
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
const props = defineProps({
loggedUser: {
type: Object,
required: true
}
});
const emits = defineEmits(["logout"]);
const logout = () => emits("logout");
</script>
<template>
<q-btn flat>
<AvatarFromPhoto v-if="loggedUser.avatarImageUrl" :size="32" :src="loggedUser.avatarImageUrl"/>
<q-icon name="person" v-else/>
<q-menu auto-close>
<q-list style="min-width: 200px">
<q-item clickable :to="{name: 'profile'}">
<q-item-section avatar>
<q-icon name="person"/>
</q-item-section>
<q-item-section>Osobní nastavení</q-item-section>
</q-item>
<q-item clickable @click="logout" class="q-mt-lg">
<q-item-section avatar>
<q-icon name="logout"/>
</q-item-section>
<q-item-section>
Odhlásit
</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</template>
@@ -0,0 +1,45 @@
<script setup>
import {onMounted, reactive} from "vue";
import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules";
const props = defineProps({
organization: {
type: Object
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
name: null,
});
const rules = {
name: [isRequired]
}
onMounted(() => {
if (props.organization) {
setDefaultData(formData, props.organization);
}
})
</script>
<template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<q-input
v-bind="commonProps"
label="Jméno"
:rules="rules.name"
v-model="formData.name"
/>
</FormWrapper>
</template>
@@ -0,0 +1,36 @@
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
},
myOrganizationsIds: {
type: Array,
required: false
}
})
</script>
<template>
<q-list bordered separator>
<q-item
clickable
:to="{name: 'organizationDetail', params: {id: organization.id }}"
v-ripple
v-for="organization of items"
:key="organization"
>
<q-item-section avatar>
<q-avatar icon="groups" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
<q-item-label>
{{ organization.name }}
<q-icon name="star" color="red" v-if="myOrganizationsIds.includes(organization.id)"/>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
@@ -0,0 +1,6 @@
<script setup>
</script>
<template>
<q-skeleton type="QInput" />
</template>
+34 -8
View File
@@ -12,13 +12,21 @@ const props = defineProps({
},
locations: {
type: Array
}
},
aircrafts: {
type: Array
},
copilots: {
type: Array
},
});
const formData = ref({
name: "",
description: "",
pointOfInterest: null,
copilots: [],
aircraftId: null,
photoIds: []
});
@@ -34,23 +42,41 @@ const setup = () => {
<template>
<DialogWrapper :formData="formData" @show="setup()">
<q-card-section class="text-center">
<!-- <q-scroll-area style="min-height: 80px; max-height: 150px">-->
<q-img :src="photo.thumbnailUrl" width="80px" class="q-ma-sm" v-for="photo in photos" :key="photo.thumbnailUrl"/>
<!-- {{ photos }}-->
<!-- </q-scroll-area>-->
</q-card-section>
<q-card-section>
<FormWrapper :save-button="false">
<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"/>
<ComboboxInput
:options="locations"
v-bind="commonProps"
v-model="formData.pointOfInterest"
:enable-new-values="false"
label="POI"
label="Místo"
/>
<q-select
v-if="copilots.length > 0"
:options="copilots"
v-model="formData.copilots"
v-bind="commonProps"
label="Kopiloti"
option-label="name"
option-value="id"
multiple
/>
<q-select
:options="aircrafts"
v-model="formData.aircraftId"
v-bind="commonProps"
label="Letadlo"
option-label="callSign"
option-value="id"
emit-value
map-options
/>
<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"/>
</FormWrapper>
</q-card-section>
</DialogWrapper>
+63
View File
@@ -0,0 +1,63 @@
<script setup>
import {formatDatetime} from "src/composables/datetimeUtils";
import {roundWithPrecision} from "src/composables/utils";
import EmbeddedMap from "components/map/EmbeddedMap.vue";
const props = defineProps({
photo: {
type: Object,
required: true
}
})
</script>
<template>
<div class="pswp-caption-content">
<strong v-if="photo.name !== ''">{{ photo.name }}</strong>
<article v-html="photo.description" v-if="photo.description !== ''"/>
<q-list>
<q-item v-if="photo.pointOfInterest">
<q-item-section avatar>
<q-avatar icon="marker"></q-avatar>
</q-item-section>
<q-item-section>
{{ photo.pointOfInterest.name }}
</q-item-section>
</q-item>
<q-item v-if="photo.exposedAt">
<q-item-section avatar>
<q-avatar icon="photo_camera"></q-avatar>
</q-item-section>
<q-item-section>
{{ formatDatetime(photo.exposedAt) }}
</q-item-section>
</q-item>
<q-item v-if="photo.gpsAltitude">
<q-item-section avatar>
<q-avatar icon="height"/>
</q-item-section>
<q-item-section>
Nadmořská výška: {{ roundWithPrecision(photo.gpsAltitude, 1) }} m
<br>
<template v-if="photo.terrainElevation">
Výška terénu: {{ roundWithPrecision(photo.terrainElevation, 1) }} m
<br>
Výška nad zemí: {{ roundWithPrecision(photo.gpsAltitude - photo.terrainElevation, 1) }} m
</template>
</q-item-section>
</q-item>
</q-list>
<div style="height: 200px; width: 100%;">
<EmbeddedMap
v-if="photo.gpsLatitude && photo.gpsLongitude"
height="200px"
class="q-mb-sm"
:gps-latitude="photo.gpsLatitude"
:gps-longitude="photo.gpsLongitude"
/>
</div>
</div>
</template>
-227
View File
@@ -1,227 +0,0 @@
<script setup>
import EmbeddedMap from "components/map/EmbeddedMap.vue";
import {computed, onMounted, ref} from "vue";
import {formatDatetime} from "src/composables/datetimeUtils";
import 'vue-inner-image-zoom/lib/vue-inner-image-zoom.css'
import {useQuasar} from "quasar";
const props = defineProps({
photos: {
type: Array,
required: false,
default: () => []
},
openedIndex: {
type: Number,
required: false,
},
modelValue: {
type: Boolean,
required: false,
default: false
},
defaultTitle: {
type: String,
required: false
}
});
const emit = defineEmits(["update:modelValue"])
const isDetailOpened = computed({
get() {
return props.modelValue;
},
set(newVal) {
emit("update:modelValue", newVal);
}
})
const photoIndex = ref(null);
onMounted(() => {
photoIndex.value = props.openedIndex
});
const photo = computed(() => props.photos[photoIndex.value]);
const nextPhoto = () => {
if (imageFullscreen.value) return;
if (photoIndex.value < props.photos.length - 1)
photoIndex.value++
}
const prevPhoto = () => {
if (imageFullscreen.value) return;
if (photoIndex.value > 0) photoIndex.value--;
}
const showDetail = (idx) => {
photoIndex.value = idx;
}
const showExtra = ref(false);
const $q = useQuasar();
const isMaximized = computed(() => $q.screen.width <= $q.screen.sizes.sm);
const imageFullscreen = ref(false);
</script>
<template>
<q-dialog
v-model="isDetailOpened"
full-width
:maximized="isMaximized"
>
<q-card>
<q-card-section class="bg-black text-white" v-if="isMaximized">
<div class="row">
<div class="col">
<strong>{{ photo.name || defaultTitle }}</strong>
</div>
<div class="col-1 text-right">
<q-btn round text-color="white" size="sm" icon="close" @click="isDetailOpened = false"/>
</div>
</div>
</q-card-section>
<q-card-section :class="{'q-pa-none': isMaximized}">
<div :class="{'relative-position': true, 'image-full-height': imageFullscreen}">
<div
class="text-center"
v-touch-swipe.mouse.right="prevPhoto"
v-touch-swipe.mouse.left="nextPhoto"
@keyup.left="prevPhoto"
@keyup.right="nextPhoto">
<inner-image-zoom
:move-type="isMaximized ? 'drag' : 'pan'"
:zoomScale="0.75"
:hide-hint="true"
:after-zoom-in="() => imageFullscreen = true"
:after-zoom-out="() => imageFullscreen = false"
:src="photo.url"
/>
</div>
<template v-if="!imageFullscreen">
<q-btn
class="absolute all-pointer-events"
round
flat
icon="info"
:text-color="showExtra ? 'primary' : 'white'"
style="background: rgba(0, 0, 0, 0.25); bottom: 16px; left: 8px"
@click="showExtra = !showExtra"
/>
<q-btn
class="absolute all-pointer-events"
style="background: rgba(0, 0, 0, 0.25); top: 50%; left: 8px;"
@click="prevPhoto"
v-if="photoIndex > 0 && !isMaximized"
icon="chevron_left"
text-color="white"
flat
rounded
/>
<q-btn
class="absolute all-pointer-events"
style="background: rgba(0, 0, 0, 0.25); top: 50%; right: 8px;"
@click="nextPhoto"
v-if="photoIndex < photos.length - 1 && !isMaximized"
icon-right="chevron_right"
text-color="white"
flat
rounded
/>
</template>
</div>
<div class="row" v-show="!imageFullscreen">
<div class="col-4 col-md-1 q-pa-sm" v-for="(thumb, idx) in photos" :key="thumb.thumbnailUrl">
<q-img
:src="thumb.thumbnailUrl"
@click="showDetail(idx)"
:class='{"shadow-5": true, "active-thumbnail": idx === photoIndex}'
/>
</div>
</div>
<div class="row">
<div class="col-12 q-my-md" v-if="showExtra">
<EmbeddedMap
v-if="photo.gpsLatitude && photo.gpsLongitude"
class="q-mx-sm"
:gps-latitude="photo.gpsLatitude"
:gps-longitude="photo.gpsLongitude"
/>
<strong v-if="photo.name !== '' && !isMaximized">{{ photo.name }}</strong>
<p v-if="photo.description !== ''">{{ photo.description }}</p>
<q-list dense>
<q-item v-if="photo.pointOfInterest">
<q-item-section avatar>
<q-avatar icon="marker"></q-avatar>
</q-item-section>
<q-item-section>
{{ photo.pointOfInterest.name }}
</q-item-section>
</q-item>
<q-item v-if="photo.exposedAt">
<q-item-section avatar>
<q-avatar icon="photo_camera"></q-avatar>
</q-item-section>
<q-item-section>
{{ formatDatetime(photo.exposedAt) }}
</q-item-section>
</q-item>
<q-item v-if="photo.gpsAltitude">
<q-item-section avatar>
<q-avatar icon="height"/>
</q-item-section>
<q-item-section>
Nadmořská výška: {{ photo.gpsAltitude }} m
<br>
<template v-if="photo.terrainElevation">
Výška terénu: {{ photo.terrainElevation }} m
<br>
Výška nad zemí: {{ photo.gpsAltitude - photo.terrainElevation }} m
</template>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
<style>
.active-thumbnail {
outline: solid 2px
}
.iiz {
width: 100%;
}
.image-full-height .iiz {
height: calc(95vh - 65px) !important;
}
.iiz__img {
max-height: 90vh !important;
margin: 0 auto;
}
.iiz__img--hidden {
height: 100vh;
}
.iiz__zoom-img--visible {
//height: 125vh !important
}
</style>
+33 -6
View File
@@ -12,6 +12,12 @@ const props = defineProps({
},
locations: {
type: Array
},
aircrafts: {
type: Array
},
copilots: {
type: Array
}
});
@@ -20,7 +26,8 @@ const formData = ref({
name: "",
description: "",
pointOfInterest: null,
isFlightCover: false
copilots: [],
aircraftId: null,
});
const setup = (() => {
@@ -33,17 +40,37 @@ const setup = (() => {
<q-img :src="photo.thumbnailUrl"/>
<q-card-section>
<FormWrapper :save-button="false">
<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"/>
<ComboboxInput
:options="locations"
v-bind="commonProps"
v-model="formData.pointOfInterest"
label="POI"
label="Místo"
/>
<q-checkbox v-model="formData.isFlightCover" label="Úvodní fotografie k letu?"/>
<q-select
v-if="copilots.length > 0"
:options="copilots"
v-model="formData.copilots"
v-bind="commonProps"
label="Kopiloti"
option-label="name"
option-value="id"
multiple
/>
<q-select
:options="aircrafts"
v-model="formData.aircraftId"
v-bind="commonProps"
label="Letadlo"
option-label="callSign"
option-value="id"
emit-value
map-options
/>
<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"/>
</FormWrapper>
</q-card-section>
</DialogWrapper>
+106
View File
@@ -0,0 +1,106 @@
<script setup>
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
import 'photoswipe/style.css';
import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css";
import PhotoSwipeDynamicCaption from "../../../node_modules/photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.esm";
import PhotoCaption from "components/photos/PhotoCaption.vue";
import PhotoSwipeLightbox from "../../../node_modules/photoswipe/dist/photoswipe-lightbox.esm"
import PhotoSwipe from "../../../node_modules/photoswipe/dist/photoswipe.esm"
import {useRoute, useRouter} from "vue-router";
import {usePhotosStore} from "stores/Photos";
const props = defineProps({
galleryId: {
type: String,
required: false,
default: () => "photogallery"
},
photos: {
type: Array,
required: true
}
})
const route = useRoute();
const $router = useRouter();
let $lightbox = ref(null);
const initLightbox = () => {
if ($lightbox.value && $lightbox.value.pswp) {
// lightbox already initialized
return
}
$lightbox.value = new PhotoSwipeLightbox({
gallery: '#' + props.galleryId,
children: 'a',
pswpModule: () => PhotoSwipe,
});
let captionPlugin = new PhotoSwipeDynamicCaption($lightbox.value, {
type: 'auto',
});
$lightbox.value.init();
}
onMounted(() => {
initLightbox();
// todo: tohle nefunguje, sice se galerie zavre, ale i tak se user presmeruje na predchozi stranku
window.onpopstate = function (e) {
if (!$lightbox.value || !$lightbox.value.pswp) {
initLightbox();
}
if ($lightbox.value && $lightbox.value.pswp)
$lightbox.value.pswp.close();
};
});
onUnmounted(() => {
if ($lightbox.value) {
$lightbox.value.destroy();
$lightbox.value = null;
}
});
const openedPhoto = computed(() => usePhotosStore().openedPhoto);
watch(() => [openedPhoto.value, $lightbox.value], () => {
if (!$lightbox.value || !openedPhoto.value) return;
const idx = props.photos.findIndex(p => p.id === openedPhoto.value.id);
if (idx >= 0) {
$lightbox.value.loadAndOpen(idx);
usePhotosStore().setOpenedPhoto(null);
}
}, {immediate: true})
</script>
<template>
<div :id="galleryId" class="row">
<div class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" v-for="photo in photos" :key="photo.id">
<a
:href="photo.url"
:data-pswp-width="photo.width"
:data-pswp-height="photo.height"
target="_blank"
rel="noreferrer"
>
<q-img
spinner-color="primary"
class="rounded-borders"
:src="photo.thumbnailUrl"
alt=""
>
<div class="absolute-bottom" v-if="photo.name !== ''">
{{ photo.name }}
</div>
</q-img>
<PhotoCaption :photo="photo"/>
</a>
</div>
</div>
</template>
+190
View File
@@ -0,0 +1,190 @@
<script setup>
import {computed, onMounted, reactive, ref, watch} from "vue";
import {useQuasar} from "quasar";
import {confirmObject} from "src/composables/dialog";
const props = defineProps({
photos: {
type: Array
},
titlePhoto: {
type: Object,
},
showActions: {
type: Boolean,
required: false,
default: true
}
});
const $q = useQuasar();
const batchEdit = ref({});
for (const photo of props.photos) {
batchEdit.value[photo.id] = false;
}
const emits = defineEmits(["edit", "delete", "set-title-photo", "batch-edit", "batch-rotate"])
const openEdit = (photo) => {
emits("edit", photo);
};
const confirmPhotoDelete = (id) => {
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
.onOk(() => {
emits("delete", id);
});
}
const confirmBatchPhotoDelete = () => {
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?"))
.onOk(() => {
for (const id in batchEdit.value) {
if (!batchEdit.value[id]) continue;
emits("delete", parseInt(id));
batchEdit.value[id] = false;
}
});
}
const handleImageClick = (photo, idx) => {
batchEdit.value[photo.id] = !batchEdit.value[photo.id];
}
const selectedPhotos = computed(() => {
const photos = [];
for (const i in batchEdit.value) {
const photo = props.photos.find(p => p.id === parseInt(i));
if (photo && batchEdit.value[i]) photos.push(photo);
}
return photos;
});
const openBatchEdit = () => {
const photos = selectedPhotos.value;
for (const i in batchEdit.value) {
batchEdit.value[i] = false;
}
emits('batch-edit', photos);
}
const batchRotate = (direction) => {
const photos = selectedPhotos.value;
emits("batch-rotate", {photos, direction});
}
const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0);
const checkAll = ref(false);
watch(() => checkAll.value, (newVal) => {
if (newVal === null) return;
for (const photo of props.photos) {
batchEdit.value[photo.id] = newVal;
}
});
watch(() => batchEdit.value, () => {
let checkedAll = true;
let uncheckedAll = true;
for (const i of Object.keys(batchEdit.value)) {
if (typeof batchEdit.value[i] === 'undefined') continue;
if (batchEdit.value[i]) uncheckedAll = false;
if (!batchEdit.value[i]) checkedAll = false;
}
if (checkedAll) {
checkAll.value = true;
} else if (uncheckedAll) {
checkAll.value = false;
} else {
checkAll.value = null;
}
}, {deep: true});
const batchOptionsProps = computed(() => ({
round: true,
flat: true,
class: "q-ml-lg",
disable: !isBatchEditAllowed.value
}));
const setFlightCover = (titlePhoto) => {
emits("set-title-photo", titlePhoto);
}
const openPhoto = ref(false);
const photo = ref(null);
const showLarge = (displayedPhoto) => {
photo.value = displayedPhoto;
openPhoto.value = true;
}
</script>
<template>
<div class="row" style="margin-bottom: 150px">
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id">
<q-card :class='{"q-ma-sm": true, "shadow-1": !batchEdit[photo.id], "shadow-10": batchEdit[photo.id]}'>
<q-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)" style="cursor: pointer">
<div class="absolute-top bg-transparent flex">
<div class="col-grow">
<q-btn icon="fullscreen" color="grey-6" round @click.stop.prevent="showLarge(photo)"/>
</div>
<q-btn
icon="star"
:color="titlePhoto?.id === photo.id ? 'amber-9' : 'grey-6'"
round
@click.stop.prevent="setFlightCover(photo)"
/>
</div>
<div class="absolute-bottom" v-if="photo.name !== '' || photo.description !== ''">
<div class="text-h6" v-if="photo.name !== ''">
{{ photo.name }}
</div>
<div class="text-subtitle2" v-if="photo.description !== ''">
{{ photo.description }}
</div>
</div>
</q-img>
<q-card-actions align="right" :class="{'bg-primary': batchEdit[photo.id], 'text-white': batchEdit[photo.id]}" v-if="showActions">
<div class="col-grow">
<q-checkbox v-model="batchEdit[photo.id]"/>
<q-icon name="flight" v-if="photo.aircraftId" size="xs" color="grey-5"/>
<q-icon name="person" v-if="photo.copilots.length > 0" size="xs" color="grey-5"/>
<q-icon name="place" v-if="photo.pointOfInterest" size="xs" color="grey-5"/>
</div>
<q-btn flat icon="edit" color="blue" @click="openEdit(photo)"/>
<q-btn flat icon="tune" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/>
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"/>
</q-card-actions>
</q-card>
</div>
</div>
<q-page-sticky :offset="[50, 50]" position="bottom" v-if="photos.length > 0 && showActions">
<q-card>
<q-card-actions>
<q-checkbox v-model="checkAll" label="Vybrat vše"/>
<q-btn v-bind="batchOptionsProps" icon="edit" :color="isBatchEditAllowed ? 'primary' : 'grey'" @click="openBatchEdit"/>
<q-btn v-bind="batchOptionsProps" icon="rotate_90_degrees_ccw" :color="isBatchEditAllowed ? 'secondary' : 'grey'" @click="batchRotate('counterClockwise')"/>
<q-btn v-bind="batchOptionsProps" icon="rotate_90_degrees_cw" :color="isBatchEditAllowed ? 'secondary' : 'grey'" @click="batchRotate('clockwise')"/>
<q-btn v-bind="batchOptionsProps" icon="delete" :color="isBatchEditAllowed ? 'red' : 'grey'" @click="confirmBatchPhotoDelete"/>
</q-card-actions>
</q-card>
</q-page-sticky>
<q-dialog v-model="openPhoto" full-width>
<q-img :src="photo.url">
<div class="absolute-top bg-transparent text-right">
<q-btn icon="close" round color="primary" @click="openPhoto = false" />
</div>
</q-img>
</q-dialog>
</template>
-109
View File
@@ -1,109 +0,0 @@
<script setup>
import {computed, onMounted, ref} from "vue";
import {useQuasar} from "quasar";
import {confirmObject} from "src/composables/dialog";
const props = defineProps({
photos: {
type: Array
},
showActions: {
type: Boolean,
required: false,
default: true
}
});
const batchEdit = ref({});
onMounted(() => {
for (const photo of props.photos) {
batchEdit.value[photo.id] = false;
}
});
const emits = defineEmits(["edit", "delete", "batch-edit"])
const openEdit = (photo) => {
emits("edit", photo);
};
const $q = useQuasar();
const confirmPhotoDelete = (id) => {
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
.onOk(() => {
emits("delete", id);
});
}
const confirmBatchPhotoDelete = () => {
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?" + JSON.stringify(batchEdit.value)))
.onOk(() => {
for (const id in batchEdit.value) {
if (!batchEdit.value[id]) continue;
emits("delete", parseInt(id));
batchEdit.value[id] = false;
}
});
}
const handleImageClick = (photo, idx) => {
if (props.showActions) {
batchEdit.value[photo.id] = !batchEdit.value[photo.id];
}
}
const openBatchEdit = () => {
const photos = [];
for (const i in batchEdit.value) {
const photo = props.photos.find(p => p.id === parseInt(i));
if (photo && batchEdit.value[i]) photos.push(photo);
batchEdit.value[i] = false;
}
emits('batch-edit', photos);
}
const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0);
</script>
<template>
<div class="row q-my-md">
<div class="col text-right">
<q-btn round flat icon="edit" :color="isBatchEditAllowed ? 'primary' : 'grey'" :disable="!isBatchEditAllowed" @click="openBatchEdit" v-if="showActions"/>
<q-btn round flat icon="delete" :color="isBatchEditAllowed ? 'red' : 'grey'" :disable="!isBatchEditAllowed" @click="confirmBatchPhotoDelete" v-if="showActions"/>
</div>
</div>
<div class="row">
<div class="col-12 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id">
<q-card :class='{"q-ma-sm": true, "shadow-1": !batchEdit[photo.id], "shadow-10": batchEdit[photo.id]}'>
<q-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)">
<div class="absolute-bottom" v-if="photo.name !== '' || photo.description !== ''">
<div class="text-h6" v-if="photo.name !== ''">
{{ photo.name }}
</div>
<div class="text-subtitle2" v-if="photo.description !== ''">
{{ photo.description }}
</div>
</div>
</q-img>
<q-card-actions v-if="showActions" align="right" :class="{'bg-primary': batchEdit[photo.id], 'text-white': batchEdit[photo.id]}">
<div class="col-grow">
<q-checkbox v-model="batchEdit[photo.id]"/>
<q-icon name="star" v-if="photo.isFlightCover" color="red"/>
<q-icon name="place" v-if="photo.pointOfInterestId"/>
<q-icon name="gps_fixed" v-if="photo.gpsLatitude && photo.gpsLongitude"/>
<q-icon name="gps_off" v-else/>
</div>
<q-btn flat icon="edit" color="blue" @click="openEdit(photo)"></q-btn>
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"></q-btn>
</q-card-actions>
</q-card>
</div>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup>
import {useRoute, useRouter} from "vue-router";
import {usePhotosStore} from "stores/Photos";
const props = defineProps({
photo: {
type: Object,
required: true
},
title: {
type: String,
required: false
}
});
const route = useRoute();
const $router = useRouter();
const openGallery = (photo) => {
usePhotosStore().setOpenedPhoto(photo);
}
</script>
<template>
<q-img :src="photo.url" fit="cover" :ratio="4/3" @click="openGallery(photo)" class="cursor-pointer" v-if="photo">
<div class="absolute-bottom custom-caption" v-if="title && title !== ''">
<div class="text-h5">
{{ title }}
</div>
</div>
</q-img>
</template>
@@ -0,0 +1,21 @@
<script setup>
</script>
<template>
<q-card style="max-width: 750px; margin: 0 auto">
<q-skeleton square height="500px" width="100%"/>
<q-list>
<q-item v-for="i in 3" :key="i">
<q-item-section avatar>
<q-skeleton type="circle" width="60" />
</q-item-section>
<q-item-section>
<q-skeleton type="QSlider" />
</q-item-section>
</q-item>
</q-list>
</q-card>
</template>
@@ -0,0 +1,7 @@
<script setup>
</script>
<template>
gallery skeleton
</template>
+1 -1
View File
@@ -24,7 +24,7 @@ const props = defineProps({
const formData = reactive({
name: null,
description: null,
description: "",
gpsLatitude: null,
gpsLongitude: null,
type: null,
+4 -1
View File
@@ -10,7 +10,10 @@ const props = defineProps({
<q-list bordered separator>
<q-item clickable v-ripple v-for="poi of items" :key="poi" :to="{name: 'poiDetail', params: {id: poi.id}}">
<q-item-section avatar>
<q-avatar :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
<q-avatar v-if="poi.titlePhoto">
<img :src="poi.titlePhoto.thumbnailUrl" alt=""/>
</q-avatar>
<q-avatar v-else :icon="poi.type?.icon || 'place'" color="primary" text-color="white"/>
</q-item-section>
<q-item-section>
{{ poi.name }}
@@ -6,12 +6,12 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
<template>
<div>
<BasicTextSkeleton :lines="randomInt(4, 8)" />
<BasicTextSkeleton :lines="4" />
<q-skeleton type="rect" width="100%" class="q-my-lg" height="400px"/>
<div class="row">
<div class="col-12 col-sm-4 col-md-4 col-lg-3" v-for="i in randomInt(3, 10)" :key="i">
<div class="col-12 col-sm-4 col-md-4 col-lg-3" v-for="i in 6" :key="i">
<q-card class="q-my-sm q-mx-sm">
<q-skeleton type="rect" width="100%" height="200px"/>
</q-card>
@@ -0,0 +1,8 @@
<script setup>
</script>
<template>
<q-skeleton type="QInput" class="q-my-lg"/>
<q-skeleton type="rect" class="q-my-lg" height="400px"/>
<q-skeleton type="rect" class="q-my-lg" height="200px"/>
</template>
+8 -2
View File
@@ -1,7 +1,13 @@
<script setup>
import {user as pilot} from "src/composables/objects/user";
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
const props = defineProps({
pilot: {
type: Object,
required: true
}
});
</script>
<template>
+7 -4
View File
@@ -3,8 +3,7 @@ import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs";
import {reactive, watch} from "vue";
import {setDefaultData} from "src/composables/form/formHandling";
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
import {isRequestPending} from "src/composables/form/props";
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
const props = defineProps({
user: {
@@ -12,7 +11,11 @@ const props = defineProps({
required: false,
default: null
},
isRequestPending
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
});
const formData = reactive({
avatarImage: null,
@@ -29,7 +32,7 @@ watch(() => props.user, (newVal) => {
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<div class="text-center">
<AvatarFromPhoto :size="200" :src="user.avatarImageUrl" v-if="user.avatarImageUrl" />
<AvatarFromPhoto :size="200" :src="user.avatarImageUrl" v-if="user.avatarImageUrl"/>
</div>
<q-file
+5 -2
View File
@@ -6,7 +6,6 @@ import {commonProps} from "src/composables/inputs";
import RichTextEditor from "components/inputs/RichTextEditor.vue";
import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling";
import {isRequestPending} from "src/composables/form/props";
const props = defineProps({
user: {
@@ -14,7 +13,11 @@ const props = defineProps({
required: false,
default: null
},
isRequestPending,
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
@@ -1,3 +0,0 @@
<script setup> </script>
<template>Skeleton</template>
@@ -0,0 +1,23 @@
<script setup> import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
import {randomInt} from "src/composables/random";
</script>
<template>
<q-card flat style="margin: 0 auto; max-width: 400px">
<q-skeleton type="rect" height="300px"/>
<q-card-section>
<div style="width: 200px; text-align: center; margin-top: -100px; margin-left: auto; margin-right: auto">
<q-skeleton class="inline-block" type="QAvatar" size="150px"/>
<q-skeleton class="inline-block" type="text" height="50px" :width="`${randomInt(50, 80)}%`"/>
<q-skeleton class="inline-block" type="text" height="40px" :width="`${randomInt(60, 90)}%`"/>
</div>
<div class="q-mt-lg">
<BasicTextSkeleton :lines="3" :paragraphs="1"/>
</div>
</q-card-section>
</q-card>
</template>
+1 -1
View File
@@ -10,7 +10,7 @@ const props = defineProps({
})
const kmhToKnots = (speedKmh) => {
return Math.round(speedKmh / 0.53996);
return Math.round(speedKmh * 0.53996);
}
const openDetail = ref(false);
+3 -2
View File
@@ -3,6 +3,7 @@ import {date} from "quasar";
export const FORMAT_ISO = "YYYY-MM-DDTHH:mm:ss";
export const FORMAT_DATETIME_LONG = "DD. MM. YYYY, HH:mm";
export const FORMAT_DATE = "DD. MM. YYYY";
export const FORMAT_DATE_ISO = "YYYY-MM-DD";
export const FORMAT_TIME = "HH:mm"
export const minutesToHumanReadable = (totalMinutes) => {
@@ -45,9 +46,9 @@ export const parseDate = (datetimeStr, format = FORMAT_ISO) => {
return date.extractDate(datetimeStr, format)
}
export const formatDatetime = (datetime, format = FORMAT_DATETIME_LONG) => {
export const formatDatetime = (datetime, format = FORMAT_DATETIME_LONG, inputFormat = FORMAT_ISO) => {
if (typeof datetime === 'string') {
datetime = parseDate(datetime);
datetime = parseDate(datetime, inputFormat);
}
return date.formatDate(datetime, format);
}
+13
View File
@@ -0,0 +1,13 @@
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
export const flightName = (flight) => {
const date = formatDatetime(flight.takeoffDatetime, FORMAT_DATE);
if (flight.name !== '') {
return `${flight.name} (${date})`
} else {
const track = flight.track ? flight.track.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : [];
const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - ';
return `${flight.takeoffAirport.icaoCode} ${trackStr} ${flight.landingAirport.icaoCode} (${date})`;
}
}
+11
View File
@@ -1,3 +1,5 @@
import {successObject} from "src/composables/dialog";
export const setDefaultData = (formData, defaultData) => {
const ignoredFields = ['__typename'];
@@ -10,3 +12,12 @@ export const setDefaultData = (formData, defaultData) => {
formData[key] = defaultData[key];
}
}
export const toggleVisibility = ({store, $q, object}) => {
console.log(object);
const newVisibility = !object.isPublic;
store.update(object.id, {isPublic: newVisibility})
.then(() => {
$q.notify(successObject(newVisibility ? "Úspěšně publikováno" : "Úspěšně skryto"));
});
}
-10
View File
@@ -1,10 +0,0 @@
export const isRequestPending = {
type: Boolean,
required: false,
default: false,
};
export const defaultValues = {
type: Object,
required: false,
default: () => ({})
}
-1
View File
@@ -6,7 +6,6 @@ export const isValidEmail = (email) => {
export const minLength = (length) => (val) => val.length >= length || `Zadej alespoň ${length} znaky`
export const maxLength = (length) => (val) => val.length <= length || `Zadej maximálně ${length} znaků`
export const isEmptyArray = () => (val) => {
return (val && val.length !== 0) || "Select item, please";
};
+41
View File
@@ -0,0 +1,41 @@
const stripHtml = (str) => {
if (!str)
return '';
else
str = str.toString();
return str.replace(/<[^>]+>/ig, '');
}
export const commonKeywords = [
"zápisník", "létání", "ultralight", "letectví", "fotogalerie", "výlet", "vyhlídkový let", "navigační let"
];
export const metaData = ({title, description, keywords, image}) => {
const filteredKeywords = (keywords || []).filter(kw => !!kw);
const filteredDescription = stripHtml(description);
return {
title: title || "",
titleTemplate(title) {
return title !== '' ? `${title} - Polétání.cz` : 'Polétání.cz'
},
meta: {
description: {name: "description", content: filteredDescription || ""},
keywords: {name: "keywords", content: (filteredKeywords || commonKeywords).join(", ")},
ogImage: image ? {property: "og:image", content: image} : undefined,
ogTitle: {
property: "og:title",
content: title,
template(ogTitle) {
return (!ogTitle || ogTitle === '') ? "Polétání.cz" : `${ogTitle} - Polétání.cz`
}
},
ogDescription: {
property: "og:description", content: filteredDescription,
}
}
}
}
-25
View File
@@ -1,25 +0,0 @@
import {useAircraftsStore} from "stores/Aircrafts";
import {ref} from "vue";
import {storeToRefs} from "pinia";
export const loading = ref(true);
export const aircraft = ref(null);
export const fetchAircraft = (aircraftId) => {
loading.value = true;
return useAircraftsStore().fetchDetail(aircraftId).then((data) => {
aircraft.value = data;
}).catch(err => {
console.error(err);
}).finally(() => loading.value = false);
}
export const aircrafts = storeToRefs(useAircraftsStore()).items;
export const fetchAircrafts = () => {
loading.value = true;
useAircraftsStore().fetch().finally(() => loading.value = false);
}
export const isRequestPending = storeToRefs(useAircraftsStore).isMutationRunning;
-23
View File
@@ -1,23 +0,0 @@
import {ref} from "vue";
import {useCopilotsStore} from "stores/Copilots";
import {storeToRefs} from "pinia";
export const loading = ref(true);
export const copilot = ref(null);
export const fetchCopilot = (copilotId) => {
loading.value = true;
return useCopilotsStore().fetchDetail(parseInt(copilotId))
.then(resp => {
copilot.value = resp;
return resp;
})
.finally(() => loading.value = false);
}
export const copilots = storeToRefs(useCopilotsStore()).items;
export const fetchCopilots = () => {
loading.value = true;
return useCopilotsStore().fetch().finally(() => loading.value = false);
}
export const isRequestPending = storeToRefs(useCopilotsStore()).isMutationRunning;
-50
View File
@@ -1,50 +0,0 @@
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
import {useFlightsStore} from "stores/Flights";
import {ref} from "vue";
import {storeToRefs} from "pinia";
import {confirmObject} from "src/composables/dialog";
import {useAppStore} from "stores/App";
export const flightName = (flight) => {
const date = formatDatetime(flight.takeoffDatetime, FORMAT_DATE);
if (flight.name !== '') {
return `${flight.name} (${date})`
} else {
const track = flight.track ? flight.track.map(t => t.pointOfInterest.name) : [];
const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - ';
return `${flight.takeoffAirport.icaoCode} ${trackStr} ${flight.landingAirport.icaoCode} (${date})`;
}
}
export const loading = ref(true);
export const flight = ref(null);
export const fetchFlight = (flightId, username) => {
loading.value = true;
return useFlightsStore().fetchDetail(parseInt(flightId), username)
.then(respFlight => {
flight.value = respFlight;
return respFlight;
})
.finally(() => loading.value = false);
};
export const flights = storeToRefs(useFlightsStore()).items;
export const fetchFlights = (username = null) => {
loading.value = true;
useFlightsStore().fetch(username).finally(() => {
loading.value = false;
});
};
export const handleDeleteFlight = ($q, flightId, onOkCallback = () => {
}) => {
$q.dialog(confirmObject(`Opravdu chceš smazat tento let?`))
.onOk(() => {
useFlightsStore().delete(flightId).then(onOkCallback);
});
};
export const isRequestPending = storeToRefs(useFlightsStore()).isRequestPending;
-22
View File
@@ -1,22 +0,0 @@
import {ref} from "vue";
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
import {storeToRefs} from "pinia";
export const loading = ref(true);
export const poi = ref(null);
export const fetchPoi = (poiId) => {
loading.value = true;
return usePointsOfInterestStore().fetchDetail(poiId)
.then(data => {
poi.value = data;
return data;
})
.finally(() => loading.value = false);
};
export const pointsOfInterest = storeToRefs(usePointsOfInterestStore()).items;
export const fetchPOIList = () => {
loading.value = true;
return usePointsOfInterestStore().fetch().finally(() => loading.value = false);
}
-14
View File
@@ -1,14 +0,0 @@
import {ref} from "vue";
import {useUserStore} from "stores/User";
export const loading = ref(true);
export const user = ref(null);
export const fetchUser = (username) => {
loading.value = true;
return useUserStore().fetchDetail(username)
.then(respUser => {
user.value = respUser;
return respUser;
})
.finally(() => loading.value = false);
};
-30
View File
@@ -1,30 +0,0 @@
import {ref} from "vue";
import {usePhotosStore} from "stores/Photo";
import {fetchFlight} from "src/composables/objects/flight";
export const openBatchEdit = ref(false);
export const editBatchPhotos = ref([]);
export const openBatchPhotoForm = (photos) => {
openBatchEdit.value = true;
editBatchPhotos.value = photos;
}
export const handleBatchPhotoEdit = (flightId, data) => {
const photoIds = data.photoIds;
delete data.photoIds;
delete data.id;
const promises = [];
for (const id of photoIds) {
const updateData = {};
if (data.name !== '') updateData.name = data.name;
if (data.description !== '') updateData.description = data.description;
if (data.pointOfInterest) updateData.pointOfInterest = data.pointOfInterest;
if (Object.keys(updateData).length === 0) continue;
promises.push(usePhotosStore().update(id, updateData));
}
Promise.all(promises).finally(() => fetchFlight(flightId))
}
+57
View File
@@ -0,0 +1,57 @@
import {computed, ref, watch} from "vue";
import {cropper} from "vue-picture-cropper";
import {roundWithPrecision} from "src/composables/utils";
export const photoAspectRatio = ref(0);
export const cropperImgSizes = computed(() => {
if (!cropper) return [];
return [cropper.getImageData().width, cropper.getImageData().height]
});
export const croppedArea = ref(null);
export const selectedAspectRatio = ref(null);
watch(() => selectedAspectRatio.value, (newVal) => {
cropper.setAspectRatio(newVal);
})
export const setCropData = () => {
const [w, h] = cropperImgSizes.value;
const cropData = cropper.getCropBoxData();
croppedArea.value = {
left: roundWithPrecision(cropData.left / w, 5),
top: roundWithPrecision(cropData.top / h, 5),
width: roundWithPrecision(cropData.width / w, 5),
height: roundWithPrecision(cropData.height / h, 5)
};
}
export const aspectRatioOptions = computed(() => [
{label: '16:9', value: roundWithPrecision(16 / 9, 5)},
{label: '4:3', value: roundWithPrecision(4 / 3, 5)},
{label: '9:16', value: roundWithPrecision(9 / 16, 5)},
{label: '3:4', value: roundWithPrecision(3 / 4, 5)},
{label: '1:1', value: 1},
{label: 'Volný', value: null},
{label: 'Původní', value: roundWithPrecision(photoAspectRatio.value, 5)},
]
);
export const cropperReady = (e) => {
if (croppedArea.value && croppedArea.value.height) {
const [w, h] = cropperImgSizes.value;
selectedAspectRatio.value = roundWithPrecision((croppedArea.value.width * w) / (croppedArea.value.height * h), 5);
cropper.setCropBoxData({
left: croppedArea.value.left * w,
top: croppedArea.value.top * h,
width: croppedArea.value.width * w,
height: croppedArea.value.height * h
});
} else {
selectedAspectRatio.value = roundWithPrecision(photoAspectRatio.value, 5);
}
}
+18
View File
@@ -0,0 +1,18 @@
import {computed} from "vue";
import {croppedArea} from "src/composables/photos/editor/crop";
export const getPreviewUrl = (photoId, adjustments) => {
const url = new URL(`${process.env.API_URL}/photo/editor-preview/${photoId}`);
url.search = new URLSearchParams(adjustments).toString();
return url.toString();
};
export const croppedAreaForPreview = computed(() => {
if (!croppedArea.value) return {};
return {
crop_left: croppedArea.value.left || 0,
crop_top: croppedArea.value.top || 0,
crop_width: croppedArea.value.width || 1,
crop_height: croppedArea.value.height || 1
}
});

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