This commit is contained in:
parent
36846164bd
commit
ca666c9ef8
8 changed files with 559 additions and 138 deletions
frontend/src
components
elements
mixins
models
styles
src/requests
|
@ -48,6 +48,7 @@
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="form-container" v-if="showControls">
|
<div class="form-container" v-if="showControls">
|
||||||
<FilterForm :value="locationQuery"
|
<FilterForm :value="locationQuery"
|
||||||
|
:countries="countries"
|
||||||
:devices="devices"
|
:devices="devices"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
:resolution="resolutionMeters"
|
:resolution="resolutionMeters"
|
||||||
|
@ -110,6 +111,7 @@ import { useGeographic } from 'ol/proj';
|
||||||
import type { Optional } from '../models/Types';
|
import type { Optional } from '../models/Types';
|
||||||
import Api from '../mixins/Api.vue';
|
import Api from '../mixins/Api.vue';
|
||||||
import ConfirmDialog from '../elements/ConfirmDialog.vue';
|
import ConfirmDialog from '../elements/ConfirmDialog.vue';
|
||||||
|
import Country from '../models/Country';
|
||||||
import Dates from '../mixins/Dates.vue';
|
import Dates from '../mixins/Dates.vue';
|
||||||
import Feature from 'ol/Feature';
|
import Feature from 'ol/Feature';
|
||||||
import FilterButton from './filter/ToggleButton.vue';
|
import FilterButton from './filter/ToggleButton.vue';
|
||||||
|
@ -118,11 +120,13 @@ import FloatingButton from '../elements/FloatingButton.vue';
|
||||||
import GPSPoint from '../models/GPSPoint';
|
import GPSPoint from '../models/GPSPoint';
|
||||||
import LocationQuery from '../models/LocationQuery';
|
import LocationQuery from '../models/LocationQuery';
|
||||||
import LocationQueryMixin from '../mixins/LocationQuery.vue';
|
import LocationQueryMixin from '../mixins/LocationQuery.vue';
|
||||||
|
import LocationStats from '../models/LocationStats';
|
||||||
import MapSelectOverlay from './MapSelectOverlay.vue';
|
import MapSelectOverlay from './MapSelectOverlay.vue';
|
||||||
import MapView from '../mixins/MapView.vue';
|
import MapView from '../mixins/MapView.vue';
|
||||||
import Paginate from '../mixins/Paginate.vue';
|
import Paginate from '../mixins/Paginate.vue';
|
||||||
import Points from '../mixins/Points.vue';
|
import Points from '../mixins/Points.vue';
|
||||||
import Routes from '../mixins/Routes.vue';
|
import Routes from '../mixins/Routes.vue';
|
||||||
|
import StatsRequest from '../models/StatsRequest';
|
||||||
import Timeline from './Timeline.vue';
|
import Timeline from './Timeline.vue';
|
||||||
import TimelineMetricsConfiguration from '../models/TimelineMetricsConfiguration';
|
import TimelineMetricsConfiguration from '../models/TimelineMetricsConfiguration';
|
||||||
import URLQueryHandler from '../mixins/URLQueryHandler.vue';
|
import URLQueryHandler from '../mixins/URLQueryHandler.vue';
|
||||||
|
@ -155,6 +159,7 @@ export default {
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
countries: [] as string[],
|
||||||
devices: [] as UserDevice[],
|
devices: [] as UserDevice[],
|
||||||
loading: false,
|
loading: false,
|
||||||
map: null as Optional<Map>,
|
map: null as Optional<Map>,
|
||||||
|
@ -302,6 +307,21 @@ export default {
|
||||||
await this.processQueryChange(this.locationQuery, oldQuery)
|
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 {
|
createMap(): Map {
|
||||||
this.pointsLayer = this.createPointsLayer(Object.values(this.mappedPoints) as Point[])
|
this.pointsLayer = this.createPointsLayer(Object.values(this.mappedPoints) as Point[])
|
||||||
this.routesLayer = this.createRoutesLayer(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.map = this.createMap()
|
||||||
|
this.countries = await this.getCountries()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -608,6 +629,7 @@ main {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
max-height: calc(100% + 4em);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
@ -618,6 +640,7 @@ main {
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
|
max-height: calc(100% - 8em);
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
animation: unroll 0.25s ease-out;
|
animation: unroll 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,97 +1,99 @@
|
||||||
<template>
|
<template>
|
||||||
<form class="filter-view" @submit.prevent.stop="handleSubmit">
|
<form class="filter-view" @submit.prevent.stop="handleSubmit">
|
||||||
<h2>Filter</h2>
|
<header>
|
||||||
|
<h2>Filter</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="date-range-toggle">
|
<main>
|
||||||
<input type="checkbox"
|
<div class="date-range-toggle">
|
||||||
id="date-range-toggle"
|
<input type="checkbox"
|
||||||
name="date-range-toggle"
|
id="date-range-toggle"
|
||||||
v-model="enableDateRange"
|
name="date-range-toggle"
|
||||||
:disabled="disabled" />
|
v-model="enableDateRange"
|
||||||
<label for="date-range-toggle">Enable Date Range</label>
|
:disabled="disabled" />
|
||||||
</div>
|
<label for="date-range-toggle">Set Date Range</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="date-selectors" v-if="enableDateRange">
|
<div class="date-selectors" v-if="enableDateRange">
|
||||||
<div class="date-selector">
|
<div class="date-selector">
|
||||||
<label for="start-date">
|
<label for="start-date">
|
||||||
<font-awesome-icon icon="fas fa-calendar-day" />
|
<font-awesome-icon icon="fas fa-calendar-day" />
|
||||||
Start Date
|
Start Date
|
||||||
</label>
|
</label>
|
||||||
<input type="datetime-local"
|
<input type="datetime-local"
|
||||||
id="start-date"
|
id="start-date"
|
||||||
name="start-date"
|
name="start-date"
|
||||||
@input="newFilter.startDate = startPlusHours($event, 0)"
|
@input="newFilter.startDate = startPlusHours($event, 0)"
|
||||||
@change="newFilter.startDate = startPlusHours($event, 0)"
|
@change="newFilter.startDate = startPlusHours($event, 0)"
|
||||||
:value="toLocalString(newFilter.startDate)"
|
:value="toLocalString(newFilter.startDate)"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:max="maxDate" />
|
:max="maxDate" />
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
|
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -7)"
|
||||||
:disabled="disabled || !newFilter.startDate">-1w</button>
|
:disabled="disabled || !newFilter.startDate">-1w</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
|
@click="newFilter.startDate = startPlusDays(newFilter.startDate, -1)"
|
||||||
:disabled="disabled || !newFilter.startDate">-1d</button>
|
:disabled="disabled || !newFilter.startDate">-1d</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
|
@click="newFilter.startDate = startPlusHours(newFilter.startDate, -1)"
|
||||||
:disabled="disabled || !newFilter.startDate">-1h</button>
|
:disabled="disabled || !newFilter.startDate">-1h</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@click="newFilter.startDate = startPlusDays(new Date(), 0)"
|
@click="newFilter.startDate = startPlusDays(new Date(), 0)"
|
||||||
:disabled="disabled || !newFilter.startDate">Now</button>
|
:disabled="disabled || !newFilter.startDate">Now</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
|
@click="newFilter.startDate = startPlusHours(newFilter.startDate, 1)"
|
||||||
:disabled="disabled || !newFilter.startDate">+1h</button>
|
:disabled="disabled || !newFilter.startDate">+1h</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
|
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 1)"
|
||||||
:disabled="disabled || !newFilter.startDate">+1d</button>
|
:disabled="disabled || !newFilter.startDate">+1d</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
|
@click="newFilter.startDate = startPlusDays(newFilter.startDate, 7)"
|
||||||
:disabled="disabled || !newFilter.startDate">+1w</button>
|
: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>
|
</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">
|
<div class="container limit-container">
|
||||||
<label for="limit">
|
<label for="limit">
|
||||||
<font-awesome-icon icon="fas fa-list-ol" />
|
<font-awesome-icon icon="fas fa-list-ol" />
|
||||||
|
@ -120,9 +122,7 @@
|
||||||
<option value="desc">Newest points first</option>
|
<option value="desc">Newest points first</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="container resolution-container">
|
<div class="container resolution-container">
|
||||||
<label for="resolution">
|
<label for="resolution">
|
||||||
<p class="title">
|
<p class="title">
|
||||||
|
@ -158,19 +158,105 @@
|
||||||
<option v-for="device in devices" :key="device.id" :value="device.id">{{ device.name }}</option>
|
<option v-for="device in devices" :key="device.id" :value="device.id">{{ device.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<button type="submit" :disabled="disabled || !changed">
|
||||||
<font-awesome-icon icon="fas fa-check" /> Apply
|
<font-awesome-icon icon="fas fa-check" /> Apply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import _ from 'lodash'
|
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 LocationQuery from '../../models/LocationQuery'
|
||||||
import LocationQueryMixin from '../../mixins/LocationQuery.vue'
|
import LocationQueryMixin from '../../mixins/LocationQuery.vue'
|
||||||
import UserDevice from '../../models/UserDevice'
|
import UserDevice from '../../models/UserDevice'
|
||||||
|
@ -182,8 +268,16 @@ export default {
|
||||||
'set-resolution',
|
'set-resolution',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
components: {
|
||||||
|
Autocomplete,
|
||||||
|
},
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
value: LocationQuery,
|
value: LocationQuery,
|
||||||
|
countries: {
|
||||||
|
type: Array as () => Country[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
devices: {
|
devices: {
|
||||||
type: Array as () => UserDevice[],
|
type: Array as () => UserDevice[],
|
||||||
default: () => [],
|
default: () => [],
|
||||||
|
@ -199,6 +293,13 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
autocompleteCountries(): AutocompleteValue[] {
|
||||||
|
return this.countries.map((country: Country) => ({
|
||||||
|
value: country.code,
|
||||||
|
label: `${country.flag} ${country.name}`,
|
||||||
|
data: country,
|
||||||
|
}))
|
||||||
|
},
|
||||||
maxDate() {
|
maxDate() {
|
||||||
return this.toLocalString(this.endPlusHours(new Date(), 0))
|
return this.toLocalString(this.endPlusHours(new Date(), 0))
|
||||||
}
|
}
|
||||||
|
@ -340,26 +441,95 @@ export default {
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use "@/styles/common.scss" as *;
|
@use "@/styles/common.scss" as *;
|
||||||
|
|
||||||
|
$header-height: 2.5em;
|
||||||
|
$footer-height: 3.5em;
|
||||||
|
|
||||||
.filter-view {
|
.filter-view {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 1em 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1em;
|
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
|
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66);
|
||||||
|
|
||||||
@include media(mobile) {
|
@include until(desktop) {
|
||||||
width: calc(100vw - 2em);
|
width: calc(100vw - 2em);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include media(tablet) {
|
@include from(desktop) {
|
||||||
|
width: 50em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
width: 100%;
|
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 {
|
.date-selectors {
|
||||||
|
@ -403,40 +573,16 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-range-toggle {
|
.date-range-toggle {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0.5em 0 -0.5em 0;
|
justify-content: center;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
margin-right: 0.25em;
|
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 {
|
.limit-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -444,11 +590,6 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
.resolution-container {
|
.resolution-container {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
margin: 0.5em;
|
|
||||||
|
|
||||||
label {
|
label {
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
179
frontend/src/elements/Autocomplete.vue
Normal file
179
frontend/src/elements/Autocomplete.vue
Normal file
|
@ -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>
|
|
@ -14,18 +14,22 @@ export default {
|
||||||
newValue?: LocationQuery,
|
newValue?: LocationQuery,
|
||||||
oldValue?: LocationQuery,
|
oldValue?: LocationQuery,
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return !_.isEqual(
|
const values = [oldValue, newValue].map((value) =>
|
||||||
{
|
Object.entries(value || {}).reduce((acc, [key, val]) => {
|
||||||
...(oldValue || {}),
|
// Replace all undefined values with null to avoid the comparison from breaking
|
||||||
startDate: this.normalizeDate(oldValue?.startDate),
|
// when an attribute is not set and it's undefined on one side and null on the other
|
||||||
endDate: this.normalizeDate(oldValue?.endDate),
|
acc[key] = val === undefined ? null : val
|
||||||
},
|
|
||||||
{
|
// Normalize dates to avoid issues with different date formats
|
||||||
...(newValue || {}),
|
if (key === 'startDate' || key === 'endDate') {
|
||||||
startDate: this.normalizeDate(newValue?.startDate),
|
acc[key] = this.normalizeDate(val)
|
||||||
endDate: this.normalizeDate(newValue?.endDate),
|
}
|
||||||
}
|
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return !_.isEqual(values[0], values[1])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
21
frontend/src/models/AutocompleteValue.ts
Normal file
21
frontend/src/models/AutocompleteValue.ts
Normal file
|
@ -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;
|
36
frontend/src/models/Country.ts
Normal file
36
frontend/src/models/Country.ts
Normal file
|
@ -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;
|
|
@ -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 {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,7 +58,7 @@ class LocationRequest {
|
||||||
this.initNumber('maxLatitude', req, parseFloat);
|
this.initNumber('maxLatitude', req, parseFloat);
|
||||||
this.initNumber('minLongitude', req, parseFloat);
|
this.initNumber('minLongitude', req, parseFloat);
|
||||||
this.initNumber('maxLongitude', req, parseFloat);
|
this.initNumber('maxLongitude', req, parseFloat);
|
||||||
this.country = req.country;
|
this.country = req.country?.toLowerCase();
|
||||||
this.locality = req.locality;
|
this.locality = req.locality;
|
||||||
this.postalCode = req.postalCode;
|
this.postalCode = req.postalCode;
|
||||||
this.description = req.description;
|
this.description = req.description;
|
||||||
|
|
Loading…
Add table
Reference in a new issue