diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 1bc8f13..3e2e12e 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -48,6 +48,7 @@ <div class="controls"> <div class="form-container" v-if="showControls"> <FilterForm :value="locationQuery" + :countries="countries" :devices="devices" :disabled="loading" :resolution="resolutionMeters" @@ -110,6 +111,7 @@ import { useGeographic } from 'ol/proj'; import type { Optional } from '../models/Types'; import Api from '../mixins/Api.vue'; import ConfirmDialog from '../elements/ConfirmDialog.vue'; +import Country from '../models/Country'; import Dates from '../mixins/Dates.vue'; import Feature from 'ol/Feature'; import FilterButton from './filter/ToggleButton.vue'; @@ -118,11 +120,13 @@ import FloatingButton from '../elements/FloatingButton.vue'; import GPSPoint from '../models/GPSPoint'; import LocationQuery from '../models/LocationQuery'; import LocationQueryMixin from '../mixins/LocationQuery.vue'; +import LocationStats from '../models/LocationStats'; import MapSelectOverlay from './MapSelectOverlay.vue'; import MapView from '../mixins/MapView.vue'; import Paginate from '../mixins/Paginate.vue'; import Points from '../mixins/Points.vue'; import Routes from '../mixins/Routes.vue'; +import StatsRequest from '../models/StatsRequest'; import Timeline from './Timeline.vue'; import TimelineMetricsConfiguration from '../models/TimelineMetricsConfiguration'; import URLQueryHandler from '../mixins/URLQueryHandler.vue'; @@ -155,6 +159,7 @@ export default { data() { return { + countries: [] as string[], devices: [] as UserDevice[], loading: false, map: null as Optional<Map>, @@ -302,6 +307,21 @@ export default { await this.processQueryChange(this.locationQuery, oldQuery) }, + async getCountries() { + return ( + await this.getStats( + new StatsRequest({ + userId: this.$root.user.id, + groupBy: ['country'], + order: 'desc', + }) + ) + ) + .filter((record: LocationStats) => !!record.key.country) + .map((record: LocationStats) => Country.fromCode(record.key.country)) + .filter((country: Optional<Country>) => !!country) + }, + createMap(): Map { this.pointsLayer = this.createPointsLayer(Object.values(this.mappedPoints) as Point[]) this.routesLayer = this.createRoutesLayer(Object.values(this.mappedPoints) as Point[]) @@ -575,6 +595,7 @@ export default { ]) this.map = this.createMap() + this.countries = await this.getCountries() }, } </script> @@ -608,6 +629,7 @@ main { position: absolute; bottom: 0; left: 0; + max-height: calc(100% + 4em); display: flex; flex-direction: column; padding: 0.5em; @@ -618,6 +640,7 @@ main { } .form-container { + max-height: calc(100% - 8em); margin-bottom: 0.5em; animation: unroll 0.25s ease-out; } diff --git a/frontend/src/components/filter/Form.vue b/frontend/src/components/filter/Form.vue index d6394ad..8c0de79 100644 --- a/frontend/src/components/filter/Form.vue +++ b/frontend/src/components/filter/Form.vue @@ -1,97 +1,99 @@ <template> <form class="filter-view" @submit.prevent.stop="handleSubmit"> - <h2>Filter</h2> + <header> + <h2>Filter</h2> + </header> - <div class="date-range-toggle"> - <input type="checkbox" - id="date-range-toggle" - name="date-range-toggle" - v-model="enableDateRange" - :disabled="disabled" /> - <label for="date-range-toggle">Enable Date Range</label> - </div> + <main> + <div class="date-range-toggle"> + <input type="checkbox" + id="date-range-toggle" + name="date-range-toggle" + v-model="enableDateRange" + :disabled="disabled" /> + <label for="date-range-toggle">Set Date Range</label> + </div> - <div class="date-selectors" v-if="enableDateRange"> - <div class="date-selector"> - <label for="start-date"> - <font-awesome-icon icon="fas fa-calendar-day" /> - Start Date - </label> - <input type="datetime-local" - id="start-date" - name="start-date" - @input="newFilter.startDate = startPlusHours($event, 0)" - @change="newFilter.startDate = startPlusHours($event, 0)" - :value="toLocalString(newFilter.startDate)" - :disabled="disabled" - :max="maxDate" /> + <div class="date-selectors" v-if="enableDateRange"> + <div class="date-selector"> + <label for="start-date"> + <font-awesome-icon icon="fas fa-calendar-day" /> + Start Date + </label> + <input type="datetime-local" + id="start-date" + name="start-date" + @input="newFilter.startDate = startPlusHours($event, 0)" + @change="newFilter.startDate = startPlusHours($event, 0)" + :value="toLocalString(newFilter.startDate)" + :disabled="disabled" + :max="maxDate" /> - <div class="footer"> - <button type="button" - @click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)" - :disabled="disabled || !newFilter.startDate">-1w</button> - <button type="button" - @click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)" - :disabled="disabled || !newFilter.startDate">-1d</button> - <button type="button" - @click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)" - :disabled="disabled || !newFilter.startDate">-1h</button> - <button type="button" - @click="newFilter.startDate = startPlusDays(new Date(), 0)" - :disabled="disabled || !newFilter.startDate">Now</button> - <button type="button" - @click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)" - :disabled="disabled || !newFilter.startDate">+1h</button> - <button type="button" - @click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)" - :disabled="disabled || !newFilter.startDate">+1d</button> - <button type="button" - @click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)" - :disabled="disabled || !newFilter.startDate">+1w</button> + <div class="footer"> + <button type="button" + @click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)" + :disabled="disabled || !newFilter.startDate">-1w</button> + <button type="button" + @click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)" + :disabled="disabled || !newFilter.startDate">-1d</button> + <button type="button" + @click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)" + :disabled="disabled || !newFilter.startDate">-1h</button> + <button type="button" + @click="newFilter.startDate = startPlusDays(new Date(), 0)" + :disabled="disabled || !newFilter.startDate">Now</button> + <button type="button" + @click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)" + :disabled="disabled || !newFilter.startDate">+1h</button> + <button type="button" + @click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)" + :disabled="disabled || !newFilter.startDate">+1d</button> + <button type="button" + @click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)" + :disabled="disabled || !newFilter.startDate">+1w</button> + </div> + </div> + + <div class="date-selector"> + <label for="end-date"> + <font-awesome-icon icon="fas fa-calendar-day" /> + End Date + </label> + <input type="datetime-local" + id="end-date" + name="end-date" + @input="newFilter.endDate = endPlusHours($event, 0)" + @change="newFilter.endDate = endPlusHours($event, 0)" + :value="toLocalString(newFilter.endDate)" + :disabled="disabled" + :max="maxDate" /> + + <div class="footer"> + <button type="button" + @click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)" + :disabled="disabled || !newFilter.endDate">-1w</button> + <button type="button" + @click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)" + :disabled="disabled || !newFilter.endDate">-1d</button> + <button type="button" + @click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)" + :disabled="disabled || !newFilter.endDate">-1h</button> + <button type="button" + @click="newFilter.endDate = endPlusDays(new Date(), 0)" + :disabled="disabled || !newFilter.endDate">Now</button> + <button type="button" + @click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)" + :disabled="disabled || !newFilter.endDate">+1h</button> + <button type="button" + @click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)" + :disabled="disabled || !newFilter.endDate">+1d</button> + <button type="button" + @click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)" + :disabled="disabled || !newFilter.endDate">+1w</button> + </div> </div> </div> - <div class="date-selector"> - <label for="end-date"> - <font-awesome-icon icon="fas fa-calendar-day" /> - End Date - </label> - <input type="datetime-local" - id="end-date" - name="end-date" - @input="newFilter.endDate = endPlusHours($event, 0)" - @change="newFilter.endDate = endPlusHours($event, 0)" - :value="toLocalString(newFilter.endDate)" - :disabled="disabled" - :max="maxDate" /> - - <div class="footer"> - <button type="button" - @click="newFilter.endDate = endPlusDays(newFilter.endDate, -7)" - :disabled="disabled || !newFilter.endDate">-1w</button> - <button type="button" - @click="newFilter.endDate = endPlusDays(newFilter.endDate, -1)" - :disabled="disabled || !newFilter.endDate">-1d</button> - <button type="button" - @click="newFilter.endDate = endPlusHours(newFilter.endDate, -1)" - :disabled="disabled || !newFilter.endDate">-1h</button> - <button type="button" - @click="newFilter.endDate = endPlusDays(new Date(), 0)" - :disabled="disabled || !newFilter.endDate">Now</button> - <button type="button" - @click="newFilter.endDate = endPlusHours(newFilter.endDate, 1)" - :disabled="disabled || !newFilter.endDate">+1h</button> - <button type="button" - @click="newFilter.endDate = endPlusDays(newFilter.endDate, 1)" - :disabled="disabled || !newFilter.endDate">+1d</button> - <button type="button" - @click="newFilter.endDate = endPlusDays(newFilter.endDate, 7)" - :disabled="disabled || !newFilter.endDate">+1w</button> - </div> - </div> - </div> - - <div class="form-row"> <div class="container limit-container"> <label for="limit"> <font-awesome-icon icon="fas fa-list-ol" /> @@ -120,9 +122,7 @@ <option value="desc">Newest points first</option> </select> </div> - </div> - <div class="form-row"> <div class="container resolution-container"> <label for="resolution"> <p class="title"> @@ -158,19 +158,105 @@ <option v-for="device in devices" :key="device.id" :value="device.id">{{ device.name }}</option> </select> </div> - </div> - <div class="footer"> + <div class="container country-container"> + <label for="country"> + <p class="title"> + <font-awesome-icon icon="fas fa-globe" /> + Country + </p> + </label> + + <Autocomplete + id="country" + name="country" + placeholder="Filter by country" + allow-only-values + v-model="newFilter.country" + :values="autocompleteCountries" + :disabled="disabled" + @input="newFilter.country = $event" /> + </div> + + <div class="container locality-container"> + <label for="locality"> + <p class="title"> + <font-awesome-icon icon="fas fa-map-marker-alt" /> + Locality + </p> + </label> + + <input type="text" + id="locality" + name="locality" + placeholder="Filter by locality" + v-model="newFilter.locality" + :disabled="disabled" /> + </div> + + <div class="container address-container"> + <label for="address"> + <p class="title"> + <font-awesome-icon icon="fas fa-home" /> + Address + </p> + </label> + + <input type="text" + id="address" + name="address" + placeholder="Filter by address" + v-model="newFilter.address" + :disabled="disabled" /> + </div> + + <div class="container postal-code-container col-s-12"> + <label for="postal-code"> + <p class="title"> + <font-awesome-icon icon="fas fa-mail-bulk" /> + Postal Code + </p> + </label> + + <input type="text" + id="postal-code" + name="postal-code" + placeholder="Filter by postal code" + v-model="newFilter.postalCode" + :disabled="disabled" /> + </div> + + <div class="container description-container"> + <label for="description"> + <p class="title"> + <font-awesome-icon icon="fas fa-comment" /> + Description + </p> + </label> + + <input type="text" + id="description" + name="description" + placeholder="Filter by description" + v-model="newFilter.description" + :disabled="disabled" /> + </div> + </main> + + <footer> <button type="submit" :disabled="disabled || !changed"> <font-awesome-icon icon="fas fa-check" /> Apply </button> - </div> + </footer> </form> </template> <script lang="ts"> import _ from 'lodash' +import Autocomplete from '../../elements/Autocomplete.vue' +import AutocompleteValue from '../../models/AutocompleteValue' +import Country from '../../models/Country' import LocationQuery from '../../models/LocationQuery' import LocationQueryMixin from '../../mixins/LocationQuery.vue' import UserDevice from '../../models/UserDevice' @@ -182,8 +268,16 @@ export default { 'set-resolution', ], + components: { + Autocomplete, + }, + props: { value: LocationQuery, + countries: { + type: Array as () => Country[], + default: () => [], + }, devices: { type: Array as () => UserDevice[], default: () => [], @@ -199,6 +293,13 @@ export default { }, computed: { + autocompleteCountries(): AutocompleteValue[] { + return this.countries.map((country: Country) => ({ + value: country.code, + label: `${country.flag} ${country.name}`, + data: country, + })) + }, maxDate() { return this.toLocalString(this.endPlusHours(new Date(), 0)) } @@ -340,26 +441,95 @@ export default { <style lang="scss" scoped> @use "@/styles/common.scss" as *; +$header-height: 2.5em; +$footer-height: 3.5em; + .filter-view { height: 100%; background: var(--color-background); display: flex; flex-direction: column; align-items: center; + padding: 1em 0; justify-content: center; - padding: 1em; border: 1px solid var(--color-border); border-radius: 0.5em; margin-bottom: 0.25em; box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66); - @include media(mobile) { + @include until(desktop) { width: calc(100vw - 2em); } - @include media(tablet) { + @include from(desktop) { + width: 50em; + } + + header { width: 100%; - min-width: 45em; + height: $header-height; + display: flex; + justify-content: center; + } + + footer { + width: 100%; + height: $footer-height; + display: flex; + justify-content: center; + + button { + height: 2.5em; + } + } + + main { + height: calc(100% - $header-height - $footer-height - 5em); + max-height: 25em; + overflow-y: auto; + overflow-x: hidden; + display: flex; + padding: 1em; + + @include until(tablet) { + flex-direction: column; + flex-wrap: nowrap; + } + + @include from(tablet) { + flex-direction: row; + flex-wrap: wrap; + } + + .container { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5em 1em; + flex-direction: column; + + @include until(tablet) { + width: 100%; + } + + @include from(tablet) { + width: 50%; + } + + label { + margin-bottom: 0.25em; + @include until(tablet) { + margin-top: 0.75em; + } + } + + input { + width: 100%; + @include until(tablet) { + margin-bottom: 0.5em; + } + } + } } .date-selectors { @@ -403,40 +573,16 @@ export default { } .date-range-toggle { + width: 100%; display: flex; align-items: center; - margin: 0.5em 0 -0.5em 0; + justify-content: center; input { margin-right: 0.25em; } } - .form-row { - width: 100%; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - margin: 0.5em; - - .container { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - padding: 0 1em; - } - - label { - margin-bottom: 0.25em; - } - - input { - width: 100%; - } - } - .limit-container { display: flex; flex-direction: column; @@ -444,11 +590,6 @@ export default { } .resolution-container { - display: flex; - flex-direction: column; - align-items: center; - margin: 0.5em; - label { margin-bottom: 0.25em; display: flex; diff --git a/frontend/src/elements/Autocomplete.vue b/frontend/src/elements/Autocomplete.vue new file mode 100644 index 0000000..a2df131 --- /dev/null +++ b/frontend/src/elements/Autocomplete.vue @@ -0,0 +1,179 @@ +<template> + <div class="autocomplete"> + <div class="input"> + <input + type="text" + :id="id" + :name="name" + :placeholder="placeholder" + v-model="newValue" + @focus="onFocus" + @blur="onBlur" + ref="input" + /> + </div> + <div class="values" v-if="showValues" @click.stop> + <ul> + <li v-for="value in filteredValues" + :key="value.value" + @click.stop="onItemClick(value)"> + {{ value.label }} + </li> + </ul> + </div> + </div> +</template> + +<script lang="ts"> +import AutocompleteValue from '../models/AutocompleteValue'; + +export default { + emits: ['input'], + + props: { + id: { + type: String, + default: '', + }, + + name: { + type: String, + default: '', + }, + + value: { + type: String, + default: '', + }, + + values: { + type: Array as () => AutocompleteValue[], + default: () => [], + }, + + allowOnlyValues: { + type: Boolean, + default: false, + }, + + placeholder: { + type: String, + default: 'Search...', + }, + }, + + data() { + return { + newValue: '' + this.value, + showValues: false, + }; + }, + + computed: { + filteredValues() { + if (!this.newValue?.length) { + return this.values; + } + + let matches = this.values.filter((value: AutocompleteValue) => + value.value.toLowerCase() === this.newValue.toLowerCase() + ) as AutocompleteValue[]; + + if (!matches.length) { + matches = this.values.filter((value: AutocompleteValue) => + value.label.toLowerCase().includes(this.newValue.toLowerCase()) + ) as AutocompleteValue[]; + } + + return matches; + }, + + indexedValues() { + return Object.fromEntries( + this.values.map((value: AutocompleteValue) => [ + value.value, + value, + ]) + ); + }, + }, + + methods: { + onFocus() { + this.showValues = true; + }, + + onBlur() { + setTimeout(() => { + this.showValues = false; + this.emitInput(); + }, 500); + }, + + onItemClick(value: AutocompleteValue) { + this.newValue = value.value; + this.showValues = false; + this.emitInput(); + }, + + emitInput() { + this.$emit( + 'input', + this.allowOnlyValues + ? this.indexedValues[this.newValue]?.value + : this.newValue + ) + } + }, + + watch: { + value(newValue: string) { + this.newValue = newValue; + }, + + newValue() { + this.emitInput(); + }, + }, +} +</script> + +<style lang="scss" scoped> +.autocomplete { + width: 100%; + position: relative; + + .input { + input { + width: 100%; + } + } + + .values { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 20em; + overflow: auto; + background-color: var(--color-background); + z-index: 10; + box-shadow: 0 0 0.5rem 0 rgba(0, 0, 0, 0.5); + + ul { + list-style-type: none; + margin: 0; + padding: 0; + + li { + padding: 0.8em; + cursor: pointer; + + &:hover { + background-color: var(--color-accent-bg); + } + } + } + } +} +</style> diff --git a/frontend/src/mixins/LocationQuery.vue b/frontend/src/mixins/LocationQuery.vue index 541212d..ef5ed5f 100644 --- a/frontend/src/mixins/LocationQuery.vue +++ b/frontend/src/mixins/LocationQuery.vue @@ -14,18 +14,22 @@ export default { newValue?: LocationQuery, oldValue?: LocationQuery, }): boolean { - return !_.isEqual( - { - ...(oldValue || {}), - startDate: this.normalizeDate(oldValue?.startDate), - endDate: this.normalizeDate(oldValue?.endDate), - }, - { - ...(newValue || {}), - startDate: this.normalizeDate(newValue?.startDate), - endDate: this.normalizeDate(newValue?.endDate), - } + const values = [oldValue, newValue].map((value) => + Object.entries(value || {}).reduce((acc, [key, val]) => { + // Replace all undefined values with null to avoid the comparison from breaking + // when an attribute is not set and it's undefined on one side and null on the other + acc[key] = val === undefined ? null : val + + // Normalize dates to avoid issues with different date formats + if (key === 'startDate' || key === 'endDate') { + acc[key] = this.normalizeDate(val) + } + + return acc + }, {} as Record<string, any>) ) + + return !_.isEqual(values[0], values[1]) }, } } diff --git a/frontend/src/models/AutocompleteValue.ts b/frontend/src/models/AutocompleteValue.ts new file mode 100644 index 0000000..4580677 --- /dev/null +++ b/frontend/src/models/AutocompleteValue.ts @@ -0,0 +1,21 @@ +class AutocompleteValue { + value: string; + label: string; + data?: any | null = undefined; + + constructor(record: { + value: string; + label: string; + data?: any | null; + }) { + this.value = record.value; + this.label = record.label; + this.data = record.data; + } + + toString(): string { + return this.label; + } +} + +export default AutocompleteValue; diff --git a/frontend/src/models/Country.ts b/frontend/src/models/Country.ts new file mode 100644 index 0000000..a6fcec4 --- /dev/null +++ b/frontend/src/models/Country.ts @@ -0,0 +1,36 @@ +import type { TCountryCode } from 'countries-list'; +import { getCountryData, getEmojiFlag } from 'countries-list'; + +class Country { + name: string; + code: string; + continent: string; + flag: string; + + constructor(data: { + name: string; + code: string; + continent: string; + flag: string; + }) { + this.name = data.name; + this.code = data.code; + this.continent = data.continent; + this.flag = data.flag; + } + + public static fromCode(code: string | TCountryCode): Country | null { + const cc = code.toUpperCase() as TCountryCode; + const countryData = getCountryData(cc); + if (!countryData) return null; + + return new Country({ + name: countryData.name, + code: code, + continent: countryData.continent, + flag: getEmojiFlag(cc), + }); + } +} + +export default Country; diff --git a/frontend/src/styles/layout.scss b/frontend/src/styles/layout.scss index 6b2f213..6c024fd 100644 --- a/frontend/src/styles/layout.scss +++ b/frontend/src/styles/layout.scss @@ -16,6 +16,23 @@ $breakpoints: ( } } +// Generate col-<breakpoint>-<number> classes +$screen-size-to-breakpoint: ( + s:mobile, + m:tablet, + l:desktop +); + +@for $i from 1 through 12 { + @each $screen-size, $breakpoint in $screen-size-to-breakpoint { + .col-#{$screen-size}-#{$i} { + @media (min-width: map.get($breakpoints, $breakpoint)) { + width: (100% / 12) * $i; + } + } + } +} + .hidden { display: none !important; } diff --git a/src/requests/LocationRequest.ts b/src/requests/LocationRequest.ts index 44b3a90..a1bb7c8 100644 --- a/src/requests/LocationRequest.ts +++ b/src/requests/LocationRequest.ts @@ -58,7 +58,7 @@ class LocationRequest { this.initNumber('maxLatitude', req, parseFloat); this.initNumber('minLongitude', req, parseFloat); this.initNumber('maxLongitude', req, parseFloat); - this.country = req.country; + this.country = req.country?.toLowerCase(); this.locality = req.locality; this.postalCode = req.postalCode; this.description = req.description;