55 Commits
Author SHA1 Message Date
Michal Kváček cda07239ef Zakladni nastrel sitemapy 2024-02-21 06:31:32 +01:00
Michal Kváček 1a5c8f09bf Napoveda, fix filtru pro nacitani letu u objektu 2024-02-13 23:00:37 +01:00
Michal Kváček 9c9bbc9baa Reset hesla 2024-02-08 16:25:41 +01:00
Michal Kváček fe47c1f7d6 Neindexovat 404 2024-02-02 09:34:03 +01:00
Michal Kváček b22773b2ae Snad fix 404ky 2024-02-01 17:32:12 +01:00
Michal Kváček d69ceac14b Vyhledavani i ve verejnych POI 2024-02-01 14:48:05 +01:00
Michal Kváček 418469d81c Vyhledavani v POI 2024-02-01 08:32:27 +01:00
Michal Kváček c28feade90 Drobna uprava refreshe loginu 2024-02-01 07:21:03 +01:00
Michal Kváček 5b94f2c4b3 Kontaktni form 2024-02-01 07:20:54 +01:00
Michal Kváček 08a3cbf3d9 Avatar do admina, pridani debrief informaci 2024-01-20 22:08:49 +01:00
Michal Kváček b0aa6b5628 Uprava setovani 404 (asi porad nefunguje), ruzne podminky, priprava na zapomenute heslo, sjednoceni nacitani letu v profilu 2024-01-20 15:40:31 +01:00
Michal Kváček 0c89884b23 Uprava publikovani objektu, bugfixing 2024-01-18 06:52:39 +01:00
Michal Kváček 5a5c7cb645 Slugy, webp, hromada refaktoringu 2024-01-13 23:29:51 +01:00
Michal Kváček e7cde2f425 Zmena favicon, uprava HP 2024-01-02 11:30:53 +01:00
Michal Kváček 966d582b63 Uprava error stranek, indexu 2023-12-19 20:34:06 +01:00
Michal Kváček 9aebc352cd Fix nacitani letu u eventu 2023-12-12 23:54:19 +01:00
Michal Kváček c8ea17c1ec Aktualizace balicku, strnakovani, uprava hlavni stranky 2023-12-12 23:42:16 +01:00
Michal Kváček 8c7e237053 Verejny seznam POI 2023-12-10 23:21:33 +01:00
Michal Kváček 3216de4cb7 Bugfixing 2023-12-10 23:11:11 +01:00
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
241 changed files with 8903 additions and 8869 deletions
+22 -12
View File
@@ -1,24 +1,34 @@
# develop stage # develop stage
FROM node:20-alpine as develop-stage FROM node:20-alpine as develop-stage
ENV TZ=Europe/Prague
WORKDIR /app WORKDIR /app
COPY package*.json ./ RUN yarn global add @quasar/cli
RUN yarn global add @quasar/cli && yarn --production=false
COPY package.json yarn.lock ./
RUN yarn --production=false
RUN npx update-browserslist-db@latest
COPY . . COPY . .
# build stage # build stage
FROM develop-stage as build-stage FROM develop-stage as build-stage
ENV NODE_ENV production
#ARG API_URL RUN quasar build -m ssr
#ENV VUE_API_URL $API_URL
RUN yarn --production=false FROM node:20-alpine as production-stage
RUN quasar build
# production stage ENV NODE_ENV production
FROM nginx:1.25-alpine as production-stage ENV TZ=Europe/Prague
COPY conf/nginx.conf /etc/nginx/nginx.conf
COPY --from=build-stage /app/dist/spa /app/html
EXPOSE 80
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"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+9 -3
View File
@@ -24,9 +24,7 @@ http {
#tcp_nopush on; #tcp_nopush on;
keepalive_timeout 65; keepalive_timeout 65;
gzip on;
#gzip on;
include /etc/nginx/conf.d/*.conf; include /etc/nginx/conf.d/*.conf;
map $sent_http_content_type $expires { map $sent_http_content_type $expires {
@@ -34,6 +32,10 @@ http {
text/html 0; text/html 0;
} }
map $host $api_url {
default "$API_URL";
}
server { server {
listen 80 default; listen 80 default;
server_name poletani.cz; server_name poletani.cz;
@@ -46,5 +48,9 @@ http {
expires $expires; expires $expires;
try_files $uri /index.html; try_files $uri /index.html;
} }
location /sitemap.xml {
try_files $uri $api_url;
}
} }
} }
+9 -2
View File
@@ -1,7 +1,14 @@
version: "3.3" version: "3.4"
services: services:
poletani-app: poletani-app:
image: "docker.kvacek.cz/poletani/app:latest" image: "docker.kvacek.cz/poletani/app:latest"
# build:
# context: .
# dockerfile: Dockerfile
# target: "production-stage"
restart: always restart: always
ports: ports:
- 9000:80 # - 9000:80
- 9000:3000
environment:
API_URL: "https://api.poletani.cz"
+6 -3
View File
@@ -24,18 +24,21 @@ services:
## command: ls -l /app ## command: ls -l /app
app: app:
network_mode: host
build: build:
context: . context: .
target: "develop-stage" target: "develop-stage"
ports: ports:
- "9000:9000" - "9000:9000"
- "9001:9100"
volumes: volumes:
- ./src:/app/src - ./src:/app/src
- ./src-ssr:/app/src-ssr
- ./public:/app/public - ./public:/app/public
- ./node_modules:/app/node_modules:delegated # - ./node_modules:/app/node_modules:delegated
command: quasar dev command: quasar dev # -m ssr
environment: environment:
API_URL: "http://localhost:8000" API_URL: "http://127.0.0.1:8000"
# nginx: # nginx:
# image: # image:
-3
View File
@@ -1,10 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title><%= productName %></title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="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<% } %>"> <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
+15 -14
View File
@@ -1,7 +1,7 @@
{ {
"name": "app", "name": "app",
"version": "0.0.1", "version": "0.0.1",
"description": "A Quasar Project", "description": "Fotogalerie pro tvoje zážitky ze vzduchu",
"productName": "Polétání.cz", "productName": "Polétání.cz",
"author": "Michal Kváček <michal@kvacek.cz>", "author": "Michal Kváček <michal@kvacek.cz>",
"private": true, "private": true,
@@ -15,27 +15,28 @@
"@vue/apollo-composable": "4.0.0-beta.2", "@vue/apollo-composable": "4.0.0-beta.2",
"@vue/apollo-option": "^4.0.0-beta.5", "@vue/apollo-option": "^4.0.0-beta.5",
"apollo-upload-client": "^17.0.0", "apollo-upload-client": "^17.0.0",
"axios": "^1.4.0", "axios": "^1.6.2",
"chart.js": "^4.4.0", "chart.js": "^4.4.1",
"graphql": "16.6.0", "graphql": "16.6.0",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"photoswipe": "^5.4.2",
"photoswipe-dynamic-caption-plugin": "^1.2.7",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"quasar": "^2.12.0", "quasar": "^2.14.1",
"vue": "^3.2.47", "vue": "^3.3.11",
"vue-chartjs": "^5.2.0", "vue-chartjs": "^5.2.0",
"vue-inner-image-zoom": "^2.0.0", "vue-picture-cropper": "^0.7.0",
"vue-router": "^4.1.6", "vue-router": "^4.2.5",
"vue3-google-map": "^0.15.0" "vue3-google-map": "^0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@quasar/app-vite": "^1.3.0", "@quasar/app-vite": "^1.7.1",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.16",
"eslint": "^8.10.0", "eslint": "^8.55.0",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.10.0",
"eslint-plugin-vue": "^9.0.0", "eslint-plugin-vue": "^9.19.2",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"prettier": "^2.5.1", "prettier": "^2.8.8"
"vite-plugin-rewrite-all": "^1.0.1"
}, },
"engines": { "engines": {
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
+5
View File
@@ -0,0 +1,5 @@
apps:
- name: poletani
script: /app/index.js
exec_mode: cluster
instances: 3
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Disallow: /login/
Disallow: /moje/
+14 -12
View File
@@ -23,15 +23,16 @@ module.exports = configure(function (ctx) {
}, },
// https://v2.quasar.dev/quasar-cli/prefetch-feature // https://v2.quasar.dev/quasar-cli/prefetch-feature
// preFetch: true, preFetch: true,
// app boot file (/poletani/boot) // app boot file (/poletani/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files // https://v2.quasar.dev/quasar-cli/boot-files
boot: [ boot: [
"cookies",
"axios", "axios",
"vueInnerImageZoom", "apollo",
"chartJs", "chartJs"
], ],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
@@ -56,7 +57,7 @@ module.exports = configure(function (ctx) {
build: { build: {
target: { target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'], browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node18' node: 'node20'
}, },
env: { env: {
@@ -76,17 +77,14 @@ module.exports = configure(function (ctx) {
// env: {}, // env: {},
// rawDefine: {} // rawDefine: {}
// ignorePublicFolder: true, // ignorePublicFolder: true,
// minify: false, minify: true,
// polyfillModulePreload: true, // polyfillModulePreload: true,
// distDir // distDir
// extendViteConf (viteConf) {}, // extendViteConf (viteConf) {},
// viteVuePluginOptions: {}, // viteVuePluginOptions: {},
// vitePlugins: []
vitePlugins: [
[ require("vite-plugin-rewrite-all").default ] // slouzi k podpore tecky v params v routeru
]
}, },
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer // 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 // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: { framework: {
config: {}, config: {},
cssAddon: true,
iconSet: 'mdi-v6', // Quasar icon set iconSet: 'mdi-v6', // Quasar icon set
// lang: 'cs-CZ', // Quasar language pack // lang: 'cs-CZ', // Quasar language pack
@@ -111,6 +110,7 @@ module.exports = configure(function (ctx) {
// Quasar plugins // Quasar plugins
plugins: [ plugins: [
'Cookies',
'Dialog', 'Dialog',
'Meta', 'Meta',
'Notify' 'Notify'
@@ -119,7 +119,9 @@ module.exports = configure(function (ctx) {
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations // https://v2.quasar.dev/options/animations
animations: [], animations: [
"fadeIn", "fadeOut"
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
sourceFiles: { sourceFiles: {
@@ -135,10 +137,10 @@ module.exports = configure(function (ctx) {
// https://v2.quasar.dev/quasar-cli/developing-ssr/configuring-ssr // https://v2.quasar.dev/quasar-cli/developing-ssr/configuring-ssr
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 // will mess up SSR
// extendSSRWebserverConf (esbuildConf) {}, extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {}, // extendPackageJson (json) {},
pwa: false, pwa: false,
+58
View File
@@ -0,0 +1,58 @@
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
console.log("404 v renderu")
// Should reach here only if no "catch-all" route
// is defined in /src/routes
// res.status(404).send('404 | Page Not Found')
res.redirect(resolve.urlPath('404'))
} 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.redirect(resolve.urlPath('500'))
}
})
})
})
+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 -2
View File
@@ -1,13 +1,12 @@
<script setup> <script setup>
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import {computed} from "vue"; import {computed} from "vue";
import ErrorNotFound from "pages/ErrorNotFound.vue"; import ErrorNotFound from "pages/errors/ErrorNotFound.vue";
import {storeToRefs} from "pinia"; import {storeToRefs} from "pinia";
import {useAppStore} from "stores/App"; import {useAppStore} from "stores/App";
const route = useRoute(); const route = useRoute();
const layoutComponent = computed(() => { const layoutComponent = computed(() => {
if (!route.meta) return null; if (!route.meta) return null;
return route.meta.layout; return route.meta.layout;
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

+105
View File
@@ -0,0 +1,105 @@
import {ApolloClient, ApolloLink, fromPromise, InMemoryCache} from '@apollo/client/core'
import {onError} from "@apollo/client/link/error";
import {useAppStore} from "stores/App";
import {Notify} from "quasar";
import {errorObject, warningObject} from "src/composables/dialog";
import {createUploadLink} from "apollo-upload-client";
import {useAuthStore} from "stores/Auth";
import {useRouter} from "vue-router";
// 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(useAuthStore(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 = useAuthStore(store).getAccessToken();
if (accessToken) {
operation.setContext({
headers: {"Authorization": `Bearer ${accessToken}`},
});
}
return forward(operation);
});
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
if (networkError?.statusCode === 401) {
return handleRefreshToken(store, router, forward, operation);
}
if (graphQLErrors) {
for (const error of graphQLErrors) {
if (error.message.includes("401") || error.message.includes("Not authorized")) {
return handleRefreshToken(store, router, forward, operation);
} else if (error.message.includes("404") || error.message.includes("Not found")) {
useAppStore(store).appStatus = 404;
} else {
Notify.create(errorObject("API error: " + error.message));
}
}
}
});
const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, uploadLink]), cache: new InMemoryCache(), connectToDevTools: true,
});
apolloWrapper.query = (query, variables = {}) => {
return apolloClient.query({
query: query,
fetchPolicy: 'no-cache',
variables: variables
})
.then((response) => response)
.catch(err => {
const errStr = err.toString();
if (errStr.includes('Not found') || errStr.includes("No row was found")) {
useAppStore(store).appStatus = 404;
return Promise.resolve();
}
else if (!errStr.includes("401") && !errStr.includes("Not authorized")) {
console.error("Error during query: ", err)
}
return Promise.reject(err);
})
};
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);
})
};
});
+33 -44
View File
@@ -1,57 +1,46 @@
import {boot} from 'quasar/wrappers' import {boot} from 'quasar/wrappers'
import axios from 'axios' import axios from 'axios'
import {useUserStore} from "stores/User"; import {useAuthStore} from "stores/Auth";
const api = axios.create({ const api = axios.create({
baseURL: process.env.API_URL, baseURL: process.env.API_URL,
withCredentials: true, withCredentials: true,
xsrfHeaderName: "X-CSRF-TOKEN", xsrfHeaderName: "X-CSRF-TOKEN",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json;charset=UTF-8", "Content-Type": "application/json;charset=UTF-8",
}, },
}) })
api.interceptors.request.use((config) => { export default boot(({store}) => {
const token = useUserStore().getAccessToken(); api.interceptors.request.use((config) => {
if (token) { const token = useAuthStore(store).getAccessToken();
config.headers['Authorization'] = `Bearer ${token}` if (token) {
} config.headers['Authorization'] = `Bearer ${token}`
}
if (['/refresh', '/login'].includes(config.url)) { if (['/refresh', '/login'].includes(config.url)) {
delete config.headers['Authorization']; delete config.headers['Authorization'];
} }
return config; return config;
})
// Response interceptor for API calls
api.interceptors.response.use((response) => {
return response
}, async function (error) {
const originalRequest = error.config;
if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
const newToken = await useUserStore().refreshAccessToken().catch(err => {
console.error(err);
return Promise.reject("Logged out");
}) })
api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
return api(originalRequest);
}
return Promise.reject(error);
});
// Response interceptor for API calls
export default boot(({app}) => { api.interceptors.response.use(
// for use inside Vue files (Options API) through this.$axios and this.$api (response) => response,
async function (error) {
// app.config.globalProperties.$axios = axios const originalRequest = error.config;
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form) if (error.response.status === 401 && !['/refresh', '/login'].includes(originalRequest.url)) {
// so you won't necessarily have to import axios in each vue file const newToken = await useAuthStore(store).refreshAccessToken().catch(err => {
return Promise.reject("Logged out");
app.config.globalProperties.$api = api })
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form) api.defaults.headers.common['Authorization'] = 'Bearer ' + newToken;
// so you can easily perform requests against your app's API return api(originalRequest);
}
return Promise.reject(error);
});
}) })
export {axios, 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"; import {BarElement, CategoryScale, Chart as ChartJS, Filler, Legend, LinearScale, LineElement, PointElement, Title, Tooltip} from "chart.js";
export default boot(({app}) => { 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);
})
+69
View File
@@ -0,0 +1,69 @@
<script setup>
import {ref} from "vue";
const date = new Date();
const year = ref(date.getFullYear());
</script>
<template>
<q-footer class="q-pa-lg q-mt-xl">
<div class="row justify-center">
<div class="col-xs-12 col-md-4 text-center q-py-lg" style="font-size: 130%">
<q-img fit="contain" width="32px" src="/icons/favicon-32x32.png"/>
<div class="text-h5">Polétání.cz</div>
Vytvořeno s <q-icon name="favorite" color="red"/> nejen pro létání.
</div>
<div class="col-xs-6 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'about'}">O projektu</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'contact'}">Kontakt</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'features'}">Funkce</router-link>
</q-item>
</q-list>
</div>
<div class="col-xs-6 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'login'}">Login</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'registration'}">Registrace</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'forgottenPassword'}">Zapomenuté heslo</router-link>
</q-item>
</q-list>
</div>
<div class="col-xs-12 col-md-2">
<q-list>
<q-item>
<router-link :to="{name: 'termsAndConditions'}">Podmínky použití</router-link>
</q-item>
<q-item>
<router-link :to="{name: 'personalInformation'}">Správa osobních údajů</router-link>
</q-item>
</q-list>
</div>
</div>
<div class="row q-mt-lg">
<div class="col text-center">
&copy; {{ year }} Polétání.cz
</div>
</div>
</q-footer>
</template>
<style scoped>
a {
color: white;
font-size: 110%;
}
</style>
+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> <script setup>
import {isRequestPending} from "src/composables/form/props"; import FormSubmitButton from "components/FormSubmitButton.vue";
import {computed, useSlots} from "vue";
import {useQuasar} from "quasar";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
}, },
isRequestPending, isRequestPending: {
type: Boolean,
required: false,
default: false,
},
saveButton: { saveButton: {
type: [Object, Boolean], type: [Object, Boolean],
required: false, required: false,
@@ -23,38 +25,11 @@ const emits = defineEmits(["save"]);
const saveForm = () => { const saveForm = () => {
emits("save", props.modelValue); emits("save", props.modelValue);
} }
const $q = useQuasar();
const fullwidthBtn = computed(() => $q.screen.width <= $q.screen.sizes.md);
</script> </script>
<template> <template>
<q-form @submit.prevent="saveForm" ref="form"> <q-form @submit.prevent="saveForm" ref="form">
<slot name="default"/> <slot name="default"/>
<FormSubmitButton v-if="saveButton" :save-button="saveButton"/>
<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>
</q-form> </q-form>
</template> </template>
<style>
.btn--fullwidth {
width: 100%;
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup>
import {commonProps} from "src/composables/inputs";
import {onMounted, ref, watch} from "vue";
import {debounce} from "quasar";
import {useRoute, useRouter} from "vue-router";
const props = defineProps({
emitDebounce: {
type: Number,
required: false,
default: 200
}
})
const emits = defineEmits(["filtersChanged"]);
const filters = ref({
search: ""
});
const $route = useRoute();
const $router = useRouter();
watch((() => filters.value), debounce(() => {
emits("filtersChanged", filters.value);
const query = {...$route.query};
for (const key in filters.value) {
if (filters.value[key] === "" || filters.value['key'] === null) {
delete query[key];
} else {
query[key] = filters.value[key];
}
}
$router.replace({query});
}, props.emitDebounce), {deep: true});
onMounted(() => {
const search = $route.query.search;
if (search) {
filters.value.search = search;
}
})
</script>
<template>
<q-input v-model="filters.search" placeholder="Vyhledávání..." v-bind="commonProps" clearable>
<template #prepend>
<q-icon name="search"/>
</template>
</q-input>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
const props = defineProps({
totalItemsCount: {
type: Number,
required: true
},
itemsLength: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
}
})
const emits = defineEmits(["load-more"]);
const loadMore = () => {
emits("load-more", props.itemsPerPage);
}
</script>
<template>
<div>
<slot name="items"/>
<slot name="skeleton" v-if="isQueryRunning"/>
<div class="text-center q-my-md">
<q-btn color="primary" :loading="isQueryRunning" rounded size="lg" icon="expand_more" @click="loadMore()" v-if="totalItemsCount > itemsLength">
<slot name="loadMoreText"/>
</q-btn>
</div>
</div>
</template>
@@ -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>
+56 -35
View File
@@ -2,9 +2,9 @@
import {onMounted, reactive} from "vue"; import {onMounted, reactive} from "vue";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling"; 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 {isNumber, isRequired, maxLength, minLength} from "src/composables/form/rules";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
const formData = reactive({ const formData = reactive({
id: null, id: null,
@@ -13,13 +13,25 @@ const formData = reactive({
seats: null, seats: null,
manufacturer: "", manufacturer: "",
description: "", description: "",
photo: null, organization: null,
organizationId: null isPublic: false
}); });
const props = defineProps({ const props = defineProps({
defaultValues, defaultValues: {
isRequestPending type: Object,
required: false,
default: () => ({})
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
},
organizations: {
type: Array,
required: true,
}
}); });
const rules = { const rules = {
@@ -28,8 +40,12 @@ const rules = {
}; };
onMounted(() => { onMounted(() => {
if (Object.keys(props.defaultValues).length > 0) if (Object.keys(props.defaultValues).length > 0) {
setDefaultData(formData, props.defaultValues); setDefaultData(formData, props.defaultValues);
if (props.defaultValues.organization) {
formData.organization = {id: props.defaultValues.organization.id, name: props.defaultValues.organization.name};
}
}
}) })
</script> </script>
@@ -38,46 +54,51 @@ onMounted(() => {
<q-img :src="props.defaultValues.photoUrl" v-if="props.defaultValues.photoUrl"/> <q-img :src="props.defaultValues.photoUrl" v-if="props.defaultValues.photoUrl"/>
<q-input <q-input
type="text" type="text"
:rules="rules.callSign" :rules="rules.callSign"
v-model="formData.callSign" v-model="formData.callSign"
label="Volací značka" label="Volací značka"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-input <q-input
type="text" type="number"
v-model="formData.manufacturer" v-model.number="formData.seats"
label="Výrobce" label="Počet míst (včetně pilota)"
v-bind="commonProps" min="1"
:rules="rules.seats"
v-bind="commonProps"
/> />
<q-input
type="text" <ComboboxInput
v-model="formData.model" :options="organizations"
label="Model" label="Organizace"
v-bind="commonProps" v-model="formData.organization"
v-bind="commonProps"
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
/> />
<q-input <q-input
type="number" type="text"
v-model.number="formData.seats" v-model="formData.manufacturer"
label="Počet míst (včetně pilota)" label="Výrobce"
min="1" v-bind="commonProps"
:rules="rules.seats" />
v-bind="commonProps" <q-input
type="text"
v-model="formData.model"
label="Model"
v-bind="commonProps"
/> />
<q-input <q-input
type="textarea" type="textarea"
v-model="formData.description" v-model="formData.description"
label="Popis" label="Popis"
v-bind="commonProps" v-bind="commonProps"
/> />
<q-file v-model="formData.photo" label="Fotografie" v-bind="commonProps"> <q-checkbox v-model="formData.isPublic" label="Veřejné?" v-bind="commonProps"/>
<template #prepend>
<q-icon name="upload"/>
</template>
</q-file>
</FormWrapper> </FormWrapper>
</template> </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>
+56 -30
View File
@@ -1,42 +1,68 @@
<script setup> <script setup>
import LoadMoreList from "components/LoadMoreList.vue";
import {useAppStore} from "stores/App"; import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: true required: true
},
totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean,
required: false,
default: false
} }
}); });
useAppStore().setPageTitle("Letadla");
</script> </script>
<template> <template>
<q-list bordered separator> <LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<q-item <template #items>
clickable <q-list bordered separator>
v-ripple <q-item
:to="{name: 'aircraftDetail', params: {id: aircraft.id }}" clickable
v-for="aircraft of props.items" :key="aircraft" v-ripple
> :to="{name: 'aircraftDetail', params: {id: aircraft.id }}"
<q-item-section avatar> v-for="aircraft of props.items" :key="aircraft"
<q-avatar v-if="aircraft.photoUrl"> >
<img :src="aircraft.photoUrl" alt=""/> <q-item-section avatar>
</q-avatar> <q-avatar v-if="aircraft.titlePhoto">
<q-avatar color="teal" text-color="white" icon="flight" v-else/> <img :src="aircraft.titlePhoto.thumbnailUrl" alt=""/>
</q-item-section> </q-avatar>
<q-item-section> <q-avatar color="teal" text-color="white" icon="flight" v-else/>
<q-item-label label> </q-item-section>
{{ aircraft.callSign }} <q-item-section>
</q-item-label> <q-item-label label>
<q-item-label caption> {{ aircraft.callSign }}
{{ aircraft.model }} </q-item-label>
<template v-if="aircraft.manufacturer"> <q-item-label caption>
({{ aircraft.manufacturer }}) {{ aircraft.model }}
</template> <template v-if="aircraft.manufacturer">
</q-item-label> ({{ aircraft.manufacturer }})
</q-item-section> </template>
</q-item> </q-item-label>
</q-list> <q-item-label caption v-if="aircraft.organization">
Provozovatel: {{ aircraft.organization.name }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
<template #skeleton>
<BasicListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Další
</template>
</LoadMoreList>
</template> </template>
@@ -13,11 +13,11 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</q-card-section> </q-card-section>
<q-card-section class="q-my-md"> <q-card-section class="q-my-md">
<BasicTextSkeleton :paragraphs="1" :lines="randomInt(1, 3)"/> <BasicTextSkeleton :paragraphs="1" :lines="2"/>
</q-card-section> </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> </q-card>
</template> </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> <script setup>
import {poi} from "src/composables/objects/poi";
const props = defineProps({ const props = defineProps({
routeBack: { routeBack: {
type: Object, type: Object,
@@ -1,5 +1,6 @@
<script setup> <script setup>
import {randomInt} from "src/composables/random"; import {randomInt} from "src/composables/random";
import {computed} from "vue";
const props = defineProps({ const props = defineProps({
itemsCountMin: { itemsCountMin: {
@@ -11,14 +12,19 @@ const props = defineProps({
type: Number, type: Number,
required:false, required:false,
default: 5 default: 5
},
items: {
type: Number,
required: false,
} }
}) });
const renderedItems = computed(() => props.items || randomInt(props.itemsCountMin, props.itemsCountMax));
</script> </script>
<template> <template>
<q-list bordered separator> <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-item-section avatar>
<q-skeleton type="QAvatar" /> <q-skeleton type="QAvatar" />
</q-item-section> </q-item-section>
+28 -3
View File
@@ -4,24 +4,35 @@ import {onMounted, reactive} from "vue";
import {setDefaultData} from "src/composables/form/formHandling"; import {setDefaultData} from "src/composables/form/formHandling";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {isRequestPending} from "src/composables/form/props";
import {isRequired} from "src/composables/form/rules"; import {isRequired} from "src/composables/form/rules";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
copilot: { copilot: {
type: Object type: Object
}, },
isRequestPending isRequestPending: {
type: Boolean,
required: false,
default: false,
}
}) })
const formData = reactive({ const formData = reactive({
name: null, name: null,
urlSlug: "",
isPublic: false,
}); });
const rules = { const rules = {
name: [isRequired] name: [isRequired],
urlSlug: [],
} }
const loggedUser = storeToRefs(useLoggedUserStore()).user;
onMounted(() => { onMounted(() => {
if (props.copilot) { if (props.copilot) {
setDefaultData(formData, props.copilot); setDefaultData(formData, props.copilot);
@@ -38,5 +49,19 @@ onMounted(() => {
:rules="rules.name" :rules="rules.name"
v-model="formData.name" v-model="formData.name"
/> />
<template v-if="loggedUser?.publicUsername">
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser.publicUsername}/a/`"
:slug-source="formData.name"
:rules="rules.urlSlug"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
</FormWrapper> </FormWrapper>
</template> </template>
+10 -5
View File
@@ -11,14 +11,19 @@ const props = defineProps({
<template> <template>
<q-list bordered separator> <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-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>
<q-item-section> <q-item-section>
<router-link :to="{name: 'copilotDetail', params: {id: pilot.id }}"> {{ pilot.name }}
{{ pilot.name }}
</router-link>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </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> <script setup></script>
<template> <template>
<em>Copilot skeleton</em> <q-skeleton type="QInput" />
</template> </template>
+80
View File
@@ -0,0 +1,80 @@
<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";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({
event: {
type: Object
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
})
const formData = reactive({
name: "",
urlSlug: "",
isPublic: false,
description: "",
dateFrom: null,
dateTo: null,
});
const rules = {
name: [isRequired]
}
onMounted(() => {
if (props.event) {
setDefaultData(formData, props.event);
}
})
const loggedUser = storeToRefs(useLoggedUserStore()).user;
</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"
/>
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser?.publicUsername}/udalosti/`"
:slug-source="formData.name"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
<div class="row">
<div class="col q-pr-sm">
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateFrom" label="Od"/>
</div>
<div class="col q-pl-sm">
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateTo" label="Do"/>
</div>
</div>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
</FormWrapper>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup>
import AvatarsGroup from "components/avatars/AvatarsGroup.vue";
const props = defineProps({
items: {
type: Array,
required: true
},
routeDetail: {
type: Object,
required: true,
},
});
const getCoverPhotos = (item) => item.flights.filter(f => !!f.titlePhoto).map(f => f.titlePhoto.thumbnailUrl);
</script>
<template>
<q-list bordered separator>
<q-item
v-for="eventDetail of items" :key="eventDetail"
clickable
:to="{name: props.routeDetail.name, params: {...props.routeDetail.params, eventSlug: eventDetail.urlSlug, id: eventDetail.id}}"
v-ripple
>
<q-item-section>
<q-item-label>
<q-avatar icon="event" color="primary" text-color="white" v-if="!eventDetail.flights || eventDetail.flights.length === 0"/>
<AvatarsGroup :avatars="getCoverPhotos(eventDetail)" v-else/>
{{ eventDetail.name }}
</q-item-label>
<q-item-label lines="4" caption>
<article v-html="eventDetail.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>
+52 -10
View File
@@ -1,5 +1,4 @@
<script setup> <script setup>
import {flight} from "src/composables/objects/flight";
import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils"; import {FORMAT_DATE, FORMAT_TIME, formatDatetime, minutesToHumanReadable} from "src/composables/datetimeUtils";
import WeatherInfo from "components/weather/WeatherInfo.vue"; import WeatherInfo from "components/weather/WeatherInfo.vue";
@@ -8,10 +7,20 @@ const props = defineProps({
type: Object, type: Object,
required: true required: true
}, },
showCopilots: { copilotDetailRoute: {
type: Boolean, type: Object,
required: false, required: false,
default: false default: null
},
aircraftDetailRoute: {
type: Object,
required: false,
default: null
},
poiDetailRoute: {
type: Object,
required: false,
default: null
} }
}) })
</script> </script>
@@ -23,7 +32,15 @@ const props = defineProps({
<q-icon name="flight"/> <q-icon name="flight"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ flight.aircraft.callSign }} <router-link
:to="{...aircraftDetailRoute, params: {id: flight.aircraft.id, callSign: flight.aircraft.callSign}}"
v-if="flight.aircraft.isPublic"
>
{{ flight.aircraft.callSign }}
</router-link>
<template v-else>
{{ flight.aircraft.callSign }}
</template>
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -44,7 +61,9 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<div> <div>
<strong>{{flight.takeoffAirport.icaoCode}}</strong> ({{ flight.takeoffAirport.name }}), <template v-if="flight.takeoffAirport">
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}),
</template>
čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }} čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/> <WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/>
@@ -54,10 +73,27 @@ const props = defineProps({
<q-item v-for="poi in flight.track" :key="poi.id"> <q-item v-for="poi in flight.track" :key="poi.id">
<q-item-section avatar> <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>
<q-item-section> <q-item-section>
{{ poi.pointOfInterest.name }} <template v-if="poi.pointOfInterest">
<router-link
:to="{name: poiDetailRoute.name, params: {...poiDetailRoute.params, slug: poi.pointOfInterest.urlSlug, id: poi.pointOfInterest.id}}"
v-if="poi.pointOfInterest.isPublic"
>
{{ poi.pointOfInterest.name }}
</router-link>
<template v-else>{{ poi.pointOfInterest.name }}</template>
</template>
<span v-if="poi.airport">
<strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}),
<template v-if="poi.landingDuration > 0">
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }}
</template>
<template v-else>touch and go</template>
</span>
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -67,7 +103,9 @@ const props = defineProps({
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<div> <div>
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}), <template v-if="flight.landingAirport">
<strong>{{ flight.landingAirport.icaoCode }}</strong> ({{ flight.landingAirport.name }}),
</template>
čas přistání: {{ formatDatetime(flight.landingDatetime, FORMAT_TIME) }} čas přistání: {{ formatDatetime(flight.landingDatetime, FORMAT_TIME) }}
<WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/> <WeatherInfo class="q-mx-sm" v-if="flight.landingWeatherInfo" :weather="flight.landingWeatherInfo"/>
</div> </div>
@@ -91,9 +129,13 @@ const props = defineProps({
<q-icon name="people"/> <q-icon name="people"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<router-link :to="{name: 'copilotDetail', params: {id: copilot.id}}"> <router-link
v-if="copilot.isPublic"
:to="{name: copilotDetailRoute.name, params: {...copilotDetailRoute.params, copilotSlug: copilot.urlSlug, id: copilot.id}}"
>
{{ copilot.name }} {{ copilot.name }}
</router-link> </router-link>
<template v-else> {{ copilot.name }}</template>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </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>
+29 -50
View File
@@ -1,59 +1,37 @@
<script setup> <script setup>
import {flight} from "src/composables/objects/flight";
import MapWithMarkers from "components/map/MapWithMarkers.vue"; import MapWithMarkers from "components/map/MapWithMarkers.vue";
import {computed, ref} from "vue"; import {computed, reactive, ref} from "vue";
import {Line} from "vue-chartjs" import {Line} from "vue-chartjs"
const props = defineProps({ const props = defineProps({
flight: { flight: {
type: Object, type: Object,
required: true required: true
},
photos: {
type: Array,
required: false,
} }
}) })
const mapPhotos = computed(() => flight.value.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude)); const mapPhotos = computed(() => props.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
const poisWithGPS = computed(() => { const poisWithGPS = computed(() => props.flight.track.map(poi => poi.pointOfInterest || poi.airport));
return flight.value.track.filter(poi => poi.pointOfInterest.gpsLatitude && poi.pointOfInterest.gpsLongitude)
});
const pointsOfInterests = computed(() => { const mapMarkers = [props.flight.takeoffAirport].concat(poisWithGPS.value).concat([props.flight.landingAirport]);
const path = [{ const pointsOfInterests = computed(() => mapMarkers
label: flight.value.takeoffAirport.name, .filter(marker => marker && marker.gpsLatitude && marker.gpsLongitude)
position: .map(
{ marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
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 gpxTrack = props.flight.gpxTrack ? reactive(props.flight.gpxTrack) : null;
const displayMap = computed(() => { const displayMap = computed(() => {
return ( return (
!!gpxTrack || !!gpxTrack ||
mapPhotos.value.length > 0 || mapPhotos.value.length > 0 ||
poisWithGPS.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 currentPositionMarker = ref(null);
const drawCurrentPositionMarker = (e) => { 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) { if (res.length === 0) {
currentPositionMarker.value = null; currentPositionMarker.value = null;
return; return;
} }
const idx = res[0].index; const idx = res[0].index;
currentPositionMarker.value = flight.value.gpxTrack.coordinates[idx]; currentPositionMarker.value = props.flight.gpxTrack.coordinates[idx];
} }
const chartOptions = { const chartOptions = {
@@ -123,16 +101,17 @@ const chartOptions = {
</script> </script>
<template> <template>
<MapWithMarkers <q-no-ssr>
v-if="displayMap" <MapWithMarkers
style="width: 100%; height: 500px;" v-if="displayMap"
:gpx-track="flight.gpxTrack?.coordinates || []" style="width: 100%; height: 500px;"
:markers="pointsOfInterests" :gpx-track="flight.gpxTrack?.coordinates || []"
:highlight-position="currentPositionMarker" :markers="pointsOfInterests"
:photos="mapPhotos" :highlight-position="currentPositionMarker"
/> :photos="mapPhotos"
/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartSpeedData" :options="chartOptions"/>
<Line v-if="gpxTrack" :data="chartAltitudeData" :options="chartOptions"/>
</q-no-ssr>
</template> </template>
+97 -38
View File
@@ -1,59 +1,118 @@
<script setup> <script setup>
import {minutesToHumanReadable} from "src/composables/datetimeUtils"; import {minutesToHumanReadable} from "src/composables/datetimeUtils";
import {flightName} from "src/composables/objects/flight"; import {flightName} from "src/composables/flight";
import AvatarFromPhoto from "components/AvatarFromPhoto.vue"; import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
import FlightsListSkeleton from "components/flights/skeletons/FlightsListSkeleton.vue";
import {computed} from "vue";
import LoadMoreList from "components/LoadMoreList.vue";
const props = defineProps({ const props = defineProps({
items: { items: {
type: Array, type: Array,
required: true required: true
}, },
routeDetail: { public: {
type: Object, type: Boolean,
required: false, required: true
default: () => ({name: 'flightDetail', params: {}})
}, },
displayVisibilityInfo: { totalItemsCount: {
type: Number,
required: true
},
itemsPerPage: {
type: Number,
required: false,
default: 5,
},
isQueryRunning: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: false
} }
}); });
const routeDetail = (flight) => {
if (props.public)
return {name: "publicFlightDetail", params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}
else
return {name: "flightDetail", params: {id: flight.id}}
};
const pluralize = (count, singular, plural, pluralMore) => {
if (count === 1) return singular;
else if (count < 5) return plural;
else return pluralMore;
}
const loadMoreCount = computed(() => {
const remaining = props.totalItemsCount - props.items.length;
return Math.min(props.itemsPerPage, remaining);
});
</script> </script>
<template> <template>
<q-list bordered separator> <LoadMoreList :total-items-count="totalItemsCount" :items-length="items.length">
<q-item <template #items>
v-for="flight of items" :key="flight" <q-list bordered separator>
ripple <q-item
clickable v-for="flight of items" :key="flight"
:to="{name: routeDetail.name, params:{...routeDetail.params, id: flight.id}}" ripple
> clickable
<q-item-section avatar> :to="routeDetail(flight)"
<AvatarFromPhoto :size="120" v-if="flight.coverPhoto" :src="flight.coverPhoto.thumbnailUrl"/> >
</q-item-section> <q-item-section avatar>
<q-item-section> <AvatarFromPhoto :size="120" v-if="flight.titlePhoto" :src="flight.titlePhoto.thumbnailUrl"/>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div> <q-avatar v-else size="120px" icon="flight" text-color="grey-7" color="grey-4"></q-avatar>
</q-item-section>
<q-item-section>
<div class="text-h5 q-mb-md">{{ flightName(flight) }}</div>
<div class="text-secondary" v-if="flight.copilot"> <div class="text-secondary" v-if="props.public">
<q-icon name="person"/> <q-icon name="support_agent"/>
{{ flight.copilot.name }} @{{ flight.pilot.publicUsername }}
</div> </div>
<div class="text-secondary"> <div class="text-secondary" v-if="flight.copilots">
<q-icon name="schedule"/> <q-icon name="person"/>
{{ minutesToHumanReadable(flight.durationMinCalculated) }} {{ flight.pilot.name }}
</div> {{ flight.copilots }}
<div class="text-secondary"> </div>
<q-icon name="flight"/>
{{ flight.aircraft.callSign }} <div class="text-secondary">
</div> <q-icon name="schedule"/>
</q-item-section> {{ minutesToHumanReadable(flight.durationMinCalculated) }}
</div>
<div class="text-secondary">
<q-icon name="flight"/>
{{ flight.aircraft.callSign }}
</div>
<div class="text-secondary" v-if="flight.event">
<q-icon name="event"/>
{{ flight.event.name }}
</div>
</q-item-section>
<q-item-section side top v-if="!props.public">
<!-- Zobraz jen v adminu -->
<q-icon name="visibility" v-if="flight.isPublic"/>
<q-icon name="warning" color="orange" v-if="flight.isPublic && flight.urlSlug === ''">
<q-tooltip>
Let je sice publikovaný, nemá ale nastavenou URL adresu!
</q-tooltip>
</q-icon>
</q-item-section>
</q-item>
</q-list>
</template>
<template #skeleton>
<FlightsListSkeleton :items="itemsPerPage"/>
</template>
<template #loadMoreText>
Načíst {{ pluralize(loadMoreCount, 'jeden let', `další ${loadMoreCount} lety`, `dalších ${loadMoreCount} letů`) }}
</template>
</LoadMoreList>
<q-item-section side top v-if="flight.isPublic && displayVisibilityInfo">
<q-icon name="visibility"></q-icon>
</q-item-section>
</q-item>
</q-list>
</template> </template>
-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>
+82 -77
View File
@@ -6,7 +6,6 @@ import DatetimePicker from "components/inputs/DatetimePicker.vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {isRequired} from "src/composables/form/rules"; import {isRequired} from "src/composables/form/rules";
import {isRequestPending} from "src/composables/form/props";
const props = defineProps({ const props = defineProps({
aircrafts: { aircrafts: {
@@ -21,19 +20,29 @@ const props = defineProps({
type: Object, type: Object,
required: false 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 manualInput = ref(false);
const allowGPXUpload = ref(true);
const formData = reactive({ const formData = reactive({
aircraft: null, aircraft: null,
gpxTrackFile: null,
takeoffAirport: null, takeoffAirport: null,
landingAirport: null, landingAirport: null,
takeoffDatetime: null, takeoffDatetime: null,
landingDatetime: null, landingDatetime: null,
landings: 1,
isPublic: false,
name: "",
description: "",
}); });
const rules = { const rules = {
@@ -43,19 +52,21 @@ const rules = {
landingDatetime: [isRequired], landingDatetime: [isRequired],
landingAirport: [isRequired], landingAirport: [isRequired],
}; };
watch(() => props.flight, (newVal) => { watch(() => props.flight, (newVal) => {
if (newVal) { if (newVal) {
setDefaultData(formData, newVal); setDefaultData(formData, newVal);
formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign}; 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} if (newVal.takeoffAirport)
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
if (newVal.landingAirport)
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)}
allowGPXUpload.value = false;
manualInput.value = true;
} }
}, {immediate: true}); }, {immediate: true});
const aircrafts = computed(() => {
return props.aircrafts.map(a => ({id: a.id, name: a.callSign}));
});
watch(() => formData.takeoffDatetime, () => { watch(() => formData.takeoffDatetime, () => {
if (!formData.landingDatetime) { if (!formData.landingDatetime) {
formData.landingDatetime = structuredClone(formData.takeoffDatetime); formData.landingDatetime = structuredClone(formData.takeoffDatetime);
@@ -67,81 +78,75 @@ watch(() => formData.takeoffAirport, () => {
formData.landingAirport = {...formData.takeoffAirport}; formData.landingAirport = {...formData.takeoffAirport};
} }
}); });
</script> </script>
<template> <template>
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending"> <FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<q-card class="q-mx-md q-my-md"> <ComboboxInput
<q-card-section> :rules="rules.aircraft"
<div class="text-h6">Vyber letadlo</div> v-bind="commonProps"
</q-card-section> :options="aircrafts"
v-model="formData.aircraft"
<q-card-section> label="Letadlo"
<ComboboxInput />
:rules="rules.aircraft"
v-bind="commonProps"
:options="aircrafts"
v-model="formData.aircraft"
label="Letadlo"
/>
</q-card-section>
</q-card>
</div> </div>
</div> </div>
<div class="row">
<div class="col col-12 col-md-6">
<q-card class="q-mx-md q-my-md">
<q-card-section>
<div class="text-h6">Vzlet</div>
</q-card-section>
<q-card-section>
<DatetimePicker
v-model="formData.takeoffDatetime"
:rules="rules.takeoffDatetime"
label="Datum a čas vzletu: "
v-bind="commonProps"
/>
<ComboboxInput <div class="row" v-if="!manualInput">
:options="airports" <div class="col">
:rules="rules.takeoffAirport" <div class="text-h6">Mám trasu v GPX, chci importovat data</div>
v-model="formData.takeoffAirport" <q-file v-bind="commonProps" v-model="formData.gpxTrackFile" label="Trasa v GPX" hint="Z trasy se načtou letiště a časy vzletu a přistání. Tyto hodnoty je možné později upravit."/>
label="Letiště vzletu"
v-bind="commonProps"
:enable-new-values="false"
/>
</q-card-section>
</q-card>
</div>
<div class="col col-12 col-md-6"> <div class="text-center">
<q-btn @click="manualInput = true" rounded flat>Zadat informace ručně</q-btn>
<q-card class="q-mx-md q-my-md"> </div>
<q-card-section>
<div class="text-h6">Přistání</div>
</q-card-section>
<q-card-section>
<DatetimePicker
v-model="formData.landingDatetime"
:min-date="formData.takeoffDatetime"
:rules="rules.landingDatetime"
label="Datum a čas přistání"
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
v-model="formData.landingAirport"
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
:enable-new-values="false"
/>
</q-card-section>
</q-card>
</div> </div>
</div> </div>
<div class="row" v-if="manualInput">
<div class="col col-12 col-md-6 q-pr-md-sm">
<div class="text-h6">Vzlet</div>
<DatetimePicker
v-model="formData.takeoffDatetime"
:rules="rules.takeoffDatetime"
label="Datum a čas vzletu: "
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
:rules="rules.takeoffAirport"
v-model="formData.takeoffAirport"
label="Letiště vzletu"
v-bind="commonProps"
/>
</div>
<div class="col col-12 col-md-6 q-pl-md-sm">
<div class="text-h6">Přistání</div>
<DatetimePicker
v-model="formData.landingDatetime"
:min-date="formData.takeoffDatetime"
:rules="rules.landingDatetime"
label="Datum a čas přistání"
v-bind="commonProps"
/>
<ComboboxInput
:options="airports"
v-model="formData.landingAirport"
label="Letiště přistání"
v-bind="commonProps"
:rules="rules.landingAirport"
/>
</div>
<div class="col-12 text-center" v-if="allowGPXUpload">
<q-btn @click="manualInput = false" rounded flat>Mám GPX, načíst informace z trasy</q-btn>
</div>
</div>
</FormWrapper> </FormWrapper>
</template> </template>
+63 -12
View File
@@ -1,35 +1,86 @@
<script setup> <script setup>
import {onMounted, ref} from "vue"; import {reactive, ref, watch} from "vue";
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {setDefaultData} from "src/composables/form/formHandling"; import {setDefaultData} from "src/composables/form/formHandling";
import RichTextEditor from "components/inputs/RichTextEditor.vue"; import RichTextEditor from "components/inputs/RichTextEditor.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import ComboboxInput from "components/inputs/ComboboxInput.vue";
import SlugInput from "components/inputs/SlugInput.vue";
import {storeToRefs} from "pinia";
import {useLoggedUserStore} from "stores/LoggedUser";
const props = defineProps({ const props = defineProps({
flight: { flight: {
type: Object, type: Object,
required: false required: false
}, },
events: {
type: Array,
required: false,
},
isRequestPending: {
type: Boolean,
required: false,
default: false,
}
}); });
const formData = ref({ const formData = reactive({
name: "", name: "",
description: "" event: null,
description: "",
debriefingNotes: "",
urlSlug: "",
isPublic: null
}) })
const isMounted = ref(false);
onMounted(() => {
if (props.flight)
setDefaultData(formData.value, props.flight);
isMounted.value = true; const loggedUser = storeToRefs(useLoggedUserStore()).user;
})
watch(() => props.flight, (newVal) => {
if (newVal) {
setDefaultData(formData, newVal);
if (newVal.event) {
formData.event = {id: newVal.event.id, name: newVal.event.name};
}
}
}, {immediate: true});
const descriptionTab = ref("description");
</script> </script>
<template> <template>
<FormWrapper v-model="formData" v-if="isMounted"> <FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps" /> <ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/>
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..." /> <q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
<template v-if="loggedUser?.publicUsername">
<SlugInput
v-if="formData.isPublic"
v-bind="commonProps"
v-model="formData.urlSlug"
label="URL"
:url="`/${loggedUser.publicUsername}/`"
:slug-source="formData.name"
/>
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
</template>
<q-tabs v-model="descriptionTab" active-color="primary">
<q-tab name="description">Popis</q-tab>
<q-tab name="debriefing">Debriefing (soukromé poznámky)</q-tab>
</q-tabs>
<q-tab-panels v-model="descriptionTab">
<q-tab-panel name="description">
<RichTextEditor v-model="formData.description" v-bind="commonProps" height="30vh"/>
</q-tab-panel>
<q-tab-panel name="debriefing">
<RichTextEditor v-model="formData.debriefingNotes" v-bind="commonProps" height="20vh"/>
</q-tab-panel>
</q-tab-panels>
</FormWrapper> </FormWrapper>
</template> </template>
@@ -2,6 +2,7 @@
import FormWrapper from "components/FormWrapper.vue"; import FormWrapper from "components/FormWrapper.vue";
import {reactive} from "vue"; import {reactive} from "vue";
import ComboboxInput from "components/inputs/ComboboxInput.vue"; import ComboboxInput from "components/inputs/ComboboxInput.vue";
import {roundWithPrecision} from "../../../composables/utils";
const props = defineProps({ const props = defineProps({
flight: { flight: {
@@ -17,6 +18,10 @@ const props = defineProps({
const formData = reactive({ const formData = reactive({
photos: [] photos: []
}); });
const inlinePreview = (photo) => {
return URL.createObjectURL(photo);
}
</script> </script>
<template> <template>
@@ -26,6 +31,18 @@ const formData = reactive({
align: "left" 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 <q-file
class="q-my-lg" class="q-my-lg"
outlined outlined
+82 -21
View File
@@ -1,8 +1,9 @@
<script setup> <script setup>
import FormWrapper from "components/FormWrapper.vue"; 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 ComboboxInput from "components/inputs/ComboboxInput.vue";
import {commonProps} from "src/composables/inputs"; import {commonProps} from "src/composables/inputs";
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -12,6 +13,10 @@ const props = defineProps({
type: Array, type: Array,
required: true required: true
}, },
airports: {
type: Array,
required: true
},
flight: { flight: {
type: Object, type: Object,
required: false required: false
@@ -20,45 +25,65 @@ const props = defineProps({
const formData = reactive({ const formData = reactive({
track: [], track: [],
gpxTrack: null gpxTrackFile: null
}); });
const removeTrackItem = (index) => { const removeTrackItem = (index) => {
formData.track.splice(index, 1); formData.track.splice(index, 1);
} }
const poi = ref(null); const poi = ref(null);
const addTrackItem = (item) => { const airport = ref(null);
formData.track.push(item); const landingDuration = ref(null);
const addTrackPOIItem = (item) => {
formData.track.push({pointOfInterest: item});
poi.value = null; poi.value = null;
} }
const addAirportToTrack = (airportData, duration) => {
formData.track.push({airport: airportData, landingDuration: parseInt(duration)})
airport.value = null;
}
watch(poi, (newVal) => { watch(poi, (newVal) => {
if (newVal?.name) { if (newVal?.name) {
addTrackItem(newVal); addTrackPOIItem(newVal);
} }
}); });
onMounted(() => { watch(() => props.flight.track, (newVal) => {
for (const i in props.flight.track) { formData.track = [];
const poiInfo = props.flight.track[i].pointOfInterest; for (const i in newVal) {
formData.track.push({ const trackInfo = newVal[i];
id: poiInfo.id, const info = {landingDuration: trackInfo.landingDuration};
name: poiInfo.name 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> </script>
<template> <template>
<FormWrapper v-model="formData"> <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"/> <q-icon name="check"/>
Trasa nahrána Trasa nahrána
</q-banner> </q-banner>
<q-file <q-file
accept=".gpx" accept=".gpx"
v-model="formData.gpxTrack" v-model="formData.gpxTrackFile"
v-bind="commonProps" v-bind="commonProps"
label="Nahrát trasu v GPX" label="Nahrát trasu v GPX"
hint="Slouží pro vykreslení kompletní a detailní trasy" hint="Slouží pro vykreslení kompletní a detailní trasy"
@@ -68,17 +93,46 @@ onMounted(() => {
</template> </template>
</q-file> </q-file>
<h5>Body na mapě</h5>
<ComboboxInput <ComboboxInput
v-bind="commonProps" v-bind="commonProps"
v-model="poi" v-model="poi"
:key="formData.track.length" :key="formData.track.length"
:options="locations" :options="locations"
@select="addTrackItem" @select="addTrackPOIItem"
clearable clearable
label="Přidat otočný bod" label="Přidat otočný bod"
hint="Nastav významné otočné body" hint="Nastav významné otočné body"
/> />
<div class="row">
<div class="col-12 col-md-8">
<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 mezipřistání"
/>
</div>
<div class="col-md-4 col-12" v-if="airport?.name">
<q-input
type="number"
v-bind="commonProps"
v-model="landingDuration"
label="Délka mezipřistání (minuty)"
class="q-mx-md-md"
>
<template #after>
<q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn>
</template>
</q-input>
</div>
</div>
<q-list bordered> <q-list bordered>
<q-item-label header>Trasa</q-item-label> <q-item-label header>Trasa</q-item-label>
<q-item> <q-item>
@@ -86,7 +140,7 @@ onMounted(() => {
<q-avatar color="primary" text-color="white" icon="flight_takeoff"/> <q-avatar color="primary" text-color="white" icon="flight_takeoff"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ flight.takeoffAirport.name }} {{ flight.takeoffAirport?.name || '???' }}
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -95,13 +149,20 @@ onMounted(() => {
<q-avatar size="32px" color="secondary" text-color="white">{{ index + 1 }}.</q-avatar> <q-avatar size="32px" color="secondary" text-color="white">{{ index + 1 }}.</q-avatar>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<span> <span v-if="item.pointOfInterest">
{{ item.name }} {{ 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-icon name="add"/>
</q-badge> </q-badge>
</span> </span>
<span v-if="item.airport">
{{ item.airport.name }},
<template v-if="item.landingDuration > 0">
mezipřistání {{ minutesToHumanReadable(item.landingDuration) }}
</template>
<template v-else>touch and go</template>
</span>
</q-item-section> </q-item-section>
<q-item-section top side> <q-item-section top side>
@@ -114,7 +175,7 @@ onMounted(() => {
<q-avatar color="primary" text-color="white" icon="flight_land"/> <q-avatar color="primary" text-color="white" icon="flight_land"/>
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
{{ flight.landingAirport.name }} {{ flight.landingAirport?.name || '???' }}
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -1,5 +1,4 @@
<script setup> <script setup>
import {randomInt} from "src/composables/random";
import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue"; import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue";
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue"; import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</script> </script>
@@ -16,7 +15,7 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
<q-item> <q-item>
<q-list> <q-list>
<ListItemSkeleton v-for="i in randomInt(1, 3)" :key="i"/> <ListItemSkeleton v-for="i in 3" :key="i"/>
</q-list> </q-list>
</q-item> </q-item>
@@ -26,7 +25,7 @@ import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
</q-list> </q-list>
<article class="q-my-lg"> <article class="q-my-lg">
<BasicTextSkeleton :lines="randomInt(2, 5)" :paragraphs="randomInt(1, 2)" /> <BasicTextSkeleton :lines="7" :paragraphs="2" />
</article> </article>
<q-skeleton height="400px" square class="q-mt-lg"/> <q-skeleton height="400px" square class="q-mt-lg"/>
@@ -1,6 +1,7 @@
<script setup> <script setup>
</script> </script>
<template> <template>
<em>POI edit skeleton</em> edit copilot skeleton
</template> </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>
@@ -1,10 +1,18 @@
<script setup> <script setup>
import {randomInt} from "src/composables/random"; import {randomInt} from "src/composables/random";
const props = defineProps({
items: {
type: Number,
required: false,
default: 10,
}
})
</script> </script>
<template> <template>
<q-list bordered separator class="q-mx-md q-my-md"> <q-list bordered separator>
<q-item v-for="i in randomInt(5, 10)" :key="i" ripple> <q-item v-for="i in items" :key="i" ripple>
<q-item-section avatar> <q-item-section avatar>
<q-avatar size="100px"> <q-avatar size="100px">
<q-skeleton type="QAvatar" size="100px"></q-skeleton> <q-skeleton type="QAvatar" size="100px"></q-skeleton>
@@ -12,10 +20,9 @@ import {randomInt} from "src/composables/random";
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-skeleton :width="randomInt(30,50)+'%'"/> <q-skeleton :width="randomInt(30,50)+'%'"/>
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/>
<q-skeleton type="text" :width="randomInt(10,15)+'%'"/> <q-skeleton type="text" :width="randomInt(5,10)+'%'"/>
<q-skeleton type="text" :width="randomInt(5,10)+'%'"/> <q-skeleton type="text" width="8%"/>
<q-skeleton type="text" width="8%"/>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -0,0 +1,33 @@
<script setup>
import {flightName} from "src/composables/flight";
const props = defineProps({
flight: {
type: Object,
required: true
},
textColor: {
type: String,
required: false,
default: "white"
}
})
</script>
<template>
<div class="text-h4">
<router-link
:class="`text-${props.textColor}`"
:to="{name: 'publicFlightDetail', params: {username: flight.pilot.publicUsername, flightSlug: flight.urlSlug}}"
>
{{ flightName(flight) }}
</router-link>
</div>
<router-link
:class="`text-${props.textColor}`"
:to="{name: 'publicProfileDetail', params: {username: flight.pilot.publicUsername}}"
>
@{{ flight.pilot.publicUsername }}
</router-link>
</template>
@@ -0,0 +1,48 @@
<script setup>
import {ref} from "vue";
import {flightName} from "src/composables/flight";
import LatestFlightSliderInfo from "components/flights/slider/LatestFlightSliderInfo.vue";
const props = defineProps({
items: {
type: Array,
required: true
}
});
const slide = ref(0);
</script>
<template>
<div class="bg-blue-1 q-py-lg">
<h3 class="text-center">Poslední lety</h3>
<q-carousel
v-model="slide"
swipeable
animated
control-color="primary"
navigation
padding
arrows
height="500px"
class="bg-blue-1 text-white"
>
<q-carousel-slide :name="idx" v-for="(flight, idx) in items" :key="idx" class="column no-wrap flex-center text-center">
<q-img
:src="flight.titlePhoto.url"
fit="cover"
ratio="16/9"
style="max-width: 1000px;"
height="400px"
v-if="flight.titlePhoto"
>
<div class="absolute-bottom">
<LatestFlightSliderInfo :flight="flight"/>
</div>
</q-img>
<LatestFlightSliderInfo v-else :flight="flight" text-color="primary"/>
</q-carousel-slide>
</q-carousel>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup>
</script>
<template>
<div class="row q-mx-md">
<div class="col-12 col-md-6 col-lg-6 self-center info-box">
<div class="text-h3 text-center q-my-lg">Fotogalerie ze vzduchu</div>
<p>Polétání.cz je aplikace pro piloty, kteří si chtějí spravovat fotografie ze svých výletů, zobrazovat je na mapě a ukládat si trasy svých výletů.</p>
<div class="text-center q-my-lg">
<q-btn size="xl" :to="{name: 'registration'}" rounded color="secondary">Založit účet</q-btn>
</div>
</div>
<div class="col-12 col-md-6 col-lg-6 text-center">
<div>
<q-img src="hp/mobile-preview-200.webp" height="401px" width="200px" class="q-ma-lg" fit="contain" alt="Nahled aplikace"/>
<q-img src="hp/desktop-preview-350.webp" height="310px" width="350px" fit="contain" class="xs-hide sm-hide" alt="Nahled aplikace"/>
</div>
</div>
</div>
</template>
<style>
.info-box {
font-size: 150%;
}
</style>
@@ -0,0 +1,37 @@
<script setup>
</script>
<template>
<q-timeline color="primary" class="q-my-lg">
<q-timeline-entry title="Zaznamenej si let">
<div>
Zapiš si odkud, kdy a čím jsi letěl. Aplikace sama stáhne informace o počasí při vzletu a přistání.
</div>
</q-timeline-entry>
<q-timeline-entry title="Ulož trasu">
<div>
Polétání.cz umí pracovat s trasou ve formátu GPX. Pokud ale tento záznam nemáš, můžeš si naklikat trasu ručně.
</div>
</q-timeline-entry>
<q-timeline-entry title="Vyber a nahraj fotky">
<div>A pokud mají v sobě uloženou informaci o poloze, p
Máš nějaké fotky z letu? Nahraj je ke svému letu! Polétání.cz ti zobrazí i místo kde fotka vznikla.
</div>
</q-timeline-entry>
<q-timeline-entry title="Popiš zážitek">
<div>
Určitě se po ceste stalo něco vzrušujícího. Proč o tom nenapsat pár vět?
</div>
</q-timeline-entry>
<q-timeline-entry title="Sdílej">
<div>
Létat v sóle je super, ale ještě lepší je se o zážitek podělit. Jedním kliknutím můžeš svůj let zveřejnit a
sdílet odkaz kde chceš. Třeba s tvým kopilotem.
</div>
</q-timeline-entry>
</q-timeline>
</template>
+6 -7
View File
@@ -10,11 +10,6 @@ const props = defineProps({
required: false, required: false,
default: () => [] default: () => []
}, },
enableNewValues: {
type: Boolean,
required: false,
default: true,
},
options: { options: {
type: Array, type: Array,
required: true, required: true,
@@ -101,11 +96,11 @@ const filterFn = (val, update, abort) => {
:options="availableOptions" :options="availableOptions"
:option-label="optionLabel" :option-label="optionLabel"
:option-value="optionValue" :option-value="optionValue"
:new-value-mode="enableNewValues ? 'add' : 'toggle'" new-value-mode="add"
clear-icon="clear" clear-icon="clear"
:v-bind="$attrs" :v-bind="$attrs"
> >
<template v-slot:no-option v-if="enableNewValues"> <template v-slot:no-option>
<q-item v-if="inputValue"> <q-item v-if="inputValue">
<q-item-section avatar> <q-item-section avatar>
<q-avatar text-color="green" size="32px" icon="add"/> <q-avatar text-color="green" size="32px" icon="add"/>
@@ -115,5 +110,9 @@ const filterFn = (val, update, abort) => {
</q-item-section> </q-item-section>
</q-item> </q-item>
</template> </template>
<template #append>
<slot name="append" />
</template>
</q-select> </q-select>
</template> </template>
+32
View File
@@ -0,0 +1,32 @@
<script setup>
import {copyToClipboard, useQuasar} from "quasar";
import {successObject} from "src/composables/dialog";
import {useRouter} from "vue-router";
const props = defineProps({
route: {
type: Object,
required: true
}
})
const $router = useRouter();
const $q = useQuasar();
const copyLinkToClipboard = () => {
const url = $router.resolve(props.route);
copyToClipboard(window.location.origin + url.href).then(() => {
$q.notify(successObject("Odkaz zkopírován"))
});
};
</script>
<template>
<q-btn
icon="link"
@click="copyLinkToClipboard()"
flat
label="Získat odkaz"
/>
</template>
+61 -21
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import {computed, onMounted, ref, watch} from "vue"; 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"; import {commonProps} from "src/composables/inputs";
const props = defineProps({ const props = defineProps({
@@ -23,9 +23,14 @@ const props = defineProps({
} }
}) })
let dateModel = ref(""); const emit = defineEmits(['update:modelValue']);
let timeModel = ref("");
const dateModel = ref("");
const timeModel = ref("00:00:00");
const tab = ref("date");
let originalTime, originalDate; let originalTime, originalDate;
const popupProxy = ref(null);
const publicModel = computed(() => { const publicModel = computed(() => {
if (!dateModel.value) { if (!dateModel.value) {
@@ -35,12 +40,22 @@ const publicModel = computed(() => {
return formatDatetime(dateObject, props.mask); 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 = () => { const emitModelUpdate = () => {
if (dateModel.value === '' || timeModel.value === '') return; if (dateModel.value === '' || timeModel.value === '') {
return;
}
const val = parseDate(model.value); const val = parseDate(model.value);
emit("update:modelValue", val); emit("update:modelValue", val);
@@ -49,6 +64,12 @@ const emitModelUpdate = () => {
popupProxy.value.hide(); 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) => { const containsAnyOf = (needles, haystack) => {
for (const i in needles) { for (const i in needles) {
if (haystack.includes(needles[i])) return true; if (haystack.includes(needles[i])) return true;
@@ -57,9 +78,6 @@ const containsAnyOf = (needles, haystack) => {
return false; return false;
} }
const showDatePicker = computed(() => containsAnyOf(["YY", "M", "D"], props.mask));
const showTimePicker = computed(() => containsAnyOf(["H", "h", "m"], props.mask));
const setOriginalValues = () => { const setOriginalValues = () => {
originalDate = dateModel.value; originalDate = dateModel.value;
originalTime = timeModel.value; originalTime = timeModel.value;
@@ -120,21 +138,43 @@ watch(() => props.modelValue, (newVal) => {
<template #default> <template #default>
<q-popup-proxy ref="popupProxy" @hide="emitModelUpdate" @show="setOriginalValues"> <q-popup-proxy ref="popupProxy" @hide="emitModelUpdate" @show="setOriginalValues">
<div class="proxy-wrapper"> <div class="proxy-wrapper relative-position" :style="`min-height: ${showTimePicker ? '500' : '300'}px`">
<div class="row">
<div class="col-12 col-sm-6" v-if="showDatePicker"> <q-tabs v-model="tab" v-if="showTimePicker" class="bg-primary text-white">
<q-date square v-model="dateModel" :options="isDateValid" :today-btn="true" flat mask="YYYY-MM-DD" :locale="myLocale"/> <q-tab name="date" icon="event" :label="dateModel ? formatDatetime(dateModel, 'D. M.', FORMAT_DATE_ISO) : 'datum'"/>
</div> <q-tab name="time" v-if="showTimePicker" icon="schedule" :label="model ? formatDatetime(model, 'HH:mm') : 'čas'"/>
<div class="col-12 col-sm-6" v-if="showTimePicker"> </q-tabs>
<q-time square v-model="timeModel" format24h flat mask="HH:mm:ss"/>
</div> <q-separator/>
</div>
<div class="row q-mb-sm"> <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"> <div class="col">
<q-btn color="secondary" class="q-ml-sm" @click="cancelSelection">Zrušit</q-btn> <q-btn color="secondary" class="q-ml-sm" @click="cancelSelection">Zrušit</q-btn>
</div> </div>
<div class="col text-right"> <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> </div>
</div> </div>
+64
View File
@@ -0,0 +1,64 @@
<script setup>
import {useQuasar} from "quasar";
import {ref, watch} from "vue";
import {slugify} from "src/composables/slugify";
const props = defineProps({
requireUrlSlug: {
type: Boolean,
required: true
},
isPublic: {
type: Boolean,
required: true
},
urlPrefix: {
type: String,
required: true
}
});
const emits = defineEmits(["changeVisibility"]);
const $q = useQuasar();
const urlSlugModel = ref("");
watch(() => urlSlugModel.value, (newVal) => {
const slug = slugify(newVal);
if (urlSlugModel.value !== slug)
urlSlugModel.value = slug;
})
const inputPrompt = () =>
$q.dialog({
title: 'URL',
message: props.urlPrefix+"...",
prompt: {
model: urlSlugModel,
placeholder: "URL fragment",
isValid: val => val.length > 1,
type: 'text' // optional
},
cancel: true,
persistent: true
})
const togglePublicVisibility = () => {
if (props.requireUrlSlug && !props.isPublic) {
inputPrompt().onOk((data) => {
emits("changeVisibility", {isPublic: !props.isPublic, urlSlug: slugify(urlSlugModel.value)});
})
} else {
emits("changeVisibility", {isPublic: !props.isPublic});
}
}
</script>
<template>
<q-btn
@click="togglePublicVisibility"
:icon="isPublic ? 'visibility_off' : 'visibility'"
flat
:label="isPublic ? 'Skrýt' : 'Zveřejnit'"
/>
</template>
+8 -1
View File
@@ -56,5 +56,12 @@ const toolbar = [
</script> </script>
<template> <template>
<q-editor v-model="content" :definitions="definitions" :toolbar="toolbar" :height="height" :label="label" :rules="rules"/> <q-editor
v-model="content"
:definitions="definitions"
:toolbar="toolbar"
:height="height"
v-bind="$attrs"
:rules="rules"
/>
</template> </template>
+62
View File
@@ -0,0 +1,62 @@
<script setup>
import {ref, watch} from "vue";
import {slugify} from "src/composables/slugify";
const props = defineProps({
modelValue: {
type: String
},
slugSource: {
type: String,
required: false,
},
url : {
type: String,
required: false,
default: null
},
label: {
type: String,
required: false,
default: "URL"
},
rules: {
type: Array,
required: false,
default: () => ([])
}
});
const emit = defineEmits(['update:modelValue']);
const model = ref("");
const emitValue = (val) => {
emit("update:modelValue", val);
}
watch(() => model.value, (newVal) => {
model.value = slugify(newVal);
emitValue(newVal);
})
watch(() => props.slugSource, (newVal, oldVal) => {
if (model.value === "" || slugify(oldVal) === model.value) {
// pokud puvodni vychozi hodnota je stejna jako aktualne nastaveny slug, zmenime model
model.value = slugify(newVal);
}
}, {immediate: true});
</script>
<template>
<q-input
v-model="model"
:label="label"
:rules="rules"
hint="Veřejná URL adresa"
>
<template #prepend>
{{ url }}
</template>
</q-input>
</template>
-3
View File
@@ -27,9 +27,6 @@ const props = defineProps({
}) })
const track = ref([]); const track = ref([]);
const speed = ref([]);
const elevation = ref([]);
const bounds = computed(() => { const bounds = computed(() => {
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}})); const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
const allData = [...props.markers, ...track.value, ...photosTransformed]; const allData = [...props.markers, ...track.value, ...photosTransformed];
@@ -8,7 +8,7 @@ const props = defineProps({
</script> </script>
<template> <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-el v-for="item in items" :key="item.label" :to="item.to"> {{ item.label }} </q-breadcrumbs-el>
</q-breadcrumbs> </q-breadcrumbs>
</template> </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(100, 190)}px`" type="rect" /> </q-breadcrumbs-el>
<q-breadcrumbs-el> <q-skeleton :width="`${randomInt(200, 280)}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> <template>
<q-item <q-item
clickable clickable
:active="isButtonActive"
tag="router-link" tag="router-link"
:to="to" :to="to"
> >
@@ -19,6 +20,8 @@
</template> </template>
<script setup> <script setup>
import {computed} from "vue";
import {useRoute, useRouter} from "vue-router";
const props = defineProps({ const props = defineProps({
title: { title: {
@@ -40,5 +43,17 @@ const props = defineProps({
type: String, type: String,
default: '' 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> </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>

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