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
|
{{ speedKmH }} km/h
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- @vue-ignore -->
|
||||||
<form class="description editor" @submit.prevent="editPoint" v-if="editDescription">
|
<form class="description editor" @submit.prevent="editPoint" v-if="editDescription">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<!-- @vue-ignore -->
|
||||||
<textarea
|
<textarea
|
||||||
:value="point.description"
|
:value="point.description"
|
||||||
@keydown.enter="editPoint"
|
@keydown.enter="editPoint"
|
||||||
|
@ -87,18 +89,36 @@
|
||||||
</p>
|
</p>
|
||||||
<p class="timestamp" v-if="timeString">{{ timeString }}</p>
|
<p class="timestamp" v-if="timeString">{{ timeString }}</p>
|
||||||
|
|
||||||
<div class="remove">
|
<div class="buttons">
|
||||||
<button title="Remove" @click="$emit('remove', point)">
|
<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
|
<font-awesome-icon icon="fas fa-trash-alt" /> Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import EditPointInfo from './EditPointInfo.vue';
|
||||||
import Map from 'ol/Map';
|
import Map from 'ol/Map';
|
||||||
|
import Modal from '../elements/Modal.vue';
|
||||||
import Overlay from 'ol/Overlay';
|
import Overlay from 'ol/Overlay';
|
||||||
import type { TCountryCode } from 'countries-list';
|
import type { TCountryCode } from 'countries-list';
|
||||||
import { getCountryData, getEmojiFlag } from 'countries-list';
|
import { getCountryData, getEmojiFlag } from 'countries-list';
|
||||||
|
@ -110,6 +130,11 @@ import UserDevice from '../models/UserDevice';
|
||||||
export default {
|
export default {
|
||||||
emit: ['close', 'edit', 'remove'],
|
emit: ['close', 'edit', 'remove'],
|
||||||
mixins: [Dates],
|
mixins: [Dates],
|
||||||
|
components: {
|
||||||
|
EditPointInfo,
|
||||||
|
Modal,
|
||||||
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
device: {
|
device: {
|
||||||
type: [UserDevice, null],
|
type: [UserDevice, null],
|
||||||
|
@ -121,9 +146,11 @@ export default {
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
newValue: {} as GPSPoint,
|
// @ts-ignore
|
||||||
|
newValue: this.point ? new GPSPoint({ ...this.point }) : null as GPSPoint | null,
|
||||||
editDescription: false,
|
editDescription: false,
|
||||||
popup: null as Overlay | null,
|
popup: null as Overlay | null,
|
||||||
|
showEditModal: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -217,10 +244,22 @@ export default {
|
||||||
map.addOverlay(this.popup)
|
map.addOverlay(this.popup)
|
||||||
},
|
},
|
||||||
|
|
||||||
editPoint() {
|
editPoint(newValue: GPSPoint | null | undefined) {
|
||||||
this.newValue.description = (this.$refs.description as HTMLTextAreaElement).value
|
|
||||||
this.$emit('edit', this.newValue)
|
|
||||||
this.editDescription = false
|
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() {
|
onDescriptionBlur() {
|
||||||
|
@ -408,17 +447,30 @@ export default {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.remove {
|
.buttons {
|
||||||
font-size: 0.85em;
|
display: flex;
|
||||||
margin-top: 0.5em;
|
justify-content: space-between;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: none;
|
background: none;
|
||||||
border: 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);
|
color: var(--vt-c-red-fg-light);
|
||||||
margin-left: -0.5em;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--vt-c-red-fg-dark);
|
color: var(--vt-c-red-fg-dark);
|
||||||
|
|
|
@ -80,7 +80,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
let matches = this.values.filter((value: AutocompleteValue) =>
|
let matches = this.values.filter((value: AutocompleteValue) =>
|
||||||
value.value.toLowerCase() === this.newValue.toLowerCase()
|
value?.value?.toLowerCase() === this.newValue.toLowerCase()
|
||||||
) as AutocompleteValue[];
|
) as AutocompleteValue[];
|
||||||
|
|
||||||
if (!matches.length) {
|
if (!matches.length) {
|
||||||
|
@ -188,7 +188,7 @@ export default {
|
||||||
|
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.input.focus();
|
(this.$refs.input as HTMLInputElement).focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -90,7 +90,9 @@ export default {
|
||||||
if (visitedCountries[key]) {
|
if (visitedCountries[key]) {
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
acc[key] = this.toAutocompleteValue(countries[key])
|
|
||||||
|
// @ts-ignore
|
||||||
|
acc[key] = this.toAutocompleteValue(countries[key] as any)
|
||||||
return acc
|
return acc
|
||||||
}, {}
|
}, {}
|
||||||
)
|
)
|
||||||
|
|
|
@ -36,6 +36,8 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@use '@/styles/common.scss' as *;
|
||||||
|
|
||||||
.modal-container {
|
.modal-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -49,13 +51,21 @@ export default {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
min-width: 30em;
|
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
box-shadow: 0 0 1em rgba(0, 0, 0, 0.5);
|
box-shadow: 0 0 1em rgba(0, 0, 0, 0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: fade-in 0.5s;
|
animation: fade-in 0.5s;
|
||||||
|
|
||||||
|
@include until(tablet) {
|
||||||
|
min-width: 90vw;
|
||||||
|
max-width: 95vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include from(tablet) {
|
||||||
|
min-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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">
|
<script lang="ts">
|
||||||
import type { Optional } from '../models/Types';
|
import type { Optional } from '../models/Types';
|
||||||
|
import Api from './Api.vue';
|
||||||
import Country from '../models/Country';
|
import Country from '../models/Country';
|
||||||
import LocationStats from '../models/LocationStats';
|
import LocationStats from '../models/LocationStats';
|
||||||
import StatsRequest from '../models/StatsRequest';
|
import StatsRequest from '../models/StatsRequest';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [Api],
|
||||||
methods: {
|
methods: {
|
||||||
async getCountries(): Promise<Country[]> {
|
async getCountries(): Promise<Country[]> {
|
||||||
return (
|
return (
|
||||||
|
@ -21,6 +23,25 @@ export default {
|
||||||
.map((record: LocationStats) => Country.fromCode(record.key.country))
|
.map((record: LocationStats) => Country.fromCode(record.key.country))
|
||||||
.filter((country: Optional<Country>) => !!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>
|
</script>
|
||||||
|
|
Loading…
Add table
Reference in a new issue