diff --git a/frontend/src/components/EditPointInfo.vue b/frontend/src/components/EditPointInfo.vue new file mode 100644 index 0000000..c5d78f2 --- /dev/null +++ b/frontend/src/components/EditPointInfo.vue @@ -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> diff --git a/frontend/src/components/PointInfo.vue b/frontend/src/components/PointInfo.vue index ac8e2a9..7d5ca9d 100644 --- a/frontend/src/components/PointInfo.vue +++ b/frontend/src/components/PointInfo.vue @@ -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); diff --git a/frontend/src/elements/Autocomplete.vue b/frontend/src/elements/Autocomplete.vue index 467c3a1..888371d 100644 --- a/frontend/src/elements/Autocomplete.vue +++ b/frontend/src/elements/Autocomplete.vue @@ -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(); }); } }, diff --git a/frontend/src/elements/CountrySelector.vue b/frontend/src/elements/CountrySelector.vue index f7aad57..22dc572 100644 --- a/frontend/src/elements/CountrySelector.vue +++ b/frontend/src/elements/CountrySelector.vue @@ -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 }, {} ) diff --git a/frontend/src/elements/Modal.vue b/frontend/src/elements/Modal.vue index 05667d7..a6ed168 100644 --- a/frontend/src/elements/Modal.vue +++ b/frontend/src/elements/Modal.vue @@ -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; diff --git a/frontend/src/mixins/Clipboard.vue b/frontend/src/mixins/Clipboard.vue new file mode 100644 index 0000000..a93a304 --- /dev/null +++ b/frontend/src/mixins/Clipboard.vue @@ -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> diff --git a/frontend/src/mixins/Stats.vue b/frontend/src/mixins/Stats.vue index 7040151..22eab30 100644 --- a/frontend/src/mixins/Stats.vue +++ b/frontend/src/mixins/Stats.vue @@ -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>