Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7ee9e686 | ||
|
|
1ed35552d5 | ||
|
|
e2ad6e39b8 | ||
|
|
a63efc44d8 | ||
|
|
100e1fa33b | ||
|
|
4c8952117b | ||
|
|
5b47c56c6f | ||
|
|
bf8df8bb34 | ||
|
|
e76d34fab4 | ||
|
|
901c652641 | ||
|
|
36b0178b97 | ||
|
|
b64c426179 | ||
|
|
d1baec342f | ||
|
|
8c3c5ee48d | ||
|
|
6fbaef4a1e | ||
|
|
0c91ff7838 | ||
|
|
b6a6fc3558 | ||
|
|
e203a7d229 |
Generated
-5400
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "0.0.1",
|
||||
"description": "A Quasar Project",
|
||||
"description": "Fotogalerie pro tvoje zážitky ze vzduchu",
|
||||
"productName": "Polétání.cz",
|
||||
"author": "Michal Kváček <michal@kvacek.cz>",
|
||||
"private": true,
|
||||
@@ -19,11 +19,14 @@
|
||||
"chart.js": "^4.4.0",
|
||||
"graphql": "16.6.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"photoswipe": "^5.4.2",
|
||||
"photoswipe-dynamic-caption-plugin": "^1.2.7",
|
||||
"pinia": "^2.0.33",
|
||||
"quasar": "^2.12.0",
|
||||
"vue": "^3.2.47",
|
||||
"vue-chartjs": "^5.2.0",
|
||||
"vue-inner-image-zoom": "^2.0.0",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-router": "^4.1.6",
|
||||
"vue3-google-map": "^0.15.0"
|
||||
},
|
||||
|
||||
@@ -98,6 +98,7 @@ module.exports = configure(function (ctx) {
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
|
||||
framework: {
|
||||
config: {},
|
||||
cssAddon: true,
|
||||
|
||||
iconSet: 'mdi-v6', // Quasar icon set
|
||||
// lang: 'cs-CZ', // Quasar language pack
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import {aircraft} from "src/composables/objects/aircraft";
|
||||
import {confirmObject} from "src/composables/dialog";
|
||||
import {useQuasar} from "quasar";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useUserStore} from "stores/User";
|
||||
|
||||
const props = defineProps({
|
||||
aircraft: {
|
||||
@@ -11,9 +13,11 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emits = defineEmits(["delete"]);
|
||||
|
||||
const $q = useQuasar();
|
||||
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
|
||||
|
||||
const confirmDelete = (aircraftId) => {
|
||||
$q.dialog(confirmObject('Opravdu chceš smazat toto letadlo?'))
|
||||
.onOk(() => {
|
||||
@@ -26,7 +30,7 @@ const confirmDelete = (aircraftId) => {
|
||||
<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">
|
||||
<q-card-actions class="q-my-md" v-if="aircraft.createdById === loggedUser.id">
|
||||
<div class="col-grow">
|
||||
<q-btn
|
||||
flat
|
||||
@@ -41,6 +45,11 @@ const confirmDelete = (aircraftId) => {
|
||||
|
||||
<q-card-section>
|
||||
<q-list>
|
||||
|
||||
<q-item v-if="aircraft.organization">
|
||||
Provozovatel: {{ aircraft.organization.name }}
|
||||
</q-item>
|
||||
|
||||
<q-item v-if="aircraft.manufacturer">
|
||||
Výrobce: {{ aircraft.manufacturer }}
|
||||
</q-item>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {setDefaultData} from "src/composables/form/formHandling";
|
||||
import {defaultValues, isRequestPending} from "src/composables/form/props";
|
||||
import {isNumber, isRequired, maxLength, minLength} from "src/composables/form/rules";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
@@ -14,12 +15,16 @@ const formData = reactive({
|
||||
manufacturer: "",
|
||||
description: "",
|
||||
photo: null,
|
||||
organizationId: null
|
||||
organization: null
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
defaultValues,
|
||||
isRequestPending
|
||||
isRequestPending,
|
||||
organizations: {
|
||||
type: Array,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const rules = {
|
||||
@@ -28,8 +33,12 @@ const rules = {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (Object.keys(props.defaultValues).length > 0)
|
||||
if (Object.keys(props.defaultValues).length > 0) {
|
||||
setDefaultData(formData, props.defaultValues);
|
||||
if (props.defaultValues.organization) {
|
||||
formData.organization = {id: props.defaultValues.organization.id, name: props.defaultValues.organization.name};
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -45,6 +54,23 @@ onMounted(() => {
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
type="number"
|
||||
v-model.number="formData.seats"
|
||||
label="Počet míst (včetně pilota)"
|
||||
min="1"
|
||||
:rules="rules.seats"
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<ComboboxInput
|
||||
:options="organizations"
|
||||
label="Organizace"
|
||||
v-model="formData.organization"
|
||||
v-bind="commonProps"
|
||||
hint="Vyber provozovatele letadla. Tím se stane dostupné i pro ostatní členy vybrané organizace."
|
||||
/>
|
||||
|
||||
<q-input
|
||||
type="text"
|
||||
v-model="formData.manufacturer"
|
||||
@@ -58,14 +84,7 @@ onMounted(() => {
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
type="number"
|
||||
v-model.number="formData.seats"
|
||||
label="Počet míst (včetně pilota)"
|
||||
min="1"
|
||||
:rules="rules.seats"
|
||||
v-bind="commonProps"
|
||||
/>
|
||||
|
||||
|
||||
<q-input
|
||||
type="textarea"
|
||||
|
||||
@@ -36,7 +36,11 @@ useAppStore().setPageTitle("Letadla");
|
||||
({{ aircraft.manufacturer }})
|
||||
</template>
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="aircraft.organization">
|
||||
Provozovatel: {{ aircraft.organization.name }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
</q-item>
|
||||
</q-list>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -3,7 +3,6 @@ import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card class="flight-detail-card">
|
||||
<q-skeleton type="rect" class="q-my-lg" style="width: 200px; height: 40px" />
|
||||
<BasicListSkeleton :items-count-min="1" :items-count-max="5"/>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<em>Copilot skeleton</em>
|
||||
<q-skeleton type="QInput" />
|
||||
</template>
|
||||
|
||||
@@ -11,14 +11,16 @@ const props = defineProps({
|
||||
|
||||
<template>
|
||||
<q-list bordered separator>
|
||||
<q-item clickable v-ripple v-for="pilot of items" :key="pilot">
|
||||
<q-item
|
||||
clickable
|
||||
:to="{name: 'copilotDetail', params: {id: pilot.id }}"
|
||||
v-ripple
|
||||
v-for="pilot of items" :key="pilot">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="person" color="primary" text-color="white"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<router-link :to="{name: 'copilotDetail', params: {id: pilot.id }}">
|
||||
{{ pilot.name }}
|
||||
</router-link>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
@@ -0,0 +1,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-count-min="1" :items-count-max="5"/>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<em>Copilot skeleton</em>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<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 {isRequestPending} from "src/composables/form/props";
|
||||
import {isRequired} from "src/composables/form/rules";
|
||||
import RichTextEditor from "components/inputs/RichTextEditor.vue";
|
||||
import DatetimePicker from "components/inputs/DatetimePicker.vue";
|
||||
import {FORMAT_DATE, FORMAT_DATETIME_LONG} from "src/composables/datetimeUtils";
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: Object
|
||||
},
|
||||
isRequestPending
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
name: null,
|
||||
isPublic: false,
|
||||
description: "",
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [isRequired]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.event) {
|
||||
setDefaultData(formData, props.event);
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
|
||||
<q-input
|
||||
v-bind="commonProps"
|
||||
label="Jméno"
|
||||
:rules="rules.name"
|
||||
v-model="formData.name"
|
||||
/>
|
||||
|
||||
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateFrom" label="Od" />
|
||||
<DatetimePicker :mask="FORMAT_DATE" v-model="formData.dateTo" label="Do" />
|
||||
|
||||
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
|
||||
<q-checkbox v-model="formData.isPublic" label="Veřejný?"/>
|
||||
|
||||
</FormWrapper>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import AvatarsGroup from "components/avatars/AvatarsGroup.vue";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
routeDetail: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({name: 'eventDetail', params: {}})
|
||||
},
|
||||
});
|
||||
|
||||
const getCoverPhotos = (item) => item.flights.filter(f => !!f.coverPhoto).map(f => f.coverPhoto.thumbnailUrl);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list bordered separator>
|
||||
<q-item
|
||||
clickable
|
||||
:to="{name: routeDetail.name, params: {...routeDetail.params, id: event.id}}"
|
||||
v-ripple
|
||||
v-for="event of items" :key="event"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<q-avatar icon="event" color="primary" text-color="white" v-if="event.flights.length === 0"/>
|
||||
<AvatarsGroup :avatars="getCoverPhotos(event)" v-else/>
|
||||
|
||||
{{ event.name }}
|
||||
</q-item-label>
|
||||
<q-item-label lines="4" caption>
|
||||
<article v-html="event.description"/>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</template>
|
||||
@@ -44,7 +44,7 @@ const props = defineProps({
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<div>
|
||||
<strong>{{flight.takeoffAirport.icaoCode}}</strong> ({{ flight.takeoffAirport.name }}),
|
||||
<strong>{{ flight.takeoffAirport.icaoCode }}</strong> ({{ flight.takeoffAirport.name }}),
|
||||
čas vzletu: {{ formatDatetime(flight.takeoffDatetime, FORMAT_TIME) }}
|
||||
|
||||
<WeatherInfo class="q-mx-sm" v-if="flight.takeoffWeatherInfo" :weather="flight.takeoffWeatherInfo"/>
|
||||
@@ -54,10 +54,17 @@ const props = defineProps({
|
||||
|
||||
<q-item v-for="poi in flight.track" :key="poi.id">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="place"/>
|
||||
<q-icon name="place" v-if="poi.pointOfInterest" />
|
||||
<q-icon name="local_airport" v-if="poi.airport" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<template v-if="poi.pointOfInterest">
|
||||
{{ poi.pointOfInterest.name }}
|
||||
</template>
|
||||
<span v-if="poi.airport">
|
||||
<strong>{{ poi.airport.icaoCode }}</strong> ({{ poi.airport.name }}),
|
||||
délka mezipřistání {{ minutesToHumanReadable(poi.landingDuration) }}
|
||||
</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
@@ -91,9 +98,10 @@ const props = defineProps({
|
||||
<q-icon name="people"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<router-link :to="{name: 'copilotDetail', params: {id: copilot.id}}">
|
||||
<router-link :to="{name: 'copilotDetail', params: {id: copilot.id}}" v-if="showCopilots">
|
||||
{{ copilot.name }}
|
||||
</router-link>
|
||||
<template v-else> {{ copilot.name }}</template>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
@@ -1,20 +1,77 @@
|
||||
<script setup>
|
||||
|
||||
import {randomInt} from "src/composables/random";
|
||||
import BasicTextSkeleton from "components/basicSkeletons/BasicTextSkeleton.vue";
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import {randomInt} from "src/composables/random";
|
||||
|
||||
const props = defineProps({
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card class="flight-detail-card">
|
||||
<q-skeleton type="rect" height="400px"/>
|
||||
|
||||
<template v-if="step === 'basic'">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-card class="q-my-sm">
|
||||
<q-card-section>
|
||||
<q-skeleton type="QInput" v-for="i in 3" :key="i" />
|
||||
<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>
|
||||
|
||||
<q-card class="flight-detail-card" flat>
|
||||
<BasicListSkeleton :items-count-min="1" :items-count-max="4"/>
|
||||
</q-card></template>
|
||||
<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>
|
||||
|
||||
@@ -13,41 +13,16 @@ const props = defineProps({
|
||||
|
||||
|
||||
const mapPhotos = computed(() => flight.value.photos.filter(photo => photo.gpsLatitude && photo.gpsLongitude));
|
||||
const poisWithGPS = computed(() => {
|
||||
return flight.value.track.filter(poi => poi.pointOfInterest.gpsLatitude && poi.pointOfInterest.gpsLongitude)
|
||||
});
|
||||
|
||||
const pointsOfInterests = computed(() => {
|
||||
const path = [{
|
||||
label: flight.value.takeoffAirport.name,
|
||||
position:
|
||||
{
|
||||
lat: flight.value.takeoffAirport.gpsLatitude,
|
||||
lng: flight.value.takeoffAirport.gpsLongitude
|
||||
}
|
||||
}];
|
||||
|
||||
for (const poi of poisWithGPS.value) {
|
||||
path.push({
|
||||
label: poi.pointOfInterest.name,
|
||||
position: {lat: poi.pointOfInterest.gpsLatitude, lng: poi.pointOfInterest.gpsLongitude}
|
||||
});
|
||||
}
|
||||
|
||||
path.push({
|
||||
label: flight.value.landingAirport.name,
|
||||
position: {
|
||||
lat: flight.value.landingAirport.gpsLatitude,
|
||||
lng: flight.value.landingAirport.gpsLongitude
|
||||
}
|
||||
});
|
||||
|
||||
return path;
|
||||
});
|
||||
const poisWithGPS = computed(() => flight.value.track.map(poi => poi.pointOfInterest || poi.airport));
|
||||
|
||||
const mapMarkers = [flight.value.takeoffAirport].concat(poisWithGPS.value).concat([flight.value.landingAirport]);
|
||||
const pointsOfInterests = computed(() => mapMarkers
|
||||
.filter(marker => marker.gpsLatitude && marker.gpsLongitude)
|
||||
.map(
|
||||
marker => ({label: marker.name, position: {lat: marker.gpsLatitude, lng: marker.gpsLongitude}})
|
||||
));
|
||||
|
||||
const gpxTrack = flight.value.gpxTrack;
|
||||
|
||||
const displayMap = computed(() => {
|
||||
return (
|
||||
!!gpxTrack ||
|
||||
@@ -101,7 +76,7 @@ if (gpxTrack) {
|
||||
|
||||
const currentPositionMarker = ref(null);
|
||||
const drawCurrentPositionMarker = (e) => {
|
||||
const res = e.chart.getElementsAtEventForMode(e, 'nearest', {intersect: false},);
|
||||
const res = e.chart.getElementsAtEventForMode(e, 'nearest', {intersect: false});
|
||||
if (res.length === 0) {
|
||||
currentPositionMarker.value = null;
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import {minutesToHumanReadable} from "src/composables/datetimeUtils";
|
||||
import {flightName} from "src/composables/objects/flight";
|
||||
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
@@ -28,7 +28,7 @@ const props = defineProps({
|
||||
v-for="flight of items" :key="flight"
|
||||
ripple
|
||||
clickable
|
||||
:to="{name: routeDetail.name, params:{...routeDetail.params, id: flight.id}}"
|
||||
:to="{name: routeDetail.name, params: {...routeDetail.params, id: flight.id}}"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<AvatarFromPhoto :size="120" v-if="flight.coverPhoto" :src="flight.coverPhoto.thumbnailUrl"/>
|
||||
@@ -49,6 +49,11 @@ const props = defineProps({
|
||||
<q-icon name="flight"/>
|
||||
{{ flight.aircraft.callSign }}
|
||||
</div>
|
||||
|
||||
<div class="text-secondary" v-if="flight.event">
|
||||
<q-icon name="event" />
|
||||
{{ flight.event.name }}
|
||||
</div>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side top v-if="flight.isPublic && displayVisibilityInfo">
|
||||
|
||||
@@ -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>
|
||||
@@ -24,16 +24,17 @@ const props = defineProps({
|
||||
isRequestPending,
|
||||
});
|
||||
|
||||
const airportName = (airport) => `${airport.icaoCode} (${airport.name})`;
|
||||
|
||||
const airports = computed(() => props.airports.map(a => ({id: a.id, name: airportName(a)})));
|
||||
const aircrafts = computed(() => props.aircrafts.map(a => ({id: a.id, name: a.callSign})));
|
||||
|
||||
const formData = reactive({
|
||||
aircraft: null,
|
||||
takeoffAirport: null,
|
||||
landingAirport: null,
|
||||
takeoffDatetime: null,
|
||||
landingDatetime: null,
|
||||
landings: 1,
|
||||
isPublic: false,
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const rules = {
|
||||
@@ -43,19 +44,15 @@ const rules = {
|
||||
landingDatetime: [isRequired],
|
||||
landingAirport: [isRequired],
|
||||
};
|
||||
|
||||
watch(() => props.flight, (newVal) => {
|
||||
if (newVal) {
|
||||
setDefaultData(formData, newVal);
|
||||
formData.aircraft = {id: newVal.aircraft.id, name: newVal.aircraft.callSign};
|
||||
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: newVal.takeoffAirport.name}
|
||||
formData.landingAirport = {id: newVal.landingAirport.id, name: newVal.landingAirport.name}
|
||||
formData.takeoffAirport = {id: newVal.takeoffAirport.id, name: airportName(newVal.takeoffAirport)}
|
||||
formData.landingAirport = {id: newVal.landingAirport.id, name: airportName(newVal.landingAirport)}
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
const aircrafts = computed(() => {
|
||||
return props.aircrafts.map(a => ({id: a.id, name: a.callSign}));
|
||||
});
|
||||
|
||||
watch(() => formData.takeoffDatetime, () => {
|
||||
if (!formData.landingDatetime) {
|
||||
formData.landingDatetime = structuredClone(formData.takeoffDatetime);
|
||||
@@ -73,7 +70,7 @@ watch(() => formData.takeoffAirport, () => {
|
||||
<FormWrapper v-model="formData" :is-request-pending="props.isRequestPending">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-card class="q-mx-md q-my-md">
|
||||
<q-card class="q-my-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Vyber letadlo</div>
|
||||
</q-card-section>
|
||||
@@ -92,7 +89,7 @@ watch(() => formData.takeoffAirport, () => {
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-6">
|
||||
<q-card class="q-mx-md q-my-md">
|
||||
<q-card class="q-mr-md-sm q-my-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Vzlet</div>
|
||||
</q-card-section>
|
||||
@@ -110,7 +107,6 @@ watch(() => formData.takeoffAirport, () => {
|
||||
v-model="formData.takeoffAirport"
|
||||
label="Letiště vzletu"
|
||||
v-bind="commonProps"
|
||||
:enable-new-values="false"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -118,7 +114,7 @@ watch(() => formData.takeoffAirport, () => {
|
||||
|
||||
<div class="col col-12 col-md-6">
|
||||
|
||||
<q-card class="q-mx-md q-my-md">
|
||||
<q-card class="q-ml-md-sm q-my-sm">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Přistání</div>
|
||||
</q-card-section>
|
||||
@@ -137,7 +133,6 @@ watch(() => formData.takeoffAirport, () => {
|
||||
label="Letiště přistání"
|
||||
v-bind="commonProps"
|
||||
:rules="rules.landingAirport"
|
||||
:enable-new-values="false"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
@@ -1,35 +1,45 @@
|
||||
<script setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
import {onMounted, reactive, ref, watch} from "vue";
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {setDefaultData} from "src/composables/form/formHandling";
|
||||
import RichTextEditor from "components/inputs/RichTextEditor.vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
import {isRequestPending} from "src/composables/objects/flight";
|
||||
|
||||
const props = defineProps({
|
||||
flight: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
events: {
|
||||
type: Array,
|
||||
required: false,
|
||||
}
|
||||
});
|
||||
|
||||
const formData = ref({
|
||||
const formData = reactive({
|
||||
name: "",
|
||||
event: null,
|
||||
description: ""
|
||||
})
|
||||
|
||||
const isMounted = ref(false);
|
||||
onMounted(() => {
|
||||
if (props.flight)
|
||||
setDefaultData(formData.value, props.flight);
|
||||
watch(() => props.flight, (newVal) => {
|
||||
if (newVal) {
|
||||
setDefaultData(formData, newVal);
|
||||
|
||||
isMounted.value = true;
|
||||
})
|
||||
if (newVal.event) {
|
||||
formData.event = {id: newVal.event.id, name: newVal.event.name};
|
||||
}
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData" v-if="isMounted">
|
||||
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps" />
|
||||
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..." />
|
||||
<FormWrapper v-model="formData" :is-request-pending="isRequestPending">
|
||||
<ComboboxInput :options="events" v-if="events" v-model="formData.event" label="Událost" v-bind="commonProps"/>
|
||||
<q-input type="text" v-model="formData.name" label="Jméno letu" v-bind="commonProps"/>
|
||||
<RichTextEditor v-model="formData.description" v-bind="commonProps" label="Popis letu, poznámky, ..."/>
|
||||
</FormWrapper>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import FormWrapper from "components/FormWrapper.vue";
|
||||
import {onMounted, reactive, ref, watch} from "vue";
|
||||
import {reactive, ref, watch} from "vue";
|
||||
import ComboboxInput from "components/inputs/ComboboxInput.vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
|
||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
airports: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
flight: {
|
||||
type: Object,
|
||||
required: false
|
||||
@@ -27,31 +31,50 @@ const removeTrackItem = (index) => {
|
||||
}
|
||||
|
||||
const poi = ref(null);
|
||||
const addTrackItem = (item) => {
|
||||
formData.track.push(item);
|
||||
const airport = ref(null);
|
||||
const landingDuration = ref(null);
|
||||
|
||||
const addTrackPOIItem = (item) => {
|
||||
formData.track.push({pointOfInterest: item});
|
||||
poi.value = null;
|
||||
}
|
||||
|
||||
const addAirportToTrack = (airportData, duration) => {
|
||||
formData.track.push({airport: airportData, landingDuration: parseInt(duration)})
|
||||
airport.value = null;
|
||||
}
|
||||
|
||||
watch(poi, (newVal) => {
|
||||
if (newVal?.name) {
|
||||
addTrackItem(newVal);
|
||||
addTrackPOIItem(newVal);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
for (const i in props.flight.track) {
|
||||
const poiInfo = props.flight.track[i].pointOfInterest;
|
||||
formData.track.push({
|
||||
id: poiInfo.id,
|
||||
name: poiInfo.name
|
||||
});
|
||||
watch(() => props.flight.track, (newVal) => {
|
||||
for (const i in newVal) {
|
||||
const trackInfo = newVal[i];
|
||||
const info = {landingDuration: trackInfo.landingDuration};
|
||||
if (trackInfo.pointOfInterest) {
|
||||
info['pointOfInterest'] = {
|
||||
id: trackInfo.pointOfInterest.id,
|
||||
name: trackInfo.pointOfInterest.name,
|
||||
};
|
||||
}
|
||||
});
|
||||
if (trackInfo.airport) {
|
||||
info['airport'] = {
|
||||
id: trackInfo.airport.id,
|
||||
name: trackInfo.airport.name,
|
||||
}
|
||||
}
|
||||
formData.track.push(info);
|
||||
}
|
||||
}, {immediate: true});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormWrapper v-model="formData">
|
||||
|
||||
<q-banner class="bg-green text-white" v-if="flight.gpxTrackUrl">
|
||||
<q-banner class="bg-green text-white" v-if="flight.gpxTrack">
|
||||
<q-icon name="check"/>
|
||||
Trasa nahrána
|
||||
</q-banner>
|
||||
@@ -73,12 +96,39 @@ onMounted(() => {
|
||||
v-model="poi"
|
||||
:key="formData.track.length"
|
||||
:options="locations"
|
||||
@select="addTrackItem"
|
||||
@select="addTrackPOIItem"
|
||||
clearable
|
||||
label="Přidat otočný bod"
|
||||
hint="Nastav významné otočné body"
|
||||
/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<ComboboxInput
|
||||
v-bind="commonProps"
|
||||
v-model="airport"
|
||||
:key="formData.track.length"
|
||||
:options="airports"
|
||||
clearable
|
||||
label="Letiště pro mezipřistání"
|
||||
hint="Nastav letiště a dobu mezispřistání"
|
||||
/>
|
||||
</div>
|
||||
<div class="col" v-if="airport?.name">
|
||||
<q-input
|
||||
type="number"
|
||||
v-bind="commonProps"
|
||||
v-model="landingDuration"
|
||||
label="Délka mezipřistání (minuty)"
|
||||
class="q-mx-md"
|
||||
>
|
||||
<template #after>
|
||||
<q-btn @click="addAirportToTrack(airport, landingDuration)" color="primary" icon="add"></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-list bordered>
|
||||
<q-item-label header>Trasa</q-item-label>
|
||||
<q-item>
|
||||
@@ -95,13 +145,16 @@ onMounted(() => {
|
||||
<q-avatar size="32px" color="secondary" text-color="white">{{ index + 1 }}.</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span>
|
||||
{{ item.name }}
|
||||
<span v-if="item.pointOfInterest">
|
||||
{{ item.pointOfInterest.name }}
|
||||
|
||||
<q-badge color="green" v-if="!item.id">
|
||||
<q-badge color="green" v-if="!item.pointOfInterest.id">
|
||||
<q-icon name="add"/>
|
||||
</q-badge>
|
||||
</span>
|
||||
<span v-if="item.airport">
|
||||
{{ item.airport.name }}, mezipřistání: {{ item.landingDuration }} minut
|
||||
</span>
|
||||
|
||||
</q-item-section>
|
||||
<q-item-section top side>
|
||||
|
||||
@@ -10,11 +10,6 @@ const props = defineProps({
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
enableNewValues: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
@@ -101,11 +96,11 @@ const filterFn = (val, update, abort) => {
|
||||
:options="availableOptions"
|
||||
:option-label="optionLabel"
|
||||
:option-value="optionValue"
|
||||
:new-value-mode="enableNewValues ? 'add' : 'toggle'"
|
||||
new-value-mode="add"
|
||||
clear-icon="clear"
|
||||
:v-bind="$attrs"
|
||||
>
|
||||
<template v-slot:no-option v-if="enableNewValues">
|
||||
<template v-slot:no-option>
|
||||
<q-item v-if="inputValue">
|
||||
<q-item-section avatar>
|
||||
<q-avatar text-color="green" size="32px" icon="add"/>
|
||||
@@ -115,5 +110,9 @@ const filterFn = (val, update, abort) => {
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<slot name="append" />
|
||||
</template>
|
||||
</q-select>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, ref, watch} from "vue";
|
||||
import {FORMAT_DATETIME_LONG, FORMAT_ISO, formatDatetime, parseDate} from "src/composables/datetimeUtils";
|
||||
import {FORMAT_DATE, FORMAT_DATE_ISO, FORMAT_DATETIME_LONG, FORMAT_ISO, formatDatetime, parseDate} from "src/composables/datetimeUtils";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -23,9 +23,14 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
let dateModel = ref("");
|
||||
let timeModel = ref("");
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const dateModel = ref("");
|
||||
const timeModel = ref("00:00:00");
|
||||
const tab = ref("date");
|
||||
|
||||
let originalTime, originalDate;
|
||||
const popupProxy = ref(null);
|
||||
|
||||
const publicModel = computed(() => {
|
||||
if (!dateModel.value) {
|
||||
@@ -35,12 +40,22 @@ const publicModel = computed(() => {
|
||||
return formatDatetime(dateObject, props.mask);
|
||||
});
|
||||
|
||||
const popupProxy = ref(null);
|
||||
const model = computed(() => {
|
||||
if (dateModel.value === '') return null;
|
||||
let modelVal = dateModel.value;
|
||||
if (showTimePicker.value) {
|
||||
modelVal += "T" + timeModel.value;
|
||||
} else {
|
||||
modelVal += "T00:00:00"
|
||||
}
|
||||
|
||||
return modelVal
|
||||
});
|
||||
|
||||
const model = computed(() => dateModel.value + "T" + timeModel.value);
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const emitModelUpdate = () => {
|
||||
if (dateModel.value === '' || timeModel.value === '') return;
|
||||
if (dateModel.value === '' || timeModel.value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const val = parseDate(model.value);
|
||||
emit("update:modelValue", val);
|
||||
@@ -49,6 +64,12 @@ const emitModelUpdate = () => {
|
||||
popupProxy.value.hide();
|
||||
}
|
||||
|
||||
const showTimePicker = computed(() => containsAnyOf(["H", "h", "m"], props.mask));
|
||||
|
||||
watch(() => dateModel.value, (newVal, oldVal) => {
|
||||
if (showTimePicker.value)
|
||||
tab.value = "time";
|
||||
});
|
||||
const containsAnyOf = (needles, haystack) => {
|
||||
for (const i in needles) {
|
||||
if (haystack.includes(needles[i])) return true;
|
||||
@@ -57,9 +78,6 @@ const containsAnyOf = (needles, haystack) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const showDatePicker = computed(() => containsAnyOf(["YY", "M", "D"], props.mask));
|
||||
const showTimePicker = computed(() => containsAnyOf(["H", "h", "m"], props.mask));
|
||||
|
||||
const setOriginalValues = () => {
|
||||
originalDate = dateModel.value;
|
||||
originalTime = timeModel.value;
|
||||
@@ -120,21 +138,43 @@ watch(() => props.modelValue, (newVal) => {
|
||||
|
||||
<template #default>
|
||||
<q-popup-proxy ref="popupProxy" @hide="emitModelUpdate" @show="setOriginalValues">
|
||||
<div class="proxy-wrapper">
|
||||
<div class="row">
|
||||
<div class="col-12 col-sm-6" v-if="showDatePicker">
|
||||
<q-date square v-model="dateModel" :options="isDateValid" :today-btn="true" flat mask="YYYY-MM-DD" :locale="myLocale"/>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6" v-if="showTimePicker">
|
||||
<q-time square v-model="timeModel" format24h flat mask="HH:mm:ss"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-sm">
|
||||
<div class="proxy-wrapper relative-position" :style="`min-height: ${showTimePicker ? '500' : '300'}px`">
|
||||
|
||||
<q-tabs v-model="tab" v-if="showTimePicker" class="bg-primary text-white">
|
||||
<q-tab name="date" icon="event" :label="dateModel ? formatDatetime(dateModel, 'D. M.', FORMAT_DATE_ISO) : 'datum'"/>
|
||||
<q-tab name="time" v-if="showTimePicker" icon="schedule" :label="model ? formatDatetime(model, 'HH:mm') : 'čas'"/>
|
||||
</q-tabs>
|
||||
|
||||
<q-separator/>
|
||||
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
<q-tab-panel name="date">
|
||||
<q-date
|
||||
minimal
|
||||
flat
|
||||
v-model="dateModel"
|
||||
:options="isDateValid"
|
||||
mask="YYYY-MM-DD"
|
||||
:locale="myLocale"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="time" v-if="showTimePicker">
|
||||
<q-time
|
||||
v-model="timeModel"
|
||||
format24h
|
||||
flat
|
||||
mask="HH:mm:ss"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
<div class="row q-mb-sm absolute-bottom">
|
||||
<div class="col">
|
||||
<q-btn color="secondary" class="q-ml-sm" @click="cancelSelection">Zrušit</q-btn>
|
||||
</div>
|
||||
<div class="col text-right">
|
||||
<q-btn color="primary" class="q-mr-sm" @click="emitModelUpdate">Nastavit</q-btn>
|
||||
<q-btn color="primary" class="q-mr-sm" :disable="!dateModel || !timeModel" @click="emitModelUpdate">Nastavit</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,6 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const track = ref([]);
|
||||
const speed = ref([]);
|
||||
const elevation = ref([]);
|
||||
|
||||
const bounds = computed(() => {
|
||||
const photosTransformed = props.photos.map(p => ({position: {lat: p.gpsLatitude, lng: p.gpsLongitude}}));
|
||||
const allData = [...props.markers, ...track.value, ...photosTransformed];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<q-item
|
||||
clickable
|
||||
:active="isButtonActive"
|
||||
tag="router-link"
|
||||
:to="to"
|
||||
>
|
||||
@@ -19,6 +20,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -40,5 +43,17 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const $route = useRoute();
|
||||
const $router = useRouter();
|
||||
const isButtonActive = computed(() => {
|
||||
const btnResolvedPath = $router.resolve(props.to).path;
|
||||
|
||||
if (props.to.name === 'myFlights') {
|
||||
return $route.name.toLowerCase().includes("flight");
|
||||
} else {
|
||||
return $route.fullPath === btnResolvedPath || $route.fullPath.startsWith(btnResolvedPath);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-tabs class="q-mb-md">
|
||||
<q-route-tab icon="flight_takeoff" :to="{name: 'publicProfileDetail', params: {username: username}}">Lety</q-route-tab>
|
||||
<q-route-tab icon="event" :to="{name: 'publicEventsList', params: {username: username}}">Události</q-route-tab>
|
||||
</q-tabs>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import NavigationButton from "components/navigation/NavigationButton.vue";
|
||||
|
||||
|
||||
const linksList = [
|
||||
{
|
||||
title: 'Moje lety',
|
||||
icon: "flight_takeoff",
|
||||
to: {name: "myFlights"}
|
||||
},
|
||||
{separator: true},
|
||||
|
||||
{
|
||||
title: 'Letadla',
|
||||
icon: "connecting_airports",
|
||||
to: {name: "aircrafts"}
|
||||
},
|
||||
{
|
||||
title: 'Zajímavá místa',
|
||||
icon: "place",
|
||||
to: {name: "poi"}
|
||||
},
|
||||
{
|
||||
title: 'Spolucestující',
|
||||
icon: "people",
|
||||
to: {name: "copilots"}
|
||||
},
|
||||
{separator: true},
|
||||
{
|
||||
title: 'Události',
|
||||
icon: 'event',
|
||||
to: {name: "events"}
|
||||
},
|
||||
{separator: true},
|
||||
{
|
||||
title: 'Organizace',
|
||||
icon: "groups",
|
||||
to: {name: "organizations"}
|
||||
},
|
||||
{separator: true},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-list class="q-my-lg">
|
||||
<template v-for="link in linksList" :key="link.title">
|
||||
<q-separator v-if="link.separator"/>
|
||||
<NavigationButton
|
||||
v-if="!link.separator"
|
||||
v-bind="link"
|
||||
/>
|
||||
</template>
|
||||
</q-list>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
|
||||
const props = defineProps({
|
||||
loggedUser: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const emits = defineEmits(["logout"]);
|
||||
const logout = () => emits("logout");
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-btn flat>
|
||||
<AvatarFromPhoto v-if="loggedUser.avatarImageUrl" :size="32" :src="loggedUser.avatarImageUrl"/>
|
||||
<q-icon name="person" v-else/>
|
||||
|
||||
<q-menu auto-close>
|
||||
<q-list style="min-width: 200px">
|
||||
<q-item clickable :to="{name: 'profile'}">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="person"/>
|
||||
</q-item-section>
|
||||
<q-item-section>Osobní nastavení</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable @click="logout" class="q-mt-lg">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
Odhlásit
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card class="flight-detail-card">
|
||||
<BasicListSkeleton :items-count-min="1" :items-count-max="5"/>
|
||||
</q-card>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<em>Copilot skeleton</em>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<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 {isRequestPending} from "src/composables/form/props";
|
||||
import {isRequired} from "src/composables/form/rules";
|
||||
import RichTextEditor from "components/inputs/RichTextEditor.vue";
|
||||
|
||||
const props = defineProps({
|
||||
organization: {
|
||||
type: Object
|
||||
},
|
||||
isRequestPending
|
||||
})
|
||||
|
||||
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,63 @@
|
||||
<script setup>
|
||||
import {formatDatetime} from "src/composables/datetimeUtils";
|
||||
import {roundWithPrecision} from "src/composables/utils";
|
||||
import EmbeddedMap from "components/map/EmbeddedMap.vue";
|
||||
|
||||
const props = defineProps({
|
||||
photo: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pswp-caption-content">
|
||||
<strong v-if="photo.name !== ''">{{ photo.name }}</strong>
|
||||
<article v-html="photo.description" v-if="photo.description !== ''"/>
|
||||
<q-list>
|
||||
<q-item v-if="photo.pointOfInterest">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="marker"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ photo.pointOfInterest.name }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="photo.exposedAt">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="photo_camera"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ formatDatetime(photo.exposedAt) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="photo.gpsAltitude">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="height"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
Nadmořská výška: {{ roundWithPrecision(photo.gpsAltitude, 1) }} m
|
||||
<br>
|
||||
<template v-if="photo.terrainElevation">
|
||||
Výška terénu: {{ roundWithPrecision(photo.terrainElevation, 1) }} m
|
||||
<br>
|
||||
Výška nad zemí: {{ roundWithPrecision(photo.gpsAltitude - photo.terrainElevation, 1) }} m
|
||||
</template>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
|
||||
<div style="height: 200px; width: 100%;">
|
||||
<EmbeddedMap
|
||||
v-if="photo.gpsLatitude && photo.gpsLongitude"
|
||||
height="200px"
|
||||
class="q-mb-sm"
|
||||
:gps-latitude="photo.gpsLatitude"
|
||||
:gps-longitude="photo.gpsLongitude"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,227 +0,0 @@
|
||||
<script setup>
|
||||
import EmbeddedMap from "components/map/EmbeddedMap.vue";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {formatDatetime} from "src/composables/datetimeUtils";
|
||||
import 'vue-inner-image-zoom/lib/vue-inner-image-zoom.css'
|
||||
import {useQuasar} from "quasar";
|
||||
|
||||
const props = defineProps({
|
||||
photos: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
openedIndex: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
defaultTitle: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"])
|
||||
|
||||
const isDetailOpened = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(newVal) {
|
||||
emit("update:modelValue", newVal);
|
||||
}
|
||||
})
|
||||
|
||||
const photoIndex = ref(null);
|
||||
onMounted(() => {
|
||||
photoIndex.value = props.openedIndex
|
||||
});
|
||||
|
||||
const photo = computed(() => props.photos[photoIndex.value]);
|
||||
|
||||
const nextPhoto = () => {
|
||||
if (imageFullscreen.value) return;
|
||||
|
||||
if (photoIndex.value < props.photos.length - 1)
|
||||
photoIndex.value++
|
||||
}
|
||||
|
||||
const prevPhoto = () => {
|
||||
if (imageFullscreen.value) return;
|
||||
if (photoIndex.value > 0) photoIndex.value--;
|
||||
}
|
||||
|
||||
const showDetail = (idx) => {
|
||||
photoIndex.value = idx;
|
||||
}
|
||||
|
||||
const showExtra = ref(false);
|
||||
|
||||
const $q = useQuasar();
|
||||
const isMaximized = computed(() => $q.screen.width <= $q.screen.sizes.sm);
|
||||
const imageFullscreen = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-dialog
|
||||
v-model="isDetailOpened"
|
||||
full-width
|
||||
:maximized="isMaximized"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="bg-black text-white" v-if="isMaximized">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<strong>{{ photo.name || defaultTitle }}</strong>
|
||||
</div>
|
||||
<div class="col-1 text-right">
|
||||
<q-btn round text-color="white" size="sm" icon="close" @click="isDetailOpened = false"/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section :class="{'q-pa-none': isMaximized}">
|
||||
<div :class="{'relative-position': true, 'image-full-height': imageFullscreen}">
|
||||
<div
|
||||
class="text-center"
|
||||
v-touch-swipe.mouse.right="prevPhoto"
|
||||
v-touch-swipe.mouse.left="nextPhoto"
|
||||
@keyup.left="prevPhoto"
|
||||
@keyup.right="nextPhoto">
|
||||
<inner-image-zoom
|
||||
:move-type="isMaximized ? 'drag' : 'pan'"
|
||||
:zoomScale="0.75"
|
||||
:hide-hint="true"
|
||||
:after-zoom-in="() => imageFullscreen = true"
|
||||
:after-zoom-out="() => imageFullscreen = false"
|
||||
:src="photo.url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="!imageFullscreen">
|
||||
<q-btn
|
||||
class="absolute all-pointer-events"
|
||||
round
|
||||
flat
|
||||
icon="info"
|
||||
:text-color="showExtra ? 'primary' : 'white'"
|
||||
style="background: rgba(0, 0, 0, 0.25); bottom: 16px; left: 8px"
|
||||
@click="showExtra = !showExtra"
|
||||
/>
|
||||
<q-btn
|
||||
class="absolute all-pointer-events"
|
||||
style="background: rgba(0, 0, 0, 0.25); top: 50%; left: 8px;"
|
||||
@click="prevPhoto"
|
||||
v-if="photoIndex > 0 && !isMaximized"
|
||||
icon="chevron_left"
|
||||
text-color="white"
|
||||
flat
|
||||
rounded
|
||||
/>
|
||||
<q-btn
|
||||
class="absolute all-pointer-events"
|
||||
style="background: rgba(0, 0, 0, 0.25); top: 50%; right: 8px;"
|
||||
@click="nextPhoto"
|
||||
v-if="photoIndex < photos.length - 1 && !isMaximized"
|
||||
icon-right="chevron_right"
|
||||
text-color="white"
|
||||
flat
|
||||
rounded
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="row" v-show="!imageFullscreen">
|
||||
<div class="col-4 col-md-1 q-pa-sm" v-for="(thumb, idx) in photos" :key="thumb.thumbnailUrl">
|
||||
<q-img
|
||||
:src="thumb.thumbnailUrl"
|
||||
@click="showDetail(idx)"
|
||||
:class='{"shadow-5": true, "active-thumbnail": idx === photoIndex}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 q-my-md" v-if="showExtra">
|
||||
<EmbeddedMap
|
||||
v-if="photo.gpsLatitude && photo.gpsLongitude"
|
||||
class="q-mx-sm"
|
||||
:gps-latitude="photo.gpsLatitude"
|
||||
:gps-longitude="photo.gpsLongitude"
|
||||
/>
|
||||
|
||||
<strong v-if="photo.name !== '' && !isMaximized">{{ photo.name }}</strong>
|
||||
<p v-if="photo.description !== ''">{{ photo.description }}</p>
|
||||
|
||||
<q-list dense>
|
||||
<q-item v-if="photo.pointOfInterest">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="marker"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ photo.pointOfInterest.name }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="photo.exposedAt">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="photo_camera"></q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ formatDatetime(photo.exposedAt) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item v-if="photo.gpsAltitude">
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="height"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
Nadmořská výška: {{ photo.gpsAltitude }} m
|
||||
<br>
|
||||
<template v-if="photo.terrainElevation">
|
||||
Výška terénu: {{ photo.terrainElevation }} m
|
||||
<br>
|
||||
Výška nad zemí: {{ photo.gpsAltitude - photo.terrainElevation }} m
|
||||
</template>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.active-thumbnail {
|
||||
outline: solid 2px
|
||||
}
|
||||
|
||||
.iiz {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-full-height .iiz {
|
||||
height: calc(95vh - 65px) !important;
|
||||
}
|
||||
|
||||
.iiz__img {
|
||||
max-height: 90vh !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.iiz__img--hidden {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.iiz__zoom-img--visible {
|
||||
//height: 125vh !important
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
||||
import 'photoswipe/style.css';
|
||||
import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css";
|
||||
import PhotoSwipeDynamicCaption from "photoswipe-dynamic-caption-plugin";
|
||||
import MapWithMarkers from "components/map/MapWithMarkers.vue";
|
||||
import {formatDatetime} from "src/composables/datetimeUtils";
|
||||
import EmbeddedMap from "components/map/EmbeddedMap.vue";
|
||||
import {roundWithPrecision} from "../../composables/utils";
|
||||
import PhotoCaption from "components/photos/PhotoCaption.vue";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
galleryId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => "randomGalleryId"
|
||||
},
|
||||
photos: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
let $lightbox = null;
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
if (!$lightbox) {
|
||||
$lightbox = new PhotoSwipeLightbox({
|
||||
gallery: '#' + props.galleryId,
|
||||
children: 'a',
|
||||
pswpModule: () => import('photoswipe'),
|
||||
});
|
||||
|
||||
let captionPlugin = new PhotoSwipeDynamicCaption($lightbox, {
|
||||
type: 'auto',
|
||||
});
|
||||
|
||||
$lightbox.init();
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if ($lightbox) {
|
||||
$lightbox.destroy();
|
||||
$lightbox = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :id="galleryId" class="row">
|
||||
<div class="col-6 col-sm-4 col-md-4 col-lg-3 q-pa-sm" v-for="photo in photos" :key="photo.id">
|
||||
<a
|
||||
:href="photo.url"
|
||||
:data-pswp-width="photo.width"
|
||||
:data-pswp-height="photo.height"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<q-img
|
||||
spinner-color="primary"
|
||||
class="rounded-borders"
|
||||
:src="photo.thumbnailUrl"
|
||||
alt=""
|
||||
>
|
||||
<div class="absolute-bottom" v-if="photo.name !== ''">
|
||||
{{ photo.name }}
|
||||
</div>
|
||||
</q-img>
|
||||
<PhotoCaption :photo="photo"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, reactive, ref, watch} from "vue";
|
||||
import {useQuasar} from "quasar";
|
||||
import {confirmObject} from "src/composables/dialog";
|
||||
|
||||
const $q = useQuasar();
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
photos: {
|
||||
type: Array
|
||||
},
|
||||
});
|
||||
|
||||
const batchEdit = ref({});
|
||||
|
||||
onMounted(() => {
|
||||
for (const photo of props.photos) {
|
||||
batchEdit.value[photo.id] = false;
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["edit", "delete", "batch-edit", "batch-rotate"])
|
||||
|
||||
const openEdit = (photo) => {
|
||||
emits("edit", photo);
|
||||
};
|
||||
|
||||
|
||||
const confirmPhotoDelete = (id) => {
|
||||
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
|
||||
.onOk(() => {
|
||||
emits("delete", id);
|
||||
});
|
||||
}
|
||||
|
||||
const confirmBatchPhotoDelete = () => {
|
||||
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?"))
|
||||
.onOk(() => {
|
||||
for (const id in batchEdit.value) {
|
||||
if (!batchEdit.value[id]) continue;
|
||||
emits("delete", parseInt(id));
|
||||
batchEdit.value[id] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleImageClick = (photo, idx) => {
|
||||
batchEdit.value[photo.id] = !batchEdit.value[photo.id];
|
||||
}
|
||||
|
||||
const selectedPhotos = computed(() => {
|
||||
const photos = [];
|
||||
for (const i in batchEdit.value) {
|
||||
const photo = props.photos.find(p => p.id === parseInt(i));
|
||||
if (photo && batchEdit.value[i]) photos.push(photo);
|
||||
}
|
||||
|
||||
return photos;
|
||||
});
|
||||
|
||||
const openBatchEdit = () => {
|
||||
const photos = selectedPhotos.value;
|
||||
for (const i in batchEdit.value) {
|
||||
batchEdit.value[i] = false;
|
||||
}
|
||||
emits('batch-edit', photos);
|
||||
}
|
||||
|
||||
const batchRotate = (direction) => {
|
||||
const photos = selectedPhotos.value;
|
||||
emits("batch-rotate", {photos, direction});
|
||||
}
|
||||
|
||||
const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0);
|
||||
|
||||
const checkAll = ref(false);
|
||||
watch(() => checkAll.value, (newVal) => {
|
||||
if (newVal === null) return;
|
||||
|
||||
for (const photo of props.photos) {
|
||||
batchEdit.value[photo.id] = newVal;
|
||||
}
|
||||
});
|
||||
watch(() => batchEdit.value, () => {
|
||||
let checkedAll = true;
|
||||
let uncheckedAll = true;
|
||||
|
||||
for (const i of Object.keys(batchEdit.value)) {
|
||||
if (typeof batchEdit.value[i] === 'undefined') continue;
|
||||
|
||||
if (batchEdit.value[i]) uncheckedAll = false;
|
||||
if (!batchEdit.value[i]) checkedAll = false;
|
||||
}
|
||||
|
||||
if (checkedAll) {
|
||||
checkAll.value = true;
|
||||
} else if (uncheckedAll) {
|
||||
checkAll.value = false;
|
||||
} else {
|
||||
checkAll.value = null;
|
||||
}
|
||||
}, {deep: true});
|
||||
|
||||
const batchOptionsProps = computed(() => ({
|
||||
round: true,
|
||||
flat: true,
|
||||
class: "q-ml-lg",
|
||||
disable: !isBatchEditAllowed.value
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row" style="margin-bottom: 150px">
|
||||
<div class="col-6 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id">
|
||||
<q-card :class='{"q-ma-sm": true, "shadow-1": !batchEdit[photo.id], "shadow-10": batchEdit[photo.id]}'>
|
||||
<q-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)" style="cursor: pointer">
|
||||
<div class="absolute-bottom" v-if="photo.name !== '' || photo.description !== ''">
|
||||
<div class="text-h6" v-if="photo.name !== ''">
|
||||
{{ photo.name }}
|
||||
</div>
|
||||
<div class="text-subtitle2" v-if="photo.description !== ''">
|
||||
{{ photo.description }}
|
||||
</div>
|
||||
</div>
|
||||
</q-img>
|
||||
|
||||
<q-card-actions align="right" :class="{'bg-primary': batchEdit[photo.id], 'text-white': batchEdit[photo.id]}">
|
||||
<div class="col-grow">
|
||||
<q-checkbox v-model="batchEdit[photo.id]"/>
|
||||
<q-icon name="star" v-if="photo.isFlightCover" color="red"/>
|
||||
</div>
|
||||
<q-btn flat icon="edit" color="black" :to="{name: 'photoEditor', params: {id: photo.id}}"/>
|
||||
<q-btn flat icon="info" color="blue" @click="openEdit(photo)"/>
|
||||
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-page-sticky :offset="[50, 50]" position="bottom" v-if="photos.length > 0">
|
||||
<q-card>
|
||||
<q-card-actions>
|
||||
<q-checkbox v-model="checkAll" label="Vybrat vše"/>
|
||||
<q-btn v-bind="batchOptionsProps" icon="info" :color="isBatchEditAllowed ? 'primary' : 'grey'" @click="openBatchEdit"/>
|
||||
<q-btn v-bind="batchOptionsProps" icon="rotate_90_degrees_ccw" :color="isBatchEditAllowed ? 'secondary' : 'grey'" @click="batchRotate('counterClockwise')"/>
|
||||
<q-btn v-bind="batchOptionsProps" icon="rotate_90_degrees_cw" :color="isBatchEditAllowed ? 'secondary' : 'grey'" @click="batchRotate('clockwise')"/>
|
||||
<q-btn v-bind="batchOptionsProps" icon="delete" :color="isBatchEditAllowed ? 'red' : 'grey'" @click="confirmBatchPhotoDelete"/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-page-sticky>
|
||||
</template>
|
||||
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import {useQuasar} from "quasar";
|
||||
import {confirmObject} from "src/composables/dialog";
|
||||
|
||||
const props = defineProps({
|
||||
photos: {
|
||||
type: Array
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const batchEdit = ref({});
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
for (const photo of props.photos) {
|
||||
batchEdit.value[photo.id] = false;
|
||||
}
|
||||
});
|
||||
|
||||
const emits = defineEmits(["edit", "delete", "batch-edit"])
|
||||
|
||||
const openEdit = (photo) => {
|
||||
emits("edit", photo);
|
||||
};
|
||||
|
||||
const $q = useQuasar();
|
||||
|
||||
const confirmPhotoDelete = (id) => {
|
||||
$q.dialog(confirmObject("Opravdu chces smazat tuto fotku?"))
|
||||
.onOk(() => {
|
||||
emits("delete", id);
|
||||
});
|
||||
}
|
||||
|
||||
const confirmBatchPhotoDelete = () => {
|
||||
$q.dialog(confirmObject("Opravdu chces smazat vybrané fotky?" + JSON.stringify(batchEdit.value)))
|
||||
.onOk(() => {
|
||||
for (const id in batchEdit.value) {
|
||||
if (!batchEdit.value[id]) continue;
|
||||
emits("delete", parseInt(id));
|
||||
batchEdit.value[id] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleImageClick = (photo, idx) => {
|
||||
if (props.showActions) {
|
||||
batchEdit.value[photo.id] = !batchEdit.value[photo.id];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const openBatchEdit = () => {
|
||||
const photos = [];
|
||||
for (const i in batchEdit.value) {
|
||||
const photo = props.photos.find(p => p.id === parseInt(i));
|
||||
if (photo && batchEdit.value[i]) photos.push(photo);
|
||||
batchEdit.value[i] = false;
|
||||
}
|
||||
emits('batch-edit', photos);
|
||||
}
|
||||
|
||||
const isBatchEditAllowed = computed(() => Object.values(batchEdit.value).filter(v => v).length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row q-my-md">
|
||||
<div class="col text-right">
|
||||
<q-btn round flat icon="edit" :color="isBatchEditAllowed ? 'primary' : 'grey'" :disable="!isBatchEditAllowed" @click="openBatchEdit" v-if="showActions"/>
|
||||
<q-btn round flat icon="delete" :color="isBatchEditAllowed ? 'red' : 'grey'" :disable="!isBatchEditAllowed" @click="confirmBatchPhotoDelete" v-if="showActions"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-sm-4 col-md-4 col-lg-3" v-for="(photo, idx) in photos" :key="photo.id">
|
||||
<q-card :class='{"q-ma-sm": true, "shadow-1": !batchEdit[photo.id], "shadow-10": batchEdit[photo.id]}'>
|
||||
<q-img :src="photo.thumbnailUrl" @click="handleImageClick(photo, idx)">
|
||||
<div class="absolute-bottom" v-if="photo.name !== '' || photo.description !== ''">
|
||||
<div class="text-h6" v-if="photo.name !== ''">
|
||||
{{ photo.name }}
|
||||
</div>
|
||||
<div class="text-subtitle2" v-if="photo.description !== ''">
|
||||
{{ photo.description }}
|
||||
</div>
|
||||
</div>
|
||||
</q-img>
|
||||
|
||||
<q-card-actions v-if="showActions" align="right" :class="{'bg-primary': batchEdit[photo.id], 'text-white': batchEdit[photo.id]}">
|
||||
<div class="col-grow">
|
||||
<q-checkbox v-model="batchEdit[photo.id]"/>
|
||||
<q-icon name="star" v-if="photo.isFlightCover" color="red"/>
|
||||
<q-icon name="place" v-if="photo.pointOfInterestId"/>
|
||||
<q-icon name="gps_fixed" v-if="photo.gpsLatitude && photo.gpsLongitude"/>
|
||||
<q-icon name="gps_off" v-else/>
|
||||
</div>
|
||||
|
||||
<q-btn flat icon="edit" color="blue" @click="openEdit(photo)"></q-btn>
|
||||
<q-btn flat icon="delete" color="red" @click="confirmPhotoDelete(photo.id)"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import {user as pilot} from "src/composables/objects/user";
|
||||
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
|
||||
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import FormWrapper from "components/FormWrapper.vue";
|
||||
import {commonProps} from "src/composables/inputs";
|
||||
import {reactive, watch} from "vue";
|
||||
import {setDefaultData} from "src/composables/form/formHandling";
|
||||
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
import {isRequestPending} from "src/composables/form/props";
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -10,7 +10,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const kmhToKnots = (speedKmh) => {
|
||||
return Math.round(speedKmh / 0.53996);
|
||||
return Math.round(speedKmh * 0.53996);
|
||||
}
|
||||
|
||||
const openDetail = ref(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {date} from "quasar";
|
||||
export const FORMAT_ISO = "YYYY-MM-DDTHH:mm:ss";
|
||||
export const FORMAT_DATETIME_LONG = "DD. MM. YYYY, HH:mm";
|
||||
export const FORMAT_DATE = "DD. MM. YYYY";
|
||||
export const FORMAT_DATE_ISO = "YYYY-MM-DD";
|
||||
export const FORMAT_TIME = "HH:mm"
|
||||
|
||||
export const minutesToHumanReadable = (totalMinutes) => {
|
||||
@@ -45,9 +46,9 @@ export const parseDate = (datetimeStr, format = FORMAT_ISO) => {
|
||||
return date.extractDate(datetimeStr, format)
|
||||
}
|
||||
|
||||
export const formatDatetime = (datetime, format = FORMAT_DATETIME_LONG) => {
|
||||
export const formatDatetime = (datetime, format = FORMAT_DATETIME_LONG, inputFormat = FORMAT_ISO) => {
|
||||
if (typeof datetime === 'string') {
|
||||
datetime = parseDate(datetime);
|
||||
datetime = parseDate(datetime, inputFormat);
|
||||
}
|
||||
return date.formatDate(datetime, format);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
|
||||
import {useEventsStore} from "stores/Events";
|
||||
import {ref} from "vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {confirmObject} from "src/composables/dialog";
|
||||
import {useAppStore} from "stores/App";
|
||||
export const loading = ref(true);
|
||||
export const event = ref(null);
|
||||
export const fetchEvent = (eventId, username) => {
|
||||
loading.value = true;
|
||||
return useEventsStore().fetchDetail(parseInt(eventId), username)
|
||||
.then(respEvent => {
|
||||
event.value = respEvent;
|
||||
return respEvent;
|
||||
})
|
||||
.finally(() => loading.value = false);
|
||||
};
|
||||
|
||||
export const events = storeToRefs(useEventsStore()).items;
|
||||
|
||||
export const fetchEvents = (username) => {
|
||||
loading.value = true;
|
||||
useEventsStore().fetch(username).finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const handleDeleteEvent = ($q, eventId, onOkCallback = () => {
|
||||
}) => {
|
||||
$q.dialog(confirmObject(`Opravdu chceš smazat tento let?`))
|
||||
.onOk(() => {
|
||||
useEventsStore().delete(eventId).then(onOkCallback);
|
||||
});
|
||||
};
|
||||
|
||||
export const isRequestPending = storeToRefs(useEventsStore()).isRequestPending;
|
||||
@@ -3,14 +3,13 @@ import {useFlightsStore} from "stores/Flights";
|
||||
import {ref} from "vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {confirmObject} from "src/composables/dialog";
|
||||
import {useAppStore} from "stores/App";
|
||||
|
||||
export const flightName = (flight) => {
|
||||
const date = formatDatetime(flight.takeoffDatetime, FORMAT_DATE);
|
||||
if (flight.name !== '') {
|
||||
return `${flight.name} (${date})`
|
||||
} else {
|
||||
const track = flight.track ? flight.track.map(t => t.pointOfInterest.name) : [];
|
||||
const track = flight.track ? flight.track.map(t => t.pointOfInterest?.name || t.airport?.icaoCode) : [];
|
||||
const trackStr = track.length > 0 ? ` - ${track.join(" - ")} - ` : ' - ';
|
||||
|
||||
return `${flight.takeoffAirport.icaoCode} ${trackStr} ${flight.landingAirport.icaoCode} (${date})`;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {computed, ref} from "vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useOrganizationsStore} from "stores/Organizations";
|
||||
import {useUserStore} from "stores/User";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
|
||||
export const loading = ref(true);
|
||||
export const organization = ref(null);
|
||||
export const fetchOrganization = (organizationId) => {
|
||||
loading.value = true;
|
||||
return useOrganizationsStore().fetchDetail(parseInt(organizationId))
|
||||
.then(resp => {
|
||||
organization.value = resp;
|
||||
return resp;
|
||||
})
|
||||
.finally(() => loading.value = false);
|
||||
}
|
||||
|
||||
export const organizations = storeToRefs(useOrganizationsStore()).items;
|
||||
export const fetchOrganizations = () => {
|
||||
loading.value = true;
|
||||
return useOrganizationsStore().fetch().finally(() => loading.value = false);
|
||||
}
|
||||
|
||||
export const isRequestPending = storeToRefs(useOrganizationsStore()).isMutationRunning;
|
||||
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
export const myOrganizationIds = computed(() => loggedUser.value.organizations?.map(o => o.id) || []);
|
||||
|
||||
export const addToOrganization = async (organizationId) => {
|
||||
useOrganizationsStore().addUserToOrganization(organizationId).then((data) => {
|
||||
organization.value.users = data.users;
|
||||
useUserStore().fetchLoggedUserDetail();
|
||||
});
|
||||
};
|
||||
|
||||
export const removeFromOrganization = async (organizationId) => {
|
||||
useOrganizationsStore().removeUserFromOrganization(organizationId).then((data) => {
|
||||
organization.value.users = data.users;
|
||||
useUserStore().fetchLoggedUserDetail();
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {ref} from "vue";
|
||||
import {usePhotosStore} from "stores/Photo";
|
||||
import {fetchFlight} from "src/composables/objects/flight";
|
||||
import {usePointsOfInterestStore} from "stores/PointsOfInterest";
|
||||
|
||||
export const openBatchEdit = ref(false);
|
||||
export const editBatchPhotos = ref([]);
|
||||
@@ -9,11 +10,20 @@ export const openBatchPhotoForm = (photos) => {
|
||||
editBatchPhotos.value = photos;
|
||||
}
|
||||
|
||||
export const handleBatchPhotoEdit = (flightId, data) => {
|
||||
export const handleBatchPhotoEdit = async (flightId, data) => {
|
||||
const photoIds = data.photoIds;
|
||||
delete data.photoIds;
|
||||
delete data.id;
|
||||
|
||||
if (data.pointOfInterest && !data.pointOfInterest.id) {
|
||||
const poiData = {
|
||||
...data.pointOfInterest,
|
||||
isPublic: false,
|
||||
description: "",
|
||||
}
|
||||
await usePointsOfInterestStore().create(poiData).then(poi => data.pointOfInterest = {id: poi.id, name: poi.name});
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (const id of photoIds) {
|
||||
const updateData = {};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {computed, ref, watch} from "vue";
|
||||
import {cropper} from "vue-picture-cropper";
|
||||
import {roundWithPrecision} from "src/composables/utils";
|
||||
import {photoAspectRatio} from "src/composables/photos/editor/photo";
|
||||
|
||||
export const cropperImgSizes = computed(() => [cropper.getImageData().width, cropper.getImageData().height]);
|
||||
export const croppedArea = ref(null);
|
||||
export const selectedAspectRatio = ref(null);
|
||||
|
||||
watch(() => selectedAspectRatio.value, (newVal) => {
|
||||
cropper.setAspectRatio(newVal);
|
||||
})
|
||||
|
||||
|
||||
export const setCropData = () => {
|
||||
const [w, h] = cropperImgSizes.value;
|
||||
|
||||
console.log("crop end");
|
||||
|
||||
const cropData = cropper.getCropBoxData();
|
||||
croppedArea.value = {
|
||||
left: roundWithPrecision(cropData.left / w, 5),
|
||||
top: roundWithPrecision(cropData.top / h, 5),
|
||||
width: roundWithPrecision(cropData.width / w, 5),
|
||||
height: roundWithPrecision(cropData.height / h, 5)
|
||||
};
|
||||
}
|
||||
|
||||
export const aspectRatioOptions = computed(() =>
|
||||
[
|
||||
{label: '16:9', value: roundWithPrecision(16 / 9, 5)},
|
||||
{label: '4:3', value: roundWithPrecision(4 / 3, 5)},
|
||||
{label: '9:16', value: roundWithPrecision(9 / 16, 5)},
|
||||
{label: '3:4', value: roundWithPrecision(3 / 4, 5)},
|
||||
{label: '1:1', value: 1},
|
||||
{label: 'Volný', value: null},
|
||||
{label: 'Původní', value: roundWithPrecision(photoAspectRatio.value, 5)},
|
||||
]
|
||||
);
|
||||
|
||||
export const cropperReady = (e) => {
|
||||
if (croppedArea.value) {
|
||||
const [w, h] = cropperImgSizes.value;
|
||||
selectedAspectRatio.value = roundWithPrecision((croppedArea.value.width * w) / (croppedArea.value.height * h), 5);
|
||||
|
||||
cropper.setCropBoxData({
|
||||
left: croppedArea.value.left * w,
|
||||
top: croppedArea.value.top * h,
|
||||
width: croppedArea.value.width * w,
|
||||
height: croppedArea.value.height * h
|
||||
});
|
||||
} else {
|
||||
selectedAspectRatio.value = roundWithPrecision(photoAspectRatio.value, 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {computed, ref} from "vue";
|
||||
import {usePhotosStore} from "stores/Photo";
|
||||
|
||||
export const loading = ref(true);
|
||||
export const photo = ref(null);
|
||||
export const fetchPhoto = (id) => {
|
||||
loading.value = true;
|
||||
return usePhotosStore().fetchDetail(id)
|
||||
.then(resp => {
|
||||
photo.value = resp;
|
||||
return resp;
|
||||
})
|
||||
.finally(() => loading.value = false);
|
||||
};
|
||||
|
||||
export const photoAspectRatio = computed(() => photo.value.width / photo.value.height);
|
||||
@@ -0,0 +1,18 @@
|
||||
import {computed} from "vue";
|
||||
import {croppedArea} from "src/composables/photos/editor/crop";
|
||||
|
||||
export const getPreviewUrl = (photoId, adjustments) => {
|
||||
const url = new URL(`${process.env.API_URL}/photo/editor-preview/${photoId}`);
|
||||
url.search = new URLSearchParams(adjustments).toString();
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const croppedAreaForPreview = computed(() => {
|
||||
if (!croppedArea.value) return {};
|
||||
return {
|
||||
crop_left: croppedArea.value.left || 0,
|
||||
crop_top: croppedArea.value.top || 0,
|
||||
crop_width: croppedArea.value.width || 1,
|
||||
crop_height: croppedArea.value.height || 1
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import {computed, ref} from "vue";
|
||||
import {photoAspectRatio} from "src/composables/photos/editor/photo";
|
||||
|
||||
export const compare = ref(false);
|
||||
|
||||
export const splitterWidth = ref(750); // TODO: doresit onresize a zmenu velikosti
|
||||
export const splitter = ref(50);
|
||||
export const onResize = (info) => {
|
||||
splitterWidth.value = info.width < 750 ? info.width : 750;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const roundWithPrecision = (number, precision) => {
|
||||
const factor = Math.pow(10, precision);
|
||||
return Math.round(number * factor) / factor;
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// to match your app's branding.
|
||||
// Tip: Use the "Theme Builder" on Quasar's documentation website.
|
||||
|
||||
$primary : #71bbde; //#1976D2;
|
||||
$primary : #87c8ec; //#1976D2;
|
||||
$secondary : #26A69A;
|
||||
$accent : #9C27B0;
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ fragment aircraftFullInfo on Aircraft {
|
||||
manufacturer
|
||||
photoUrl
|
||||
seats
|
||||
createdById
|
||||
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -17,5 +23,10 @@ fragment aircraftBasicInfo on Aircraft {
|
||||
id
|
||||
callSign
|
||||
manufacturer
|
||||
|
||||
organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
export const FRAGMENT_EVENT_LIST_INFO = gql`
|
||||
fragment eventListInfo on Event {
|
||||
id
|
||||
name
|
||||
description
|
||||
isPublic
|
||||
}
|
||||
`;
|
||||
|
||||
export const FRAGMENT_EVENT_FULL_INFO = gql`
|
||||
fragment eventFullInfo on Event {
|
||||
id
|
||||
name
|
||||
description
|
||||
isPublic
|
||||
dateFrom
|
||||
dateTo
|
||||
}
|
||||
`;
|
||||
@@ -41,7 +41,12 @@ fragment flightFullInfo on Flight {
|
||||
id
|
||||
name
|
||||
}
|
||||
gpxTrackUrl
|
||||
|
||||
event {
|
||||
id
|
||||
name
|
||||
}
|
||||
|
||||
gpxTrack {
|
||||
coordinates {
|
||||
lat
|
||||
@@ -62,6 +67,10 @@ fragment flightFullInfo on Flight {
|
||||
pointOfInterest {
|
||||
...pointOfInterestFullInfo
|
||||
}
|
||||
airport {
|
||||
...airportInfo
|
||||
}
|
||||
landingDuration
|
||||
}
|
||||
|
||||
landingAirport {
|
||||
|
||||
@@ -19,6 +19,13 @@ fragment flightListInfo on Flight {
|
||||
pointOfInterest {
|
||||
name
|
||||
}
|
||||
airport {
|
||||
icaoCode
|
||||
}
|
||||
}
|
||||
|
||||
event {
|
||||
name
|
||||
}
|
||||
|
||||
takeoffAirport {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_AIRCRAFT_BASIC_INFO} from "src/graphql/fragments/aircraft";
|
||||
|
||||
export const FRAGMENT_ORGANIZATION_LIST_INFO = gql`
|
||||
fragment organizationListInfo on Organization {
|
||||
id
|
||||
name
|
||||
}
|
||||
`;
|
||||
|
||||
export const FRAGMENT_ORGANIZATION_FULL_INFO = gql`
|
||||
fragment organizationFullInfo on Organization {
|
||||
id
|
||||
name
|
||||
createdById
|
||||
|
||||
users {
|
||||
id
|
||||
name
|
||||
email
|
||||
avatarImageUrl
|
||||
}
|
||||
|
||||
aircrafts {
|
||||
...aircraftBasicInfo
|
||||
}
|
||||
}
|
||||
${FRAGMENT_AIRCRAFT_BASIC_INFO}
|
||||
`;
|
||||
|
||||
// TODO: na info o userovi pouzit fragment - stejne jako v mutaci na pridani/odebrani usera z organizace
|
||||
@@ -13,9 +13,12 @@ export const FRAGMENT_PHOTO_FULL_INFO = gql`
|
||||
id
|
||||
name
|
||||
}
|
||||
width
|
||||
height
|
||||
isFlightCover
|
||||
gpsLatitude
|
||||
gpsLongitude
|
||||
gpsAltitude
|
||||
terrainElevation
|
||||
exposedAt
|
||||
}`;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_EVENT_FULL_INFO} from "src/graphql/fragments/event";
|
||||
|
||||
export const CREATE_EVENT = gql`
|
||||
mutation createEvent($input: CreateEventInput!) {
|
||||
createEvent(input: $input) {
|
||||
...eventFullInfo
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_EVENT_FULL_INFO}
|
||||
`;
|
||||
|
||||
export const EDIT_EVENT = gql`
|
||||
mutation editEvent($id: Int!, $input: EditEventInput!) {
|
||||
editEvent(id: $id, input: $input) {
|
||||
...eventFullInfo
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_EVENT_FULL_INFO}
|
||||
`;
|
||||
export const DELETE_EVENT = gql`
|
||||
mutation deleteEvent($id: Int!) {
|
||||
deleteEvent(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,54 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_ORGANIZATION_FULL_INFO} from "src/graphql/fragments/organization";
|
||||
|
||||
export const CREATE_ORGANIZATION = gql`
|
||||
mutation createOrganization($input: CreateOrganizationInput!) {
|
||||
createOrganization(input: $input) {
|
||||
...organizationFullInfo
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_ORGANIZATION_FULL_INFO}
|
||||
`;
|
||||
|
||||
export const EDIT_ORGANIZATION = gql`
|
||||
mutation editOrganization($id: Int!, $input: EditOrganizationInput!) {
|
||||
editOrganization(id: $id, input: $input) {
|
||||
...organizationFullInfo
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_ORGANIZATION_FULL_INFO}
|
||||
`;
|
||||
|
||||
export const ADD_USER_TO_ORGANIZATION = gql`
|
||||
mutation addToOrganization($organizationId: Int!) {
|
||||
addToOrganization(organizationId: $organizationId) {
|
||||
users {
|
||||
id
|
||||
name
|
||||
email
|
||||
avatarImageUrl
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
export const REMOVE_USER_FROM_ORGANIZATION = gql`
|
||||
mutation removeFromOrganization($organizationId: Int!) {
|
||||
removeFromOrganization(organizationId: $organizationId) {
|
||||
users {
|
||||
id
|
||||
name
|
||||
email
|
||||
avatarImageUrl
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
// export const DELETE_ORGANIZATION = gql`
|
||||
// mutation deleteOrganization($id: Int!) {
|
||||
// deleteOrganization(id: $id) {
|
||||
// id
|
||||
// }
|
||||
// }
|
||||
// `;
|
||||
@@ -1,5 +1,6 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
|
||||
import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list";
|
||||
|
||||
export const UPLOAD_PHOTO = gql`
|
||||
mutation uploadPhoto($input: UploadPhotoInput!) {
|
||||
@@ -21,6 +22,50 @@ mutation editPhoto($id: Int!, $input: EditPhotoInput!) {
|
||||
${FRAGMENT_PHOTO_FULL_INFO}
|
||||
`;
|
||||
|
||||
export const CHANGE_PHOTO_ORIENTATION = gql`
|
||||
mutation changeOrientation($id: Int!, $direction: String!) {
|
||||
changeOrientation(id: $id, direction: $direction) {
|
||||
...photoFullInfo
|
||||
|
||||
flight {
|
||||
...flightListInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_PHOTO_FULL_INFO}
|
||||
${FRAGMENT_FLIGHT_LIST_INFO}
|
||||
`;
|
||||
|
||||
export const ADJUST_PHOTO = gql`
|
||||
mutation adjustPhoto($id: Int!, $adjustment: AdjustmentInput!) {
|
||||
adjustPhoto(id: $id, adjustment: $adjustment) {
|
||||
...photoFullInfo
|
||||
|
||||
adjustment {
|
||||
contrast
|
||||
saturation
|
||||
brightness
|
||||
rotate
|
||||
sharpness
|
||||
cropLeft
|
||||
cropTop
|
||||
cropWidth
|
||||
cropHeight
|
||||
}
|
||||
|
||||
flight {
|
||||
...flightListInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_PHOTO_FULL_INFO}
|
||||
${FRAGMENT_FLIGHT_LIST_INFO}
|
||||
`;
|
||||
|
||||
// TODO: ^^ udelat z toho asi jeden fragment a pouzit i v query GET_PHOTO
|
||||
|
||||
export const DELETE_PHOTO = gql`
|
||||
mutation deletePhoto($id: Int!) {
|
||||
deletePhoto(id: $id) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import gql from "graphql-tag";
|
||||
// import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list";
|
||||
import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list";
|
||||
import {FRAGMENT_COPILOT_FULL_INFO, FRAGMENT_COPILOT_LIST_INFO} from "src/graphql/fragments/copilot";
|
||||
|
||||
export const COPILOTS_LIST = gql`
|
||||
@@ -18,12 +18,11 @@ query copilot($id: Int!) {
|
||||
...copilotFullInfo
|
||||
|
||||
flights {
|
||||
id
|
||||
name
|
||||
|
||||
...flightListInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_COPILOT_FULL_INFO}
|
||||
${FRAGMENT_FLIGHT_LIST_INFO}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list";
|
||||
import {FRAGMENT_EVENT_FULL_INFO, FRAGMENT_EVENT_LIST_INFO} from "src/graphql/fragments/event";
|
||||
|
||||
export const EVENTS_LIST = gql`
|
||||
query eventsList($username: String) {
|
||||
events(username: $username) {
|
||||
...eventListInfo
|
||||
|
||||
flights {
|
||||
coverPhoto {
|
||||
thumbnailUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_EVENT_LIST_INFO}
|
||||
`;
|
||||
|
||||
export const EVENT_DETAIL = gql`
|
||||
query event($id: Int!, $username: String) {
|
||||
event(id: $id, username: $username) {
|
||||
...eventFullInfo
|
||||
|
||||
flights {
|
||||
...flightListInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_EVENT_FULL_INFO}
|
||||
${FRAGMENT_FLIGHT_LIST_INFO}
|
||||
`;
|
||||
@@ -0,0 +1,22 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_ORGANIZATION_FULL_INFO, FRAGMENT_ORGANIZATION_LIST_INFO} from "src/graphql/fragments/organization";
|
||||
|
||||
export const ORGANIZATIONS_LIST = gql`
|
||||
query organizationsList {
|
||||
organizations {
|
||||
...organizationListInfo
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_ORGANIZATION_LIST_INFO}
|
||||
`;
|
||||
|
||||
export const ORGANIZATION_DETAIL = gql`
|
||||
query organization($id: Int!) {
|
||||
organization(id: $id) {
|
||||
...organizationFullInfo
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_ORGANIZATION_FULL_INFO}
|
||||
`;
|
||||
@@ -0,0 +1,30 @@
|
||||
import gql from "graphql-tag";
|
||||
import {FRAGMENT_PHOTO_FULL_INFO} from "src/graphql/fragments/photo";
|
||||
import {FRAGMENT_FLIGHT_LIST_INFO} from "src/graphql/fragments/flight_list";
|
||||
|
||||
export const PHOTO_DETAIL = gql`
|
||||
query photo($id: Int!) {
|
||||
photo(id: $id) {
|
||||
...photoFullInfo
|
||||
|
||||
adjustment {
|
||||
contrast
|
||||
saturation
|
||||
brightness
|
||||
rotate
|
||||
sharpness
|
||||
cropLeft
|
||||
cropTop
|
||||
cropWidth
|
||||
cropHeight
|
||||
}
|
||||
|
||||
flight {
|
||||
...flightListInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${FRAGMENT_PHOTO_FULL_INFO}
|
||||
${FRAGMENT_FLIGHT_LIST_INFO}
|
||||
`;
|
||||
@@ -16,6 +16,11 @@ export const LOGGED_USER_DETAIL = gql`
|
||||
query loggedUserDetail {
|
||||
loggedUser {
|
||||
...userFullInfo
|
||||
|
||||
organizations {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,31 +15,7 @@
|
||||
{{ pageTitle || "Polétání.cz" }}
|
||||
</q-toolbar-title>
|
||||
|
||||
<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>
|
||||
<TopMenuButton :logged-user="loggedUser" @logout="logout"/>
|
||||
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
@@ -50,18 +26,9 @@
|
||||
bordered
|
||||
>
|
||||
|
||||
<q-img class="absolute-top" :src="loggedUser.titleImageUrl" style="height: 150px">
|
||||
</q-img>
|
||||
<q-img class="absolute-top" :src="loggedUser.titleImageUrl" style="height: 150px"/>
|
||||
<q-scroll-area style="height: calc(100% - 150px); margin-top: 150px; border-right: 1px solid #ddd">
|
||||
<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>
|
||||
<SidebarNavigation/>
|
||||
</q-scroll-area>
|
||||
</q-drawer>
|
||||
|
||||
@@ -82,46 +49,20 @@ import {storeToRefs} from "pinia";
|
||||
import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useMeta, useQuasar} from "quasar";
|
||||
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import SidebarNavigation from "components/navigation/SidebarNavigation.vue";
|
||||
import TopMenuButton from "components/navigation/TopMenuButton.vue";
|
||||
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
|
||||
const {pageTitle, breadcrumbs} = storeToRefs(useAppStore());
|
||||
useMeta(() => ({
|
||||
title: pageTitle.value !== '' ? `${pageTitle.value} - Polétání.cz` : 'Polétání.cz'
|
||||
}));
|
||||
|
||||
const linksList = [
|
||||
{
|
||||
title: 'Přidat let',
|
||||
icon: 'airplane_ticket',
|
||||
to: {name: "addFlight"}
|
||||
},
|
||||
{
|
||||
title: 'Moje lety',
|
||||
icon: "flight_takeoff",
|
||||
to: {name: "myFlights"}
|
||||
},
|
||||
{separator: true},
|
||||
{
|
||||
title: 'Letadla',
|
||||
icon: "connecting_airports",
|
||||
to: {name: "aircrafts"}
|
||||
},
|
||||
{separator: true},
|
||||
{
|
||||
title: 'Zajímavá místa',
|
||||
icon: "place",
|
||||
to: {name: "poi"}
|
||||
},
|
||||
{separator: true},
|
||||
{
|
||||
title: 'Spolucestující',
|
||||
icon: "people",
|
||||
to: {name: "copilots"}
|
||||
},
|
||||
]
|
||||
|
||||
const leftDrawerOpen = ref(false)
|
||||
const toggleLeftDrawer = () => {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value
|
||||
@@ -132,9 +73,6 @@ onMounted(() => {
|
||||
})
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
|
||||
const logout = () => {
|
||||
useUserStore().logout().then(() => {
|
||||
$q.notify(successObject("Odhlášení bylo úspěšné"));
|
||||
|
||||
@@ -37,7 +37,7 @@ import {onMounted} from "vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useMeta} from "quasar";
|
||||
import BreadcrumbsNavigation from "components/navigation/BreadcrumbsNavigation.vue";
|
||||
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
|
||||
const {pageTitle, breadcrumbs} = storeToRefs(useAppStore());
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
|
||||
@@ -9,6 +9,10 @@ import {loading, aircraft, fetchAircraft} from "src/composables/objects/aircraft
|
||||
import {useAppStore} from "stores/App";
|
||||
import FlightsList from "components/flights/FlightsList.vue";
|
||||
import AircraftDetailCard from "components/aircrafts/AircraftDetailCard.vue";
|
||||
import {poi} from "src/composables/objects/poi";
|
||||
import AccessDeniedBanner from "components/banners/AccessDeniedBanner.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useUserStore} from "stores/User";
|
||||
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
|
||||
@@ -19,7 +19,11 @@ onMounted(() => {
|
||||
:route-add="{name: 'addAircraft'}"
|
||||
label-add="Vytvořit letadlo"
|
||||
>
|
||||
Aktuálně nemáš přidané žádné letadlo.
|
||||
Aktuálně nemáš přidané žádné letadlo. Přidej se k existující organizaci nebo si sem ulož vlastní letadlo.
|
||||
|
||||
<br>
|
||||
|
||||
Letadlo můžeš založit i během ukládání informace k tvému prvnímu letu. A pak si ho tu jen doupravit.
|
||||
</BasicInfoBanner>
|
||||
|
||||
<AircraftsList
|
||||
|
||||
@@ -8,11 +8,16 @@ import AircraftEditSkeleton from "components/aircrafts/AircraftEditSkeleton.vue"
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {aircraft, fetchAircraft, loading, isRequestPending} from "src/composables/objects/aircraft";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {fetchOrganizations, organizations} from "src/composables/objects/organization";
|
||||
import AccessDeniedBanner from "components/banners/AccessDeniedBanner.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useUserStore} from "stores/User";
|
||||
|
||||
|
||||
const $router = useRouter();
|
||||
const $q = useQuasar();
|
||||
const aircraftId = parseInt(useRoute().params.id);
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
|
||||
const handleSave = (formData) => {
|
||||
useAircraftsStore().update(aircraftId, formData).then(() => {
|
||||
@@ -29,18 +34,24 @@ onMounted(() => {
|
||||
{to: {name: "aircraftDetail", params: {id: aircraftId}}, label: aircraft.value.callSign},
|
||||
{label: "Editace"}
|
||||
])
|
||||
})
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AircraftEditSkeleton v-if="loading"/>
|
||||
|
||||
<AccessDeniedBanner
|
||||
v-else-if="aircraft.createdById !== loggedUser.id"
|
||||
:route-back="{name: 'aircraftDetail', params: {id: aircraftId}}"
|
||||
/>
|
||||
|
||||
<q-card v-else class="q-mx-md">
|
||||
<q-card-section>
|
||||
<AircraftForm
|
||||
@save="handleSave"
|
||||
:default-values="aircraft"
|
||||
:organizations="loggedUser.organizations"
|
||||
:is-request-pending="isRequestPending"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
@@ -3,13 +3,17 @@ import AircraftForm from "components/aircrafts/AircraftForm.vue";
|
||||
import {useAircraftsStore} from "stores/Aircrafts";
|
||||
import {useRouter} from "vue-router";
|
||||
import {useQuasar} from "quasar";
|
||||
import {ref} from "vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {isRequestPending} from "src/composables/objects/aircraft";
|
||||
import {fetchOrganizations, organizations} from "src/composables/objects/organization";
|
||||
import {onMounted} from "vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useUserStore} from "stores/User";
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
|
||||
const handleSave = (formData) => {
|
||||
useAircraftsStore().create(formData).then(() => {
|
||||
@@ -18,17 +22,24 @@ const handleSave = (formData) => {
|
||||
});
|
||||
};
|
||||
|
||||
useAppStore().setPageTitle("Nové letadlo");
|
||||
useAppStore().setBreadcrumbs([
|
||||
onMounted(() => {
|
||||
fetchOrganizations();
|
||||
useAppStore().setPageTitle("Nové letadlo");
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "aircrafts"}, label: "Letadla"},
|
||||
{label: "Nové"}
|
||||
]);
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card class="q-mx-md q-my-md">
|
||||
<q-card-section>
|
||||
<AircraftForm @save="handleSave" :is-request-pending="isRequestPending"/>
|
||||
<AircraftForm
|
||||
:is-request-pending="isRequestPending"
|
||||
:organizations="loggedUser.organizations"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +20,8 @@ onMounted(() => {
|
||||
v-else-if="!loading && copilots.length === 0"
|
||||
:router-add="{name: 'addCopilot'}"
|
||||
>
|
||||
V tomto seznamu budou uvedeni všichni, kteří s tebou absolvovali nějaký tvůj let.
|
||||
<br>
|
||||
Zatím tu není nikdo. Přidej si let s někým nebo kopilota vytvoř ručně tu.
|
||||
</BasicInfoBanner>
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import {onMounted} from "vue";
|
||||
import {loading, event, fetchEvent, isRequestPending} from "src/composables/objects/event";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {useEventsStore} from "stores/Events";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {useQuasar} from "quasar";
|
||||
import EventEditSkeleton from "components/event/EventEditSkeleton.vue";
|
||||
import EventForm from "components/event/EventForm.vue";
|
||||
|
||||
const eventId = parseInt(useRoute().params.id);
|
||||
|
||||
onMounted(() => {
|
||||
fetchEvent(eventId).then(() => {
|
||||
useAppStore().setPageTitle(`${event.value.name} - editace`);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "events"}, label: "Události"},
|
||||
{to: {name: "eventDetail", params: {id: eventId}}, label: event.value.name},
|
||||
{label: "Editace"}
|
||||
])
|
||||
})
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
|
||||
const handleSave = (formData) => {
|
||||
useEventsStore().update(eventId, formData).then(() => {
|
||||
$q.notify(successObject("Událost úspěšně upravena"));
|
||||
$router.push({name: "eventDetail", params: {id: eventId}});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EventEditSkeleton v-if="loading"/>
|
||||
|
||||
<EventForm
|
||||
v-else
|
||||
:event="event"
|
||||
:is-request-pending="isRequestPending"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import {useRoute} from "vue-router";
|
||||
import {onMounted} from "vue";
|
||||
import {event, fetchEvent, loading} from "src/composables/objects/event";
|
||||
import EventDetailSkeleton from "components/event/EventDetailSkeleton.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FAB from "components/FAB.vue";
|
||||
import FlightsList from "components/flights/FlightsList.vue";
|
||||
import ListItemSkeleton from "components/basicSkeletons/ListItemSkeleton.vue";
|
||||
import {FORMAT_DATE, FORMAT_DATETIME_LONG, formatDatetime} from "../../../composables/datetimeUtils";
|
||||
|
||||
const eventId = parseInt(useRoute().params.id);
|
||||
onMounted(() => {
|
||||
fetchEvent(eventId).then(() => {
|
||||
useAppStore().setPageTitle(event.value.name);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "events"}, label: "Události"},
|
||||
{label: event.value.name}
|
||||
])
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EventDetailSkeleton v-if="loading || !event"/>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<q-list bordered separator class="q-my-lg" v-if="event.dateFrom && event.dateTo">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
Od: {{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
Do: {{ formatDatetime(event.dateTo, FORMAT_DATE) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<article v-html="event.description"/>
|
||||
|
||||
<h3>Lety</h3>
|
||||
|
||||
<q-banner v-if="event.flights.length === 0" class="bg-light-blue text-white">
|
||||
Zatím tu nemáš uložené žádné lety.
|
||||
</q-banner>
|
||||
|
||||
<FlightsList :items="event.flights" v-else/>
|
||||
|
||||
<FAB mode="edit" :router-link="{name: 'eventEdit', params: {id: eventId}}"/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import {onMounted} from "vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FAB from "components/FAB.vue";
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
import {events, fetchEvents, loading} from "src/composables/objects/event";
|
||||
import EventsList from "components/event/EventsList.vue";
|
||||
|
||||
onMounted(() => {
|
||||
fetchEvents();
|
||||
});
|
||||
|
||||
useAppStore().setPageTitle("Letecké události")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicListSkeleton :items-count-min="3" :items-count-max="8" v-if="loading"/>
|
||||
<BasicInfoBanner
|
||||
v-else-if="!loading && events.length === 0"
|
||||
:route-add="{name: 'addEvent'}"
|
||||
label-add="Přidat"
|
||||
>
|
||||
Události slouží ke sloučení letů z jedné akce/výletu do přehledné skupiny. Aktuálně tu nemáš vytvořenou žádnou
|
||||
událost, ke které bys mohl napárovat svoje lety.
|
||||
|
||||
<br>
|
||||
|
||||
Událost můžeš vytvořit i v rámci uložení letu.
|
||||
</BasicInfoBanner>
|
||||
|
||||
<EventsList
|
||||
v-else
|
||||
:items="events"
|
||||
/>
|
||||
|
||||
<FAB :router-link="{name: 'addEvent'}" mode="add"/>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import {useRouter} from "vue-router";
|
||||
import {useQuasar} from "quasar";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {useEventsStore} from "stores/Events";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {isRequestPending} from "src/composables/objects/event";
|
||||
import EventForm from "components/event/EventForm.vue";
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const handleSave = (formData) => {
|
||||
useEventsStore().create(formData).then(() => {
|
||||
$q.notify(successObject("Událost úspěšně vytvořena"));
|
||||
router.push({name: "events"});
|
||||
});
|
||||
};
|
||||
|
||||
useAppStore().setPageTitle("Nová událost");
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "events"}, label: "Události"},
|
||||
{label: "Nová"}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EventForm @save="handleSave" :is-request-pending="isRequestPending"/>
|
||||
</template>
|
||||
@@ -15,8 +15,8 @@ import {useCopilotsStore} from "stores/Copilots";
|
||||
import FlightFormNotes from "components/flights/form/FlightFormNotes.vue";
|
||||
import FlightFormPhotos from "components/flights/form/FlightFormPhotos.vue";
|
||||
import {usePhotosStore} from "stores/Photo";
|
||||
import PhotosList from "components/photos/PhotosList.vue";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import PhotosList from "components/photos/PhotosEditorList.vue";
|
||||
import {errorObject, successObject, warningObject} from "src/composables/dialog";
|
||||
import PhotoFormDialog from "components/photos/PhotoFormDialog.vue";
|
||||
import FlightFormTrack from "components/flights/form/FlightFormTrack.vue";
|
||||
import {fetchFlight, flight, loading} from "src/composables/objects/flight";
|
||||
@@ -25,6 +25,7 @@ import BatchPhotoFormDialog from "components/photos/BatchPhotoFormDialog.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {editBatchPhotos, handleBatchPhotoEdit, openBatchEdit, openBatchPhotoForm} from "src/composables/photos/batchEdit";
|
||||
import {editPhoto, handlePhotoUpdate, openEdit, openPhotoEdit} from "src/composables/photos/simpleEdit";
|
||||
import {fetchEvents, events} from "src/composables/objects/event";
|
||||
|
||||
const $q = useQuasar();
|
||||
const $route = useRoute();
|
||||
@@ -35,7 +36,8 @@ const locations = storeToRefs(usePointsOfInterestStore()).items;
|
||||
const airports = storeToRefs(useAirportsStore()).items;
|
||||
const copilots = storeToRefs(useCopilotsStore()).items;
|
||||
|
||||
const formStep = ref("");
|
||||
|
||||
const formStep = ref("basic");
|
||||
watch(() => $route.params.step, (val) => formStep.value = val);
|
||||
|
||||
onMounted(() => {
|
||||
@@ -46,6 +48,8 @@ onMounted(() => {
|
||||
useAirportsStore().fetch();
|
||||
useCopilotsStore().fetch();
|
||||
|
||||
fetchEvents();
|
||||
|
||||
fetchFlight(flightId).then(() => {
|
||||
const flightNameFormatted = flightName(flight.value);
|
||||
|
||||
@@ -76,8 +80,16 @@ const handlePhotosUpload = (data) => {
|
||||
promises.push(usePhotosStore().upload(flightId, {photo}));
|
||||
}
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
$q.notify(successObject("Úspěšně nahráno"));
|
||||
Promise.allSettled(promises).then((values) => {
|
||||
const success = values.filter(v => v.status === 'fulfilled');
|
||||
const failed = values.filter(v => v.status === 'rejected'); // TODO: nebo vse - success?
|
||||
|
||||
if (success.length > 0)
|
||||
$q.notify(successObject(`Úspěšně nahráno ${success.length} fotek`));
|
||||
|
||||
if (failed.length > 0)
|
||||
$q.notify(warningObject(`Nepovedlo se nahrát ${failed.length} fotek. Zde jsou důvody: ${failed.map(f => f.reason)}`));
|
||||
|
||||
fetchFlight(flightId);
|
||||
}).finally(() => uploadInProgress.value = false);
|
||||
}
|
||||
@@ -89,15 +101,42 @@ const deletePhoto = (id) => {
|
||||
});
|
||||
}
|
||||
|
||||
const rotatePhotos = ({photos, direction}) => {
|
||||
const promises = [];
|
||||
for (const photo of photos) {
|
||||
promises.push(usePhotosStore().changeOrientation(photo.id, direction));
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
Promise.allSettled(promises).then(result => {
|
||||
for (const updatedPhoto of result) {
|
||||
const {status, value} = updatedPhoto;
|
||||
if (status === 'fulfilled') success++;
|
||||
|
||||
const idx =_findPhotoIndex(value.id);
|
||||
value.thumbnailUrl += "?t=" + (new Date()).getTime();
|
||||
flight.value.photos[idx] = value;
|
||||
}
|
||||
|
||||
const error = result.length - success;
|
||||
|
||||
if (success > 0) {
|
||||
$q.notify(successObject("Otočeno " + success + " fotografií"));
|
||||
}
|
||||
|
||||
if (error > 0) {
|
||||
$q.notify(errorObject(`Nepovedlo se otočit ${error} fotografi`))
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlightEditSkeleton v-if="loading"/>
|
||||
|
||||
<template v-else>
|
||||
<FlightFormSteps :router-edit-mode="true" :flightId="flightId"/>
|
||||
|
||||
<FlightEditSkeleton :step="formStep" v-if="loading"/>
|
||||
|
||||
<template v-else>
|
||||
<q-tab-panels v-model="formStep">
|
||||
<q-tab-panel name="basic">
|
||||
<FlightFormBasic
|
||||
@@ -118,6 +157,7 @@ const deletePhoto = (id) => {
|
||||
v-if="locations"
|
||||
:flight="flight"
|
||||
:locations="locations"
|
||||
:airports="airports"
|
||||
@save="handleFlightEdit"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
@@ -133,6 +173,7 @@ const deletePhoto = (id) => {
|
||||
:photos="flight.photos"
|
||||
@edit="openPhotoEdit"
|
||||
@batch-edit="openBatchPhotoForm"
|
||||
@batch-rotate="rotatePhotos"
|
||||
@delete="deletePhoto"
|
||||
/>
|
||||
|
||||
@@ -154,10 +195,8 @@ const deletePhoto = (id) => {
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="notes">
|
||||
<FlightFormNotes @save="handleFlightEdit" :flight="flight"/>
|
||||
<FlightFormNotes @save="handleFlightEdit" :events="events" :flight="flight"/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {onMounted, watch} from "vue";
|
||||
import {computed, onMounted, ref, watch} from "vue";
|
||||
import FlightDetailSkeleton from "components/flights/FlightDetailSkeleton.vue";
|
||||
import {fetchFlight, flight, flightName, handleDeleteFlight, loading} from "src/composables/objects/flight";
|
||||
import {useAppStore} from "stores/App";
|
||||
@@ -8,15 +8,15 @@ import FlightBasicInfo from "components/flights/FlightBasicInfo.vue";
|
||||
import {copyToClipboard, useQuasar} from "quasar";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {useFlightsStore} from "stores/Flights";
|
||||
import PhotoCarousel from "components/flights/PhotoCarousel.vue";
|
||||
import FlightTrackMap from "components/flights/FlightTrackMap.vue";
|
||||
import {useUserStore} from "stores/User";
|
||||
import {storeToRefs} from "pinia";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
import PhotoGallery from "components/photos/PhotoGallery.vue";
|
||||
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
const flightId = parseInt(useRoute().params.id);
|
||||
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
|
||||
onMounted(() => {
|
||||
@@ -69,17 +69,21 @@ const copyLinkToClipboard = () => {
|
||||
<FlightDetailSkeleton v-if="loading || !flight"/>
|
||||
|
||||
<q-card class="flight-detail-card" v-else>
|
||||
<PhotoCarousel :cover-photo-url="flight.coverPhoto?.url" :photos="flight.photos"/>
|
||||
<q-img :src="flight.coverPhoto.url" v-if="flight.coverPhoto">
|
||||
<div class="absolute-bottom custom-caption">
|
||||
<div class="text-h5"> {{ flightName(flight) }} </div>
|
||||
</div>
|
||||
</q-img>
|
||||
|
||||
<q-card-actions class="q-my-md">
|
||||
<div class="col-grow">
|
||||
<q-btn
|
||||
flat
|
||||
:to="{name: 'flightEdit', params:{id: flight.id}}"
|
||||
icon="edit"
|
||||
text-color="primary"
|
||||
label="Upravit"
|
||||
/>
|
||||
<!-- <q-btn-->
|
||||
<!-- flat-->
|
||||
<!-- :to="{name: 'flightEdit', params:{id: flight.id}}"-->
|
||||
<!-- icon="edit"-->
|
||||
<!-- text-color="primary"-->
|
||||
<!-- label="Upravit"-->
|
||||
<!-- />-->
|
||||
|
||||
<q-btn
|
||||
@click="togglePublicVisibility"
|
||||
@@ -101,11 +105,47 @@ const copyLinkToClipboard = () => {
|
||||
</q-card-actions>
|
||||
|
||||
<q-card-section>
|
||||
<div class="text-right">
|
||||
<q-btn flat class="q-my-sm" :to="{name: 'flightEdit', params: {id: flightId, step: 'basic'}}" icon="edit" color="primary">Upravit informace</q-btn>
|
||||
</div>
|
||||
<FlightBasicInfo :flight="flight" :show-copilots="true"/>
|
||||
|
||||
<div class="text-right">
|
||||
<q-btn flat class="q-my-sm" :to="{name: 'flightEdit', params: {id: flightId, step: 'notes'}}" icon="edit" color="primary">Upravit text</q-btn>
|
||||
</div>
|
||||
<article class="q-my-lg" v-html="flight.description"/>
|
||||
|
||||
<FlightTrackMap :flight="flight"/>
|
||||
<q-separator/>
|
||||
|
||||
<div class="text-right">
|
||||
<q-btn flat class="q-my-sm" :to="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}" icon="edit" color="primary">Upravit fotky</q-btn>
|
||||
</div>
|
||||
<BasicInfoBanner
|
||||
v-if="flight.photos.length === 0"
|
||||
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'photos'}}"
|
||||
>
|
||||
U tohoto letu nemáš nahrané žádné fotografie.
|
||||
</BasicInfoBanner>
|
||||
<PhotoGallery
|
||||
v-else
|
||||
:photos="flight.photos"
|
||||
/>
|
||||
|
||||
<q-separator/>
|
||||
|
||||
<div class="text-right">
|
||||
<q-btn class="q-my-sm" :to="{name: 'flightEdit', params: {id: flightId, step: 'track'}}" icon="edit" color="primary">Upravit trasu</q-btn>
|
||||
</div>
|
||||
<BasicInfoBanner
|
||||
v-if="flight.track.length === 0 && !flight.gpxTrack"
|
||||
:route-add="{name: 'flightEdit', params: {id: flightId, step: 'track'}}"
|
||||
>
|
||||
Nemáš nastavenou žádnou trasu.
|
||||
</BasicInfoBanner>
|
||||
<FlightTrackMap
|
||||
v-else
|
||||
:flight="flight"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
@@ -46,7 +46,7 @@ const handleSave = (formData) => {
|
||||
<template>
|
||||
<FlightFormSteps :disabled="['copilot', 'track', 'photos', 'notes']"/>
|
||||
|
||||
<FlightEditSkeleton v-if="loading"/>
|
||||
<FlightEditSkeleton step="basic" v-if="loading"/>
|
||||
<template v-else>
|
||||
<FlightFormBasic
|
||||
@save="handleSave"
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import {useRoute} from "vue-router";
|
||||
import {onMounted, ref, watch} from "vue";
|
||||
import {usePhotosStore} from "stores/Photo";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {flightName} from "src/composables/objects/flight";
|
||||
import VuePictureCropper, {cropper} from 'vue-picture-cropper';
|
||||
import {croppedAreaForPreview, getPreviewUrl} from "src/composables/photos/editor/preview";
|
||||
import {compare, onResize, splitter, splitterWidth} from "src/composables/photos/editor/splitter";
|
||||
import {aspectRatioOptions, croppedArea, cropperReady, selectedAspectRatio, setCropData} from "src/composables/photos/editor/crop";
|
||||
import {fetchPhoto, loading, photo, photoAspectRatio} from "src/composables/photos/editor/photo";
|
||||
import {debounce, throttle, useQuasar} from "quasar";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
|
||||
const $route = useRoute();
|
||||
const $q = useQuasar();
|
||||
const photoId = parseInt($route.params.id);
|
||||
const noCacheTimestamp = ref((new Date()).getTime());
|
||||
const editorTab = ref("rotate");
|
||||
|
||||
// adjustments
|
||||
const defaultAdjustments = {
|
||||
saturation: 1,
|
||||
brightness: 1,
|
||||
contrast: 1,
|
||||
sharpness: 1,
|
||||
rotate: 0,
|
||||
};
|
||||
const adjustment = ref({...defaultAdjustments});
|
||||
const croppedAdjusted = ref("");
|
||||
const croppedAdjustedUnrotated = ref("");
|
||||
const uncroppedAdjusted = ref("");
|
||||
const croppedUnadjusted = ref("");
|
||||
|
||||
watch(() => [adjustment.value, croppedAreaForPreview.value], debounce(() => {
|
||||
croppedAdjusted.value = getPreviewUrl(photoId, {...adjustment.value, ...croppedAreaForPreview.value});
|
||||
croppedAdjustedUnrotated.value = getPreviewUrl(photoId, {...adjustment.value, rotate: 0, ...croppedAreaForPreview.value});
|
||||
uncroppedAdjusted.value = getPreviewUrl(photoId, {...adjustment.value});
|
||||
croppedUnadjusted.value = getPreviewUrl(photoId, {rotate: adjustment.value.rotate, ...croppedAreaForPreview.value});
|
||||
}, 200), {deep: true, immediate: true});
|
||||
|
||||
const savePhotoAdjustment = (photoId) => {
|
||||
const adjustmentInput = {...adjustment.value, crop: croppedArea.value};
|
||||
usePhotosStore().adjust(photoId, adjustmentInput).then((data) => {
|
||||
noCacheTimestamp.value = (new Date()).getTime();
|
||||
photo.value = data;
|
||||
|
||||
$q.notify(successObject("Fotka úspěšně upravena"));
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPhoto(photoId).then(() => {
|
||||
const flightNameFormatted = flightName(photo.value.flight);
|
||||
|
||||
if (photo.value.adjustment) {
|
||||
const photoAdjustment = photo.value.adjustment;
|
||||
const src = photoAdjustment || defaultAdjustments;
|
||||
for (const i in defaultAdjustments) {
|
||||
adjustment.value[i] = src[i];
|
||||
}
|
||||
|
||||
if (photoAdjustment) {
|
||||
rotate.value = photoAdjustment.rotate;
|
||||
croppedArea.value = {
|
||||
left: photoAdjustment.cropLeft,
|
||||
top: photoAdjustment.cropTop,
|
||||
width: photoAdjustment.cropWidth,
|
||||
height: photoAdjustment.cropHeight
|
||||
};
|
||||
}
|
||||
}
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "myFlights"}, label: "Moje lety"},
|
||||
{to: {name: "flightDetail", params: {id: photo.value.flightId}}, label: flightNameFormatted},
|
||||
{to: {name: "flightEdit", params: {id: photo.value.flightId, step: 'photos'}}, label: "Fotografie"},
|
||||
{label: "Foto editor"}
|
||||
]);
|
||||
})
|
||||
|
||||
useAppStore().setPageTitle("Foto editor");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-resize-observer @resize="onResize" :debounce="100"/>
|
||||
|
||||
<template v-if="loading">Nacitani</template>
|
||||
|
||||
<template v-if="!loading">
|
||||
<q-card :style="`width: ${splitterWidth}px; margin: 0 auto`">
|
||||
<VuePictureCropper
|
||||
v-if="editorTab === 'crop'"
|
||||
:img="uncroppedAdjusted"
|
||||
:boxStyle="{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#f8f8f8',
|
||||
margin: 'auto',
|
||||
}"
|
||||
:options="{
|
||||
viewMode: 1,
|
||||
autoCropArea: 1,
|
||||
dragMode: 'none',
|
||||
zoomable: false,
|
||||
draggable: false,
|
||||
rotatable: false,
|
||||
}"
|
||||
@cropend="setCropData"
|
||||
@ready="cropperReady"
|
||||
/>
|
||||
|
||||
<div class="overflow-hidden relative-position" v-else-if="editorTab === 'rotate'">
|
||||
<div class="rotate-overlay"></div>
|
||||
<q-img :src="croppedAdjustedUnrotated" fit="contain" :style="`transform: rotate(${adjustment.rotate}deg)`"/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden" v-else>
|
||||
<q-img fit="contain" :src="croppedAdjusted" v-show="!compare"/>
|
||||
<q-splitter
|
||||
v-show="compare"
|
||||
v-model="splitter"
|
||||
:limits="[0, 100]"
|
||||
:style="{
|
||||
height: Math.round(splitterWidth / photoAspectRatio) + 'px',
|
||||
width: splitterWidth.value + 'px'
|
||||
}"
|
||||
before-class="overflow-hidden"
|
||||
after-class="overflow-hidden">
|
||||
<template #before>
|
||||
<img
|
||||
:width="splitterWidth"
|
||||
class="absolute-top-left"
|
||||
:src="croppedUnadjusted"
|
||||
alt="before"
|
||||
/>
|
||||
</template>
|
||||
<template #after>
|
||||
<img
|
||||
:width="splitterWidth"
|
||||
class="absolute-top-right"
|
||||
:src="croppedAdjusted"
|
||||
alt="after"
|
||||
/>
|
||||
</template>
|
||||
</q-splitter>
|
||||
|
||||
<div class="text-right q-pt-md q-pr-md">
|
||||
|
||||
<q-btn flat icon="compare" @click="compare = !compare" :text-color="compare ? 'primary' : 'grey-5'">
|
||||
<q-tooltip>Klikni pro porovnání</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-card-section>
|
||||
<q-separator/>
|
||||
<q-tabs v-model="editorTab">
|
||||
<q-tab name="colors" icon="palette" label="Barvy"/>
|
||||
<q-tab name="details" icon="deblur" label="Detialy"/>
|
||||
<q-tab name="rotate" icon="rotate" label="Otočit"/>
|
||||
<q-tab name="crop" icon="crop" label="Oříznout"/>
|
||||
</q-tabs>
|
||||
<q-separator/>
|
||||
|
||||
<q-tab-panels v-model="editorTab" animated>
|
||||
<q-tab-panel name="colors">
|
||||
<q-list>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="contrast"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-slider
|
||||
v-model="adjustment.contrast"
|
||||
:min="0.5"
|
||||
:max="1.5"
|
||||
:step="0.01"
|
||||
label-always
|
||||
@dblclick="adjustment.contrast=1"
|
||||
:label="true"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="brightness_6"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-slider
|
||||
v-model="adjustment.brightness"
|
||||
:min="0.5"
|
||||
:max="1.5"
|
||||
:step="0.01"
|
||||
:label="true"
|
||||
label-always
|
||||
@dblclick="adjustment.brightness = 1"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="palette"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-slider
|
||||
v-model="adjustment.saturation"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:step="0.05"
|
||||
:label="true"
|
||||
label-always
|
||||
@dblclick="adjustment.saturation = 1"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="details">
|
||||
<q-list>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="deblur"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-slider
|
||||
v-model="adjustment.sharpness"
|
||||
:min="-3"
|
||||
:max="8"
|
||||
:step="1"
|
||||
:label="true"
|
||||
label-always
|
||||
snap
|
||||
@dblclick="adjustment.sharpness=1"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="rotate">
|
||||
<q-list>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-btn round flat icon="rotate_left" @click="adjustment.rotate -= 0.1"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-slider
|
||||
v-model="adjustment.rotate"
|
||||
:min="-45"
|
||||
:max="45"
|
||||
:step="0.1"
|
||||
:label="true"
|
||||
label-always
|
||||
@dblclick="adjustment.rotate=0"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-btn round flat icon="rotate_right" @click="adjustment.rotate += 0.1"/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="crop">
|
||||
<q-btn-toggle
|
||||
no-caps
|
||||
spread
|
||||
v-model="selectedAspectRatio"
|
||||
toggle-color="primary"
|
||||
:options="aspectRatioOptions"
|
||||
class="q-ma-lg"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-card-section>
|
||||
<q-card-actions>
|
||||
<q-space/>
|
||||
<q-btn color="primary" icon="save" @click="savePhotoAdjustment(photoId)">Uložit</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
|
||||
<style>
|
||||
.rotate-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 888;
|
||||
background-size: 60px 60px;
|
||||
background-image: linear-gradient(to right, rgba(61, 4, 4, 0.5) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(61, 4, 4, 0.5) 1px, transparent 1px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import {onMounted} from "vue";
|
||||
import {loading, organization, fetchOrganization, isRequestPending} from "src/composables/objects/organization";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {useOrganizationsStore} from "stores/Organizations";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {useQuasar} from "quasar";
|
||||
import OrganizationEditSkeleton from "components/organizations/OrganizationEditSkeleton.vue";
|
||||
import OrganizationForm from "components/organizations/OrganizationForm.vue";
|
||||
|
||||
const organizationId = parseInt(useRoute().params.id);
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrganization(organizationId).then(() => {
|
||||
useAppStore().setPageTitle(`${organization.value.name} - editace`);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "organizations"}, label: "Organizace"},
|
||||
{to: {name: "organizationDetail", params: {id: organizationId}}, label: organization.value.name},
|
||||
{label: "Editace"}
|
||||
])
|
||||
})
|
||||
});
|
||||
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
const handleSave = (formData) => {
|
||||
useOrganizationsStore().update(organizationId, formData).then(() => {
|
||||
$q.notify(successObject("Organizace úspěšně upravena"));
|
||||
$router.push({name: "organizationDetail", params: {id: organizationId}});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OrganizationEditSkeleton v-if="loading"/>
|
||||
|
||||
<OrganizationForm
|
||||
v-else
|
||||
:organization="organization"
|
||||
:is-request-pending="isRequestPending"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import {useRouter} from "vue-router";
|
||||
import {useQuasar} from "quasar";
|
||||
import {successObject} from "src/composables/dialog";
|
||||
import {useOrganizationsStore} from "stores/Organizations";
|
||||
import {useAppStore} from "stores/App";
|
||||
import {isRequestPending} from "src/composables/objects/organization";
|
||||
import OrganizationForm from "components/organizations/OrganizationForm.vue";
|
||||
|
||||
const $q = useQuasar();
|
||||
const router = useRouter();
|
||||
const handleSave = (formData) => {
|
||||
useOrganizationsStore().create(formData).then(() => {
|
||||
$q.notify(successObject("Organizace úspěšně vytvořena"));
|
||||
router.push({name: "organizations"});
|
||||
});
|
||||
};
|
||||
|
||||
useAppStore().setPageTitle("Nová organizace");
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "organizations"}, label: "Organizace"},
|
||||
{label: "Nová"}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OrganizationForm @save="handleSave" :is-request-pending="isRequestPending"/>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import {useRoute} from "vue-router";
|
||||
import {onMounted} from "vue";
|
||||
import {organization, fetchOrganization, loading, myOrganizationIds, addToOrganization, removeFromOrganization} from "src/composables/objects/organization";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FAB from "components/FAB.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
import {useUserStore} from "stores/User";
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import AircraftsList from "components/aircrafts/AircraftsList.vue";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
import {useQuasar} from "quasar";
|
||||
import AvatarFromPhoto from "components/avatars/AvatarFromPhoto.vue";
|
||||
|
||||
const organizationId = parseInt(useRoute().params.id);
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
const $q = useQuasar();
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrganization(organizationId).then(() => {
|
||||
useAppStore().setPageTitle(organization.value.name);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: "organizations"}, label: "Organizace"},
|
||||
{label: organization.value.name}
|
||||
])
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3>Členové</h3>
|
||||
|
||||
|
||||
<div class="q-my-md">
|
||||
<q-btn color="positive" @click="addToOrganization(organizationId)" v-if="!myOrganizationIds.includes(organizationId)">
|
||||
<q-icon name="add"/>
|
||||
Přidat se
|
||||
</q-btn>
|
||||
<q-btn color="warning" @click="removeFromOrganization(organizationId)" v-else>
|
||||
<q-icon name="remove"/>
|
||||
Opustit organizaci
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<BasicListSkeleton :items-count-min="1" :items-count-max="5" v-if="loading || !organization"/>
|
||||
<template v-else>
|
||||
<q-list bordered separator v-if="organization.users.length > 0">
|
||||
<q-item v-for="user in organization.users" :key="user">
|
||||
<q-item-section avatar>
|
||||
<AvatarFromPhoto :src="user.avatarImageUrl" v-if="user.avatarImageUrl" :size="50"/>
|
||||
<q-avatar icon="person" v-else class="bg-primary text-white"/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
{{ user.name || user.email }}
|
||||
<template v-if="loggedUser.id === user.id">(já)</template>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<BasicInfoBanner v-else>
|
||||
Zatím nikdo...
|
||||
</BasicInfoBanner>
|
||||
</template>
|
||||
|
||||
<h3>Letadla</h3>
|
||||
|
||||
<BasicListSkeleton :items-count-min="1" :items-count-max="5" v-if="loading || !organization"/>
|
||||
<template v-else>
|
||||
<AircraftsList :items="organization.aircrafts" v-if="organization.aircrafts.length > 0"/>
|
||||
<BasicInfoBanner v-else>
|
||||
Žádné letadlo
|
||||
</BasicInfoBanner>
|
||||
|
||||
<FAB
|
||||
mode="edit"
|
||||
v-if="organization.createdById === loggedUser.id"
|
||||
:router-link="{name: 'organizationEdit', params: {id: organizationId}}"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import BasicListSkeleton from "components/basicSkeletons/BasicListSkeleton.vue";
|
||||
import { onMounted} from "vue";
|
||||
import {fetchOrganizations, loading, myOrganizationIds, organizations} from "src/composables/objects/organization";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FAB from "components/FAB.vue";
|
||||
import OrganizationsList from "components/organizations/OrganizationsList.vue";
|
||||
import BasicInfoBanner from "components/banners/BasicInfoBanner.vue";
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrganizations();
|
||||
useAppStore().setPageTitle("Organizace");
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicListSkeleton v-if="loading"/>
|
||||
<BasicInfoBanner v-else-if="!loading && organizations.length === 0" :route-add='{name: "addOrganization"}'>
|
||||
Aktuálně nejsou dostupné žádné organizace, ke kterým by ses mohl/a přidat.
|
||||
Zaregistruj sem svůj aeroklub, abyste mohli sdílet letadla.
|
||||
</BasicInfoBanner>
|
||||
<OrganizationsList :items="organizations" :my-organizations-ids="myOrganizationIds" v-else/>
|
||||
<FAB :router-link="{name: 'addOrganization'}" mode="add"/>
|
||||
</template>
|
||||
@@ -4,7 +4,7 @@ import {useRoute} from "vue-router";
|
||||
import POIDetailSkeleton from "components/poi/POIDetailSkeleton.vue";
|
||||
import FAB from "components/FAB.vue";
|
||||
import {fetchPoi, loading, poi} from "src/composables/objects/poi";
|
||||
import PhotosList from "components/photos/PhotosList.vue";
|
||||
import PhotosList from "components/photos/PhotosEditorList.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import EmbeddedMap from "components/map/EmbeddedMap.vue";
|
||||
import {useUserStore} from "stores/User";
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup>
|
||||
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
|
||||
import {onMounted} from "vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import FlightsListSkeleton from "components/flights/FlightsListSkeleton.vue";
|
||||
import FlightsList from "components/flights/FlightsList.vue";
|
||||
import AvatarFromPhoto from "components/AvatarFromPhoto.vue";
|
||||
import {fetchFlights, flights, loading as flightsLoading} from "src/composables/objects/flight";
|
||||
import {fetchFlights} from "src/composables/objects/flight";
|
||||
import {fetchUser, user as pilot, loading as pilotLoading} from "src/composables/objects/user";
|
||||
import PublicProfileSkeleton from "components/profile/PublicProfileSkeleton.vue";
|
||||
import {storeToRefs} from "pinia";
|
||||
@@ -15,40 +11,16 @@ import PilotProfileCard from "components/profile/PilotProfileCard.vue";
|
||||
|
||||
const loggedUser = storeToRefs(useUserStore()).user;
|
||||
const username = useRoute().params.username;
|
||||
onMounted(() => {
|
||||
fetchUser(username).then(user => {
|
||||
useAppStore().setPageTitle(user.publicUsername);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: 'homepage'}, label: "Polétání.cz"},
|
||||
{label: `@${user.publicUsername}`},
|
||||
])
|
||||
})
|
||||
fetchFlights(username);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
<FlightsListSkeleton v-if="flightsLoading"/>
|
||||
|
||||
<q-banner class="bg-primary text-white" v-else-if="!flightsLoading && flights.length === 0">
|
||||
Tento pilot zatím nepublikoval žádný let.
|
||||
</q-banner>
|
||||
|
||||
<template v-else>
|
||||
<FlightsList
|
||||
:items="flights"
|
||||
:displayVisibilityInfo="loggedUser.id === pilot.id"
|
||||
:route-detail="{name: 'publicFlightDetail', params: {username: username}}"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
</template>
|
||||
<router-view :logged-user="loggedUser" :username="username"/>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<PublicProfileSkeleton v-if="pilotLoading"/>
|
||||
|
||||
<PilotProfileCard v-else :pilot="pilot" />
|
||||
<PilotProfileCard v-else :pilot="pilot"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import {onMounted, watch} from "vue";
|
||||
import {event, fetchEvent, loading} from "src/composables/objects/event";
|
||||
import {useRoute} from "vue-router";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FlightDetailSkeleton from "components/flights/FlightDetailSkeleton.vue";
|
||||
import {fetchUser, loading as pilotLoading, user as pilot} from "src/composables/objects/user";
|
||||
import PublicProfileSkeleton from "components/profile/PublicProfileSkeleton.vue";
|
||||
import PilotProfileCard from "components/profile/PilotProfileCard.vue";
|
||||
import EventDetailSkeleton from "components/event/EventDetailSkeleton.vue";
|
||||
import {FORMAT_DATE, formatDatetime} from "src/composables/datetimeUtils";
|
||||
import FAB from "components/FAB.vue";
|
||||
import FlightsList from "components/flights/FlightsList.vue";
|
||||
|
||||
const routeParams = useRoute().params;
|
||||
onMounted(() => {
|
||||
fetchEvent(routeParams.id, routeParams.username);
|
||||
fetchUser(routeParams.username)
|
||||
})
|
||||
|
||||
watch(event, (newVal) => {
|
||||
// const eventNameFormatted = eventName(newVal);
|
||||
useAppStore().setPageTitle(newVal.name);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: 'homepage'}, label: "Polétání.cz"},
|
||||
{to: {name: "publicProfileDetail", params: {username: routeParams.username}}, label: `@${routeParams.username}`},
|
||||
{to: {name: "publicEventsList", params: {username: routeParams.username}}, label: "Události"},
|
||||
{label: newVal.name}
|
||||
]);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<EventDetailSkeleton v-if="loading || !event"/>
|
||||
|
||||
|
||||
<template v-else>
|
||||
<q-banner class="bg-warning text-white q-mx-md q-my-md" v-if="!event.isPublic">
|
||||
Pozor! Tento event není publikován a nebude tak dostupný pro ostatní!
|
||||
</q-banner>
|
||||
|
||||
|
||||
<q-list bordered separator class="q-my-lg" v-if="event.dateFrom && event.dateTo">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
Od: {{ formatDatetime(event.dateFrom, FORMAT_DATE) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
Do: {{ formatDatetime(event.dateTo, FORMAT_DATE) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<article v-html="event.description"/>
|
||||
<h3>Lety</h3>
|
||||
|
||||
<q-banner v-if="event.flights.length === 0" class="bg-light-blue text-white">
|
||||
Zatím tu nemáš uložené žádné lety.
|
||||
</q-banner>
|
||||
|
||||
<FlightsList
|
||||
v-else
|
||||
:items="event.flights"
|
||||
:route-detail="{name: 'publicFlightDetail', params: {username: routeParams.username}}"
|
||||
/>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import FlightsListSkeleton from "components/flights/FlightsListSkeleton.vue";
|
||||
import {event, events, fetchEvents, loading} from "src/composables/objects/event";
|
||||
import EventsList from "components/event/EventsList.vue";
|
||||
import {onMounted, watch} from "vue";
|
||||
import PublicFlightEventSwitcher from "components/navigation/PublicFlightEventSwitcher.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
|
||||
const props = defineProps({
|
||||
loggedUser: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.username, () => {
|
||||
}, {immediate: true});
|
||||
|
||||
onMounted(() => {
|
||||
// const eventNameFormatted = eventName(newVal);
|
||||
useAppStore().setPageTitle(`${props.username}`);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: 'homepage'}, label: "Polétání.cz"},
|
||||
{to: {name: "publicProfileDetail", params: {username: props.username}}, label: `@${props.username}`},
|
||||
{label: "Události"}
|
||||
]);
|
||||
fetchEvents(props.username);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicFlightEventSwitcher :username="username"/>
|
||||
|
||||
<FlightsListSkeleton v-if="loading"/>
|
||||
|
||||
|
||||
<q-banner class="bg-primary text-white" v-else-if="!loading && events.length === 0">
|
||||
Tento pilot zatím nepublikoval žádnou udalost.
|
||||
</q-banner>
|
||||
|
||||
<template v-else>
|
||||
<EventsList
|
||||
:items="events"
|
||||
:route-detail="{name: 'publicEventDetail', params: {username: username}}"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
+11
-12
@@ -2,14 +2,12 @@
|
||||
import {onMounted, watch} from "vue";
|
||||
import {fetchFlight, flight, flightName, loading} from "src/composables/objects/flight";
|
||||
import {useRoute} from "vue-router";
|
||||
import PhotoCarousel from "components/flights/PhotoCarousel.vue";
|
||||
import FlightTrackMap from "components/flights/FlightTrackMap.vue";
|
||||
import FlightBasicInfo from "components/flights/FlightBasicInfo.vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
import FlightDetailSkeleton from "components/flights/FlightDetailSkeleton.vue";
|
||||
import {fetchUser, loading as pilotLoading, user as pilot} from "src/composables/objects/user";
|
||||
import PublicProfileSkeleton from "components/profile/PublicProfileSkeleton.vue";
|
||||
import PilotProfileCard from "components/profile/PilotProfileCard.vue";
|
||||
import PhotoGallery from "components/photos/PhotoGallery.vue";
|
||||
|
||||
const routeParams = useRoute().params;
|
||||
onMounted(() => {
|
||||
@@ -26,7 +24,6 @@ watch(flight, (newVal) => {
|
||||
{label: flightNameFormatted}
|
||||
]);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,22 +36,24 @@ watch(flight, (newVal) => {
|
||||
</q-banner>
|
||||
|
||||
<q-card class="q-mx-md q-my-md flight-detail-card">
|
||||
<PhotoCarousel
|
||||
:default-title="flightName(flight)"
|
||||
:cover-photo-url="flight.coverPhoto?.url"
|
||||
:photos="flight.photos"
|
||||
/>
|
||||
<q-img :src="flight.coverPhoto.url" v-if="flight.coverPhoto">
|
||||
<div class="absolute-bottom custom-caption">
|
||||
<div class="text-h5"> {{ flightName(flight) }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
|
||||
<q-card-section>
|
||||
<FlightBasicInfo :flight="flight" :show-copilots="false"/>
|
||||
|
||||
<article class="q-my-lg" v-html="flight.description"/>
|
||||
|
||||
<PhotoGallery :photos="flight.photos"/>
|
||||
|
||||
<FlightTrackMap :flight="flight"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<PublicProfileSkeleton v-if="pilotLoading"/>
|
||||
<PilotProfileCard :pilot="pilot" v-else />
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import {fetchFlights, flights, loading as flightsLoading} from "src/composables/objects/flight";
|
||||
import {fetchUser, user as pilot} from "src/composables/objects/user";
|
||||
import FlightsListSkeleton from "components/flights/FlightsListSkeleton.vue";
|
||||
import FlightsList from "components/flights/FlightsList.vue";
|
||||
import PublicFlightEventSwitcher from "components/navigation/PublicFlightEventSwitcher.vue";
|
||||
import {onMounted} from "vue";
|
||||
import {useAppStore} from "stores/App";
|
||||
|
||||
const props = defineProps({
|
||||
loggedUser: {
|
||||
type: Object,
|
||||
required: false
|
||||
},
|
||||
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: onmounted presunout do child komponent, stejne jako fetch dat
|
||||
onMounted(() => {
|
||||
fetchUser(props.username).then(user => {
|
||||
useAppStore().setPageTitle(user.publicUsername);
|
||||
useAppStore().setBreadcrumbs([
|
||||
{to: {name: 'homepage'}, label: "Polétání.cz"},
|
||||
{label: `@${user.publicUsername}`},
|
||||
])
|
||||
})
|
||||
fetchFlights(props.username);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicFlightEventSwitcher :username="username" />
|
||||
|
||||
<FlightsListSkeleton v-if="flightsLoading"/>
|
||||
|
||||
<q-banner class="bg-primary text-white" v-else-if="!flightsLoading && flights.length === 0">
|
||||
Tento pilot zatím nepublikoval žádný let.
|
||||
</q-banner>
|
||||
|
||||
<template v-else>
|
||||
<FlightsList
|
||||
:items="flights"
|
||||
:displayVisibilityInfo="loggedUser.id === pilot.id"
|
||||
:route-detail="{name: 'publicFlightDetail', params: {username: username}}"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
+3
-1
@@ -27,8 +27,10 @@ export default route(function (/* { store, ssrContext } */) {
|
||||
history: createHistory(process.env.VUE_ROUTER_BASE)
|
||||
})
|
||||
|
||||
Router.afterEach(async (to) => {
|
||||
Router.afterEach(async (to, from) => {
|
||||
if (from.name !== to.name) {
|
||||
useAppStore().resetAfterRouteChange();
|
||||
}
|
||||
});
|
||||
|
||||
return Router
|
||||
|
||||
+40
-19
@@ -2,8 +2,8 @@ import PlainLayout from "layouts/PlainLayout.vue";
|
||||
|
||||
const routes = [
|
||||
{path: '/login', name: "login", component: () => import('pages/public/LoginPage.vue'), meta: {layout: PlainLayout}},
|
||||
{path: '/register', name: "registration", component: () => import('pages/public/RegisterPage.vue'), meta: {layout: PlainLayout}},
|
||||
{path: '/forgotten-password', name: "forgottenPassword", component: () => import('pages/public/RegisterPage.vue'), meta: {layout: PlainLayout}},
|
||||
{path: '/registrace', name: "registration", component: () => import('pages/public/RegisterPage.vue'), meta: {layout: PlainLayout}},
|
||||
{path: '/zapomenute-heslo', name: "forgottenPassword", component: () => import('pages/public/RegisterPage.vue'), meta: {layout: PlainLayout}},
|
||||
|
||||
{path: '', name: "homepage", component: () => import('pages/public/IndexPage.vue'), meta: {layout: PlainLayout}},
|
||||
|
||||
@@ -12,34 +12,55 @@ const routes = [
|
||||
component: () => import('layouts/PublicLayout.vue'),
|
||||
children: [
|
||||
{path: 'about', name: "about", component: () => import('pages/public/AboutPage.vue')},
|
||||
{path: ':username', name: "publicProfileDetail", component: () => import('pages/public/PilotProfilePage.vue')},
|
||||
{path: ':username/:id', name: "publicFlightDetail", component: () => import('pages/public/FlightDetailPage.vue')},
|
||||
{
|
||||
path: ':username',
|
||||
component: () => import('pages/public/PilotProfilePage.vue'),
|
||||
children: [
|
||||
{path: '', name: "publicProfileDetail", component: () => import('pages/public/pilotProfile/FlightsListPage.vue')},
|
||||
{path: ':id', name: "publicFlightDetail", component: () => import('pages/public/pilotProfile/FlightDetailPage.vue')},
|
||||
{path: 'udalosti', name: "publicEventsList", component: () => import('pages/public/pilotProfile/EventsListPage.vue')},
|
||||
{path: 'udalosti/:id', name: "publicEventDetail", component: () => import('pages/public/pilotProfile/EventDetailPage.vue')},
|
||||
]
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
path: '/moje',
|
||||
component: () => import('layouts/ProfileLayout.vue'),
|
||||
meta: {
|
||||
protected: true,
|
||||
},
|
||||
children: [
|
||||
{path: '', name: "profile", component: () => import('pages/profile/ProfilePage.vue')},
|
||||
{path: 'my-flights', name: "myFlights", component: () => import('pages/profile/flight/FlightsPage.vue')},
|
||||
{path: 'my-flights/add', name: "addFlight", component: () => import('pages/profile/flight/NewFlightPage.vue')},
|
||||
{path: 'my-flights/:id', name: "flightDetail", component: () => import('pages/profile/flight/FlightDetailPage.vue')},
|
||||
{path: 'my-flights/:id/edit/:step?', name: "flightEdit", component: () => import('pages/profile/flight/EditFlightPage.vue')},
|
||||
{path: '', name: "myFlights", component: () => import('pages/profile/flight/FlightsPage.vue')},
|
||||
{path: 'nastaveni', name: "profile", component: () => import('pages/profile/ProfilePage.vue')},
|
||||
{path: 'lety/novy', name: "addFlight", component: () => import('pages/profile/flight/NewFlightPage.vue')},
|
||||
{path: 'lety/:id', name: "flightDetail", component: () => import('pages/profile/flight/FlightDetailPage.vue')},
|
||||
{path: 'lety/:id/edit/:step?', name: "flightEdit", component: () => import('pages/profile/flight/EditFlightPage.vue')},
|
||||
{path: 'poi', name: "poi", component: () => import('pages/profile/poi/PointsOfInterestPage.vue')},
|
||||
{path: 'poi/add', name: "addPOI", component: () => import('pages/profile/poi/NewPointOfInterestPage.vue')},
|
||||
{path: 'poi/novy', name: "addPOI", component: () => import('pages/profile/poi/NewPointOfInterestPage.vue')},
|
||||
{path: 'poi/:id', name: "poiDetail", component: () => import('pages/profile/poi/PointOfInterestDetailPage.vue')},
|
||||
{path: 'poi/:id/edit', name: "poiEdit", component: () => import('pages/profile/poi/EditPointOfInterestPage.vue')},
|
||||
{path: 'copilots', name: "copilots", component: () => import('pages/profile/copilot/CopilotsPage.vue')},
|
||||
{path: 'copilots/add', name: "addCopilot", component: () => import('pages/profile/copilot/NewCopilotPage.vue')},
|
||||
{path: 'copilots/:id', name: "copilotDetail", component: () => import('pages/profile/copilot/CopilotDetailPage.vue')},
|
||||
{path: 'copilots/:id/edit', name: "copilotEdit", component: () => import('pages/profile/copilot/EditCopilotPage.vue')},
|
||||
{path: 'aircrafts', name: "aircrafts", component: () => import('pages/profile/aircraft/AircraftsPage.vue')},
|
||||
{path: 'aircrafts/new', name: "addAircraft", component: () => import('pages/profile/aircraft/NewAircraftsPage.vue')},
|
||||
{path: 'aircrafts/:id', name: "aircraftDetail", component: () => import('pages/profile/aircraft/AircraftDetailPage.vue')},
|
||||
{path: 'aircrafts/:id/edit', name: "aircraftEdit", component: () => import('pages/profile/aircraft/EditAircraftPage.vue')},
|
||||
{path: 'kopiloti', name: "copilots", component: () => import('pages/profile/copilot/CopilotsPage.vue')},
|
||||
{path: 'kopiloti/novy', name: "addCopilot", component: () => import('pages/profile/copilot/NewCopilotPage.vue')},
|
||||
{path: 'kopiloti/:id', name: "copilotDetail", component: () => import('pages/profile/copilot/CopilotDetailPage.vue')},
|
||||
{path: 'kopiloti/:id/edit', name: "copilotEdit", component: () => import('pages/profile/copilot/EditCopilotPage.vue')},
|
||||
{path: 'letadla', name: "aircrafts", component: () => import('pages/profile/aircraft/AircraftsPage.vue')},
|
||||
{path: 'letadla/nove', name: "addAircraft", component: () => import('pages/profile/aircraft/NewAircraftsPage.vue')},
|
||||
{path: 'letadla/:id', name: "aircraftDetail", component: () => import('pages/profile/aircraft/AircraftDetailPage.vue')},
|
||||
{path: 'letadla/:id/edit', name: "aircraftEdit", component: () => import('pages/profile/aircraft/EditAircraftPage.vue')},
|
||||
|
||||
{path: 'foto-editor/:id', name: "photoEditor", component: () => import("pages/profile/flight/PhotoEditorPage.vue")},
|
||||
|
||||
{path: 'udalosti', name: "events", component: () => import('pages/profile/event/EventsPage.vue')},
|
||||
{path: 'udalosti/novy', name: "addEvent", component: () => import('pages/profile/event/NewEventPage.vue')},
|
||||
{path: 'udalosti/:id', name: "eventDetail", component: () => import('pages/profile/event/EventDetailPage.vue')},
|
||||
{path: 'udalosti/:id/edit', name: "eventEdit", component: () => import('pages/profile/event/EditEventPage.vue')},
|
||||
|
||||
{path: 'organizace', name: "organizations", component: () => import('pages/profile/organization/OrganizationsPage.vue')},
|
||||
{path: 'organizace/nova', name: "addOrganization", component: () => import('pages/profile/organization/NewOrganizationPage.vue')},
|
||||
{path: 'organizace/:id', name: "organizationDetail", component: () => import('pages/profile/organization/OrganizationDetailPage.vue')},
|
||||
{path: 'organizace/:id/edit', name: "organizationEdit", component: () => import('pages/profile/organization/EditOrganizationPage.vue')},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {COPILOT_DETAIL, COPILOTS_LIST} from "src/graphql/queries/copilots";
|
||||
import {CREATE_COPILOT, EDIT_COPILOT} from "src/graphql/mutations/copilot";
|
||||
import {updateInItems} from "stores/helpers";
|
||||
|
||||
export const useCopilotsStore = defineStore('SecondPilots', {
|
||||
export const useCopilotsStore = defineStore('Copilots', {
|
||||
state: () => ({
|
||||
items: [],
|
||||
isMutationRunning: false,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {apolloWrapper} from "src/graphql/apollo";
|
||||
import {CREATE_EVENT, EDIT_EVENT} from "src/graphql/mutations/event";
|
||||
import {updateInItems} from "stores/helpers";
|
||||
import {EVENT_DETAIL, EVENTS_LIST} from "src/graphql/queries/event";
|
||||
|
||||
export const useEventsStore = defineStore('Events', {
|
||||
state: () => ({
|
||||
items: [],
|
||||
isMutationRunning: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
fetchDetail(eventId, username) {
|
||||
return apolloWrapper.query(EVENT_DETAIL, {id: eventId, username: username}).then(({data}) => data.event);
|
||||
},
|
||||
fetch(username) {
|
||||
return apolloWrapper.query(EVENTS_LIST, {username}).then(result => {
|
||||
this.items = result.data.events;
|
||||
return result.data.events;
|
||||
});
|
||||
},
|
||||
create(formData) {
|
||||
delete formData['id'];
|
||||
|
||||
this.isMutationRunning = true;
|
||||
return apolloWrapper.mutate(CREATE_EVENT, {input: formData}).then(({data}) => {
|
||||
const event = data.createEvent;
|
||||
event.flights = event.flights || [];
|
||||
this.items.push(event);
|
||||
return event;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
update(id, formData) {
|
||||
return apolloWrapper.mutate(EDIT_EVENT, {id: id, input: formData}).then(({data}) => {
|
||||
const event = data.editEvent;
|
||||
updateInItems(this.items, "id", event);
|
||||
return event;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {apolloWrapper} from "src/graphql/apollo";
|
||||
import {ADD_USER_TO_ORGANIZATION, CREATE_ORGANIZATION, EDIT_ORGANIZATION, REMOVE_USER_FROM_ORGANIZATION} from "src/graphql/mutations/organization";
|
||||
import {ORGANIZATION_DETAIL, ORGANIZATIONS_LIST} from "src/graphql/queries/organization";
|
||||
import {updateInItems} from "stores/helpers";
|
||||
|
||||
export const useOrganizationsStore = defineStore('Organizations', {
|
||||
state: () => ({
|
||||
items: [],
|
||||
isMutationRunning: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
fetchDetail(organizationId) {
|
||||
return apolloWrapper.query(ORGANIZATION_DETAIL, {id: organizationId}).then(({data}) => data.organization);
|
||||
},
|
||||
fetch() {
|
||||
return apolloWrapper.query(ORGANIZATIONS_LIST).then(result => {
|
||||
this.items = result.data.organizations;
|
||||
return result.data.organizations;
|
||||
});
|
||||
},
|
||||
create(formData) {
|
||||
delete formData['id'];
|
||||
|
||||
this.isMutationRunning = true;
|
||||
return apolloWrapper.mutate(CREATE_ORGANIZATION, {input: formData}).then(({data}) => {
|
||||
const organization = data.createOrganization;
|
||||
this.items.push(organization);
|
||||
return organization;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
|
||||
addUserToOrganization(organizationId) {
|
||||
this.isMutationRunning = true;
|
||||
return apolloWrapper.mutate(ADD_USER_TO_ORGANIZATION, {organizationId}).then(({data}) => {
|
||||
return data.addToOrganization;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
|
||||
removeUserFromOrganization(organizationId) {
|
||||
this.isMutationRunning = true;
|
||||
return apolloWrapper.mutate(REMOVE_USER_FROM_ORGANIZATION, {organizationId}).then(({data}) => {
|
||||
return data.removeFromOrganization;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
|
||||
update(id, formData) {
|
||||
return apolloWrapper.mutate(EDIT_ORGANIZATION, {id: id, input: formData}).then(({data}) => {
|
||||
const organization = data.editOrganization;
|
||||
updateInItems(this.items, "id", organization);
|
||||
return organization;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
}
|
||||
})
|
||||
+24
-3
@@ -1,13 +1,19 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {apolloWrapper} from "src/graphql/apollo";
|
||||
import {DELETE_PHOTO, EDIT_PHOTO, UPLOAD_PHOTO} from "src/graphql/mutations/photo";
|
||||
import {ADJUST_PHOTO, CHANGE_PHOTO_ORIENTATION, DELETE_PHOTO, EDIT_PHOTO, UPLOAD_PHOTO} from "src/graphql/mutations/photo";
|
||||
import {PHOTO_DETAIL} from "src/graphql/queries/photo";
|
||||
|
||||
export const usePhotosStore = defineStore('photos', {
|
||||
export const usePhotosStore = defineStore('Photos', {
|
||||
state: () => ({
|
||||
isMutationRunning: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
fetchDetail(id) {
|
||||
return apolloWrapper.query(PHOTO_DETAIL, {id}).then(({data}) => {
|
||||
return data.photo;
|
||||
});
|
||||
},
|
||||
upload(flightId, formData) {
|
||||
if (!flightId) {
|
||||
console.error("No flightId specified for photo upload! Aborting");
|
||||
@@ -35,10 +41,25 @@ export const usePhotosStore = defineStore('photos', {
|
||||
return data.editPhoto;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
|
||||
changeOrientation(id, direction) {
|
||||
this.isMutationRunning = true;
|
||||
return apolloWrapper.mutate(CHANGE_PHOTO_ORIENTATION, {id, direction}).then(({data}) => {
|
||||
return data.changeOrientation;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
|
||||
adjust(id, adjustment) {
|
||||
this.isMutationRunning = true;
|
||||
return apolloWrapper.mutate(ADJUST_PHOTO, {id, adjustment}).then(({data}) => {
|
||||
return data.adjustPhoto;
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
},
|
||||
delete(photoId) {
|
||||
this.isMutationRunning = true;
|
||||
return apolloWrapper.mutate(DELETE_PHOTO, {id: photoId}).then(({data}) => {
|
||||
return data.deletePhoto;
|
||||
});
|
||||
}).finally(() => this.isMutationRunning = false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.21.3.tgz"
|
||||
integrity sha512-lobG0d7aOfQRXh8AyklEAgZGvA4FShxo6xQbUrrT/cNBPUdIDojlokwJsQyCC/eKia7ifqM0yP+2DRZ4WKw2RQ==
|
||||
|
||||
"@bassist/utils@^0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@bassist/utils/-/utils-0.4.0.tgz#58eb247b37afc3c405543884731a134f522fc64b"
|
||||
integrity sha512-aoFTl0jUjm8/tDZodP41wnEkvB+C5O9NFCuYN/ztL6jSUSsuBkXq90/1ifBm1XhV/zySHgLYlU1+tgo3XtQ+nA==
|
||||
dependencies:
|
||||
"@withtypes/mime" "^0.1.2"
|
||||
|
||||
"@esbuild/linux-loong64@0.14.54":
|
||||
version "0.14.54"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
|
||||
@@ -414,6 +421,13 @@
|
||||
resolved "https://registry.npmjs.org/@vue/shared/-/shared-3.2.47.tgz"
|
||||
integrity sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==
|
||||
|
||||
"@withtypes/mime@^0.1.2":
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@withtypes/mime/-/mime-0.1.2.tgz#956883251b0786f2c898ed76a23594ea6d9939e0"
|
||||
integrity sha512-PB9BfZGzwblUONJY0LiOwsHCA6uV3DIPj/w9ReekdHxPOl0VdUFgI5s4avKycuuq9Gf5Nz2ZPA2O36GAUzlMPA==
|
||||
dependencies:
|
||||
mime "^3.0.0"
|
||||
|
||||
"@wry/context@^0.7.0":
|
||||
version "0.7.2"
|
||||
resolved "https://registry.npmjs.org/@wry/context/-/context-0.7.2.tgz"
|
||||
@@ -901,6 +915,11 @@ crc32-stream@^4.0.2:
|
||||
crc-32 "^1.2.0"
|
||||
readable-stream "^3.4.0"
|
||||
|
||||
cropperjs@^1.6.1:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.6.1.tgz#fd132021d93b824b1b0f2c2c3b763419fb792d89"
|
||||
integrity sha512-F4wsi+XkDHCOMrHMYjrTEE4QBOrsHHN5/2VsVAaRq8P7E5z7xQpT75S+f/9WikmBEailas3+yo+6zPIomW+NOA==
|
||||
|
||||
cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz"
|
||||
@@ -2099,6 +2118,11 @@ mime@1.6.0:
|
||||
resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
|
||||
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
|
||||
|
||||
mime@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7"
|
||||
integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==
|
||||
|
||||
mimic-fn@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
|
||||
@@ -2330,6 +2354,16 @@ path-to-regexp@0.1.7:
|
||||
resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz"
|
||||
integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==
|
||||
|
||||
photoswipe-dynamic-caption-plugin@^1.2.7:
|
||||
version "1.2.7"
|
||||
resolved "https://registry.yarnpkg.com/photoswipe-dynamic-caption-plugin/-/photoswipe-dynamic-caption-plugin-1.2.7.tgz#53aa5059f1c4dccc8aa36196ff3e09baa5e537c2"
|
||||
integrity sha512-5XXdXLf2381nwe7KqQvcyStiUBi9TitYXppUQTrzPwYAi4lZsmWNnNKMclM7I4QGlX6fXo42v3bgb6rlK9pY1Q==
|
||||
|
||||
photoswipe@^5.4.2:
|
||||
version "5.4.2"
|
||||
resolved "https://registry.yarnpkg.com/photoswipe/-/photoswipe-5.4.2.tgz#bed976c27f876bd9c86085a022701a8cea484f7e"
|
||||
integrity sha512-z5hr36nAIPOZbHJPbCJ/mQ3+ZlizttF9za5gKXKH/us1k4KNHaRbC63K1Px5sVVKUtGb/2+ixHpKqtwl0WAwvA==
|
||||
|
||||
picocolors@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
|
||||
@@ -2988,6 +3022,14 @@ vue-inner-image-zoom@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/vue-inner-image-zoom/-/vue-inner-image-zoom-2.0.0.tgz#d84a8d5f4b76ba8d9675bfb67927aef06c4cf62c"
|
||||
integrity sha512-PrCppBbgffCLCpvetEWzt+8qzYs984HrhEj/9649/4nPV9leCeditl2MY5IgKWElWpDgtR3j0O2sLDVcpgGbYA==
|
||||
|
||||
vue-picture-cropper@^0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/vue-picture-cropper/-/vue-picture-cropper-0.7.0.tgz#e9dec1b17542ba6c681a7ba6240282193e101d68"
|
||||
integrity sha512-NF7+Dgso6d0GB16E5d/BbrcTIHm1VWz8dS3IjLhoBl+ZeC+yDA46CyJphQuO32SisaPmrKHN8VbiE2LgAfhnkQ==
|
||||
dependencies:
|
||||
"@bassist/utils" "^0.4.0"
|
||||
cropperjs "^1.6.1"
|
||||
|
||||
vue-router@^4.1.6:
|
||||
version "4.1.6"
|
||||
resolved "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz"
|
||||
|
||||
Reference in New Issue
Block a user