parent
30c66d391f
commit
a9dc63c289
7 changed files with 371 additions and 18 deletions
frontend/src
components
elements
mixins
252
frontend/src/components/EditPointInfo.vue
Normal file
252
frontend/src/components/EditPointInfo.vue
Normal file
|
@ -0,0 +1,252 @@
|
|||
<template>
|
||||
<form class="edit-point" @submit.prevent="$emit('edit', newValue)">
|
||||
<div class="content">
|
||||
<div class="row">
|
||||
<label for="id">
|
||||
<font-awesome-icon icon="fas fa-tag" />
|
||||
<span class="description">ID</span>
|
||||
</label>
|
||||
<span class="value">
|
||||
<input
|
||||
name="id"
|
||||
:value="point?.id"
|
||||
type="text"
|
||||
disabled />
|
||||
|
||||
<span class="buttons">
|
||||
<button type="button"
|
||||
title="Copy ID"
|
||||
@click="copyToClipboard(point?.id?.toString() || '')">
|
||||
<font-awesome-icon icon="fas fa-copy" />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="description">
|
||||
<font-awesome-icon icon="fas fa-edit" />
|
||||
<span class="description">Description</span>
|
||||
</label>
|
||||
<span class="value">
|
||||
<!-- @vue-ignore -->
|
||||
<textarea name="description" v-model="newValue.description" placeholder="Enter a description" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="address">
|
||||
<font-awesome-icon icon="fas fa-map-marked-alt" />
|
||||
<span class="description">Address</span>
|
||||
</label>
|
||||
<span class="value">
|
||||
<input
|
||||
name="address"
|
||||
v-model="newValue.address"
|
||||
type="text"
|
||||
placeholder="Enter an address" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="locality">
|
||||
<font-awesome-icon icon="fas fa-map-pin" />
|
||||
<span class="description">Locality</span>
|
||||
</label>
|
||||
<span class="value">
|
||||
<input
|
||||
name="locality"
|
||||
v-model="newValue.locality"
|
||||
type="text"
|
||||
placeholder="Enter a locality" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="postalCode">
|
||||
<font-awesome-icon icon="fas fa-envelope" />
|
||||
<span class="description">Postal Code</span>
|
||||
</label>
|
||||
<span class="value">
|
||||
<input
|
||||
name="postalCode"
|
||||
v-model="newValue.postalCode"
|
||||
type="text"
|
||||
placeholder="Postal Code" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="country">
|
||||
<font-awesome-icon icon="fas fa-flag" />
|
||||
<span class="description">Country</span>
|
||||
</label>
|
||||
<span class="value">
|
||||
<CountrySelector
|
||||
name="country"
|
||||
@input="newValue.country = $event"
|
||||
:value="newValue.country || ''"
|
||||
show-all />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="altitude">
|
||||
<font-awesome-icon icon="fas fa-mountain" />
|
||||
<span class="description">Altitude</span>
|
||||
</label>
|
||||
<span class="value">
|
||||
<input
|
||||
name="altitude"
|
||||
v-model="newValue.altitude"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Altitude" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit"
|
||||
title="Save"
|
||||
:disabled="!hasChanged">
|
||||
Save
|
||||
</button>
|
||||
<button type="button"
|
||||
title="Cancel"
|
||||
@click="$emit('close')">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Clipboard from '../mixins/Clipboard.vue';
|
||||
import CountrySelector from '../elements/CountrySelector.vue';
|
||||
import GPSPoint from '../models/GPSPoint';
|
||||
import UserDevice from '../models/UserDevice';
|
||||
|
||||
export default {
|
||||
emits: ['close', 'edit'],
|
||||
mixins: [Clipboard],
|
||||
components: {
|
||||
CountrySelector,
|
||||
},
|
||||
|
||||
props: {
|
||||
device: {
|
||||
type: [UserDevice, null],
|
||||
},
|
||||
point: {
|
||||
type: [GPSPoint, null],
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
hasChanged: false,
|
||||
newValue: {...this.point} as GPSPoint,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
point: {
|
||||
handler(newValue: GPSPoint) {
|
||||
this.newValue = {...newValue} as GPSPoint;
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
|
||||
newValue: {
|
||||
handler(newValue: GPSPoint) {
|
||||
this.hasChanged = JSON.stringify(this.point) !== JSON.stringify(newValue);
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// gap: 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
.row {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
|
||||
.description {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: 0.5rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
$buttons-width: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
input, textarea {
|
||||
width: calc(100% - #{$buttons-width} - 1rem);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: $buttons-width;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0 0 0.5rem;
|
||||
padding: 0;
|
||||
|
||||
button {
|
||||
height: 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-accent);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-accent-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
font-size: 0.85rem;
|
||||
[type=submit] {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -29,8 +29,10 @@
|
|||
{{ speedKmH }} km/h
|
||||
</p>
|
||||
|
||||
<!-- @vue-ignore -->
|
||||
<form class="description editor" @submit.prevent="editPoint" v-if="editDescription">
|
||||
<div class="row">
|
||||
<!-- @vue-ignore -->
|
||||
<textarea
|
||||
:value="point.description"
|
||||
@keydown.enter="editPoint"
|
||||
|
@ -87,18 +89,36 @@
|
|||
</p>
|
||||
<p class="timestamp" v-if="timeString">{{ timeString }}</p>
|
||||
|
||||
<div class="remove">
|
||||
<button title="Remove" @click="$emit('remove', point)">
|
||||
<div class="buttons">
|
||||
<button title="Edit" class="edit" @click="showEditModal = true">
|
||||
<font-awesome-icon icon="fas fa-edit" /> Edit
|
||||
</button>
|
||||
|
||||
<button title="Remove" class="remove" @click="$emit('remove', point)">
|
||||
<font-awesome-icon icon="fas fa-trash-alt" /> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal :visible="showEditModal" @close="showEditModal = false">
|
||||
<template #title>
|
||||
Edit Point
|
||||
</template>
|
||||
|
||||
<EditPointInfo
|
||||
:device="device"
|
||||
:point="newValue"
|
||||
@close="showEditModal = false"
|
||||
@edit="editPoint" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import EditPointInfo from './EditPointInfo.vue';
|
||||
import Map from 'ol/Map';
|
||||
import Modal from '../elements/Modal.vue';
|
||||
import Overlay from 'ol/Overlay';
|
||||
import type { TCountryCode } from 'countries-list';
|
||||
import { getCountryData, getEmojiFlag } from 'countries-list';
|
||||
|
@ -110,6 +130,11 @@ import UserDevice from '../models/UserDevice';
|
|||
export default {
|
||||
emit: ['close', 'edit', 'remove'],
|
||||
mixins: [Dates],
|
||||
components: {
|
||||
EditPointInfo,
|
||||
Modal,
|
||||
},
|
||||
|
||||
props: {
|
||||
device: {
|
||||
type: [UserDevice, null],
|
||||
|
@ -121,9 +146,11 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
newValue: {} as GPSPoint,
|
||||
// @ts-ignore
|
||||
newValue: this.point ? new GPSPoint({ ...this.point }) : null as GPSPoint | null,
|
||||
editDescription: false,
|
||||
popup: null as Overlay | null,
|
||||
showEditModal: false,
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -217,10 +244,22 @@ export default {
|
|||
map.addOverlay(this.popup)
|
||||
},
|
||||
|
||||
editPoint() {
|
||||
this.newValue.description = (this.$refs.description as HTMLTextAreaElement).value
|
||||
this.$emit('edit', this.newValue)
|
||||
editPoint(newValue: GPSPoint | null | undefined) {
|
||||
this.editDescription = false
|
||||
|
||||
// If no new structured value is provided, then only edit the description
|
||||
if (newValue == null) {
|
||||
if (this.newValue) {
|
||||
this.newValue.description = (this.$refs.description as HTMLTextAreaElement).value
|
||||
}
|
||||
|
||||
this.$emit('edit', this.newValue)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, propagate the whole new value
|
||||
this.newValue = newValue
|
||||
this.$emit('edit', this.newValue)
|
||||
},
|
||||
|
||||
onDescriptionBlur() {
|
||||
|
@ -408,17 +447,30 @@ export default {
|
|||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
font-size: 0.85em;
|
||||
margin-top: 0.5em;
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85em;
|
||||
margin-top: 0.5em;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
button {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
margin-left: -0.5em;
|
||||
|
||||
&.edit {
|
||||
color: var(--color-accent);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-accent-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&.remove {
|
||||
color: var(--vt-c-red-fg-light);
|
||||
margin-left: -0.5em;
|
||||
|
||||
&:hover {
|
||||
color: var(--vt-c-red-fg-dark);
|
||||
|
|
|
@ -80,7 +80,7 @@ export default {
|
|||
}
|
||||
|
||||
let matches = this.values.filter((value: AutocompleteValue) =>
|
||||
value.value.toLowerCase() === this.newValue.toLowerCase()
|
||||
value?.value?.toLowerCase() === this.newValue.toLowerCase()
|
||||
) as AutocompleteValue[];
|
||||
|
||||
if (!matches.length) {
|
||||
|
@ -188,7 +188,7 @@ export default {
|
|||
|
||||
if (newValue) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input.focus();
|
||||
(this.$refs.input as HTMLInputElement).focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -90,7 +90,9 @@ export default {
|
|||
if (visitedCountries[key]) {
|
||||
return acc
|
||||
}
|
||||
acc[key] = this.toAutocompleteValue(countries[key])
|
||||
|
||||
// @ts-ignore
|
||||
acc[key] = this.toAutocompleteValue(countries[key] as any)
|
||||
return acc
|
||||
}, {}
|
||||
)
|
||||
|
|
|
@ -36,6 +36,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/common.scss' as *;
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
@ -49,13 +51,21 @@ export default {
|
|||
z-index: 1000;
|
||||
|
||||
.modal {
|
||||
min-width: 30em;
|
||||
background-color: var(--color-background);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0 0 1em rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
animation: fade-in 0.5s;
|
||||
|
||||
@include until(tablet) {
|
||||
min-width: 90vw;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
@include from(tablet) {
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
16
frontend/src/mixins/Clipboard.vue
Normal file
16
frontend/src/mixins/Clipboard.vue
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import Notifications from './Notifications.vue'
|
||||
|
||||
export default {
|
||||
mixins: [Notifications],
|
||||
methods: {
|
||||
async copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
this.notify({
|
||||
content: 'Copied to the clipboard',
|
||||
icon: 'copy',
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -1,10 +1,12 @@
|
|||
<script lang="ts">
|
||||
import type { Optional } from '../models/Types';
|
||||
import Api from './Api.vue';
|
||||
import Country from '../models/Country';
|
||||
import LocationStats from '../models/LocationStats';
|
||||
import StatsRequest from '../models/StatsRequest';
|
||||
|
||||
export default {
|
||||
mixins: [Api],
|
||||
methods: {
|
||||
async getCountries(): Promise<Country[]> {
|
||||
return (
|
||||
|
@ -21,6 +23,25 @@ export default {
|
|||
.map((record: LocationStats) => Country.fromCode(record.key.country))
|
||||
.filter((country: Optional<Country>) => !!country)
|
||||
},
|
||||
|
||||
async getLocalities(filter: {
|
||||
country: string;
|
||||
locality: string;
|
||||
postalCode: string;
|
||||
}): Promise<string[]> {
|
||||
return (
|
||||
await this.getStats(
|
||||
new StatsRequest({
|
||||
// @ts-ignore
|
||||
userId: this.$root.user.id,
|
||||
groupBy: ['locality'],
|
||||
order: 'desc',
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter((record: LocationStats) => !!record.key.locality)
|
||||
.map((record: LocationStats) => record.key.locality)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
Loading…
Add table
Reference in a new issue