Added support for editing location points.

This commit is contained in:
Fabio Manganiello 2025-04-16 09:49:09 +02:00
parent 30c66d391f
commit a9dc63c289
Signed by: blacklight
GPG key ID: D90FBA7F76362774
7 changed files with 371 additions and 18 deletions

View 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>

View file

@ -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" />&nbsp; Edit
</button>
<button title="Remove" class="remove" @click="$emit('remove', point)">
<font-awesome-icon icon="fas fa-trash-alt" />&nbsp; 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);

View file

@ -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();
});
}
},

View file

@ -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
}, {}
)

View file

@ -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;

View 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>

View file

@ -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>