Don't auto-select first autocomplete entry, to avoid timing issues with pressing enter key before results have been loaded. Reintroduce separate search results page and add places search there too. Fixes #31

master
Manuel Kasper 2025-07-07 15:36:30 +02:00
rodzic 20e433b89c
commit d797895b23
3 zmienionych plików z 279 dodań i 4 usunięć

Wyświetl plik

@ -8,7 +8,7 @@
:loading="isLoading"
:open-on-focus="true"
:clear-on-select="true"
:keep-first="true"
:keep-first="false"
placeholder="Summit, Callsign, Coords, Place..."
field="label"
icon-pack="far"
@ -19,6 +19,7 @@
@select="onSelect"
@focus="searchFocus"
@blur="searchBlur"
@keydown.native.enter="doSearch"
>
<template slot="empty">
<span v-if="isLoading">Searching...</span>
@ -88,6 +89,12 @@ const PLACE_TYPE_ICONS = {
poi: 'landmark'
}
const COORDINATE_REGEX = /^\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*$/
const REGION_REGEX = /^[A-Z0-9]{1,3}\/[A-Z]{2}$/i
const SUMMIT_REF_EXACT_REGEX = /^([A-Z0-9]{1,3})\/([A-Z]{2})-([0-9]{3})$/i
const SUMMIT_REF_RELAXED_REGEX = /^([A-Z0-9]{1,3})[/ ]?([A-Z]{2})[- ]?([0-9]{3})$/i
const REGION_NUM_REGEX = /^([A-Z]{2})[ -]?([0-9]{3})$/i
maptilersdk.config.apiKey = process.env.VUE_APP_MAPTILER_KEY
export default {
@ -137,7 +144,7 @@ export default {
}
},
makeCoordinateResult (value) {
const coordMatches = value.match(/^\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*$/)
const coordMatches = value.match(COORDINATE_REGEX)
if (coordMatches) {
return {
type: 'coordinates',
@ -148,7 +155,7 @@ export default {
return null
},
makeRegionResult (value) {
const regionMatches = value.match(/^[A-Z0-9]{1,3}\/[A-Z]{2}$/i)
const regionMatches = value.match(REGION_REGEX)
if (regionMatches) {
return {
type: 'region',
@ -216,7 +223,7 @@ export default {
// Accepts e.g. HBVS123, HB/VS-123, HB VS 123, etc.
// Converts to HB/VS-123
let ref = value.trim().toUpperCase()
let m = ref.match(/^([A-Z0-9]{1,8})[/ ]?([A-Z]{2})[- ]?([0-9]{3})$/)
let m = ref.match(SUMMIT_REF_RELAXED_REGEX)
if (m) {
return `${m[1]}/${m[2]}-${m[3]}`
}
@ -298,6 +305,41 @@ export default {
}
this.$emit('search')
},
doSearch () {
let targetUrl = null
if (this.myQuery.length > 0) {
// Coordinates?
let coordMatches = this.myQuery.match(COORDINATE_REGEX)
if (coordMatches) {
targetUrl = '/map/coordinates/' + coordMatches[1] + ',' + coordMatches[2] + '/16.0?popup=1'
} else {
// Full summit reference?
let normalizedRef = this.normalizeSummitRef(this.myQuery)
if (SUMMIT_REF_EXACT_REGEX.test(normalizedRef)) {
targetUrl = '/summits/' + normalizedRef
} else {
// Region?
let regionMatches = this.myQuery.match(REGION_REGEX)
if (regionMatches) {
targetUrl = '/summits/' + this.myQuery.toUpperCase()
} else {
// Region + number without dash (and without association?)
let regionNumMatches = this.myQuery.match(REGION_NUM_REGEX)
if (regionNumMatches) {
this.myQuery = regionNumMatches[1].toUpperCase() + '-' + regionNumMatches[2]
}
targetUrl = '/search?q=' + encodeURIComponent(this.myQuery)
}
}
}
if (targetUrl) {
this.$refs.query.isActive = false
this.myQuery = ''
this.$router.push(targetUrl)
}
}
this.$emit('search')
},
iconForPlaceType (type) {
return PLACE_TYPE_ICONS[type] || 'map-marker-alt'
}

Wyświetl plik

@ -17,6 +17,7 @@ import RBNSpots from './views/RBNSpots.vue'
import Alerts from './views/Alerts.vue'
import NewPhotos from './views/NewPhotos.vue'
import SolarHistory from './views/SolarHistory.vue'
import SearchAnything from './views/SearchAnything.vue'
Vue.use(Router)
@ -167,6 +168,11 @@ let router = new Router({
path: '/solar_history',
component: SolarHistory
},
{
path: '/search',
component: SearchAnything,
meta: { savePath: null }
},
{
path: '*',
component: NotFound,

Wyświetl plik

@ -0,0 +1,227 @@
<template>
<PageLayout>
<template v-slot:title>Search results</template>
<template>
<section v-if="summits !== null && summits.length > 0" class="section">
<div class="container">
<h4 class="title is-4"><b-icon icon="mountains" />Summits</h4>
<b-field v-if="inactiveCount > 0" grouped>
<b-switch v-model="showInactive">Show inactive ({{ inactiveCount }})</b-switch>
</b-field>
<SummitList :data="filteredSummits" auto-width />
<b-message v-if="summits !== null && summits.length === this.limit" type="is-warning" has-icon>
More than {{ this.limit }} summits found, so not all summits may be shown. Please make your search input more specific.
</b-message>
</div>
</section>
<section v-if="activators !== null && activators.length > 0" class="section">
<div class="container">
<h4 class="title is-4"><b-icon icon="user" />Activators</h4>
<b-table class="auto-width" default-sort="callsign" :narrowed="true" :striped="true" :data="activators" :mobile-cards="false">
<template slot-scope="props">
<b-table-column field="callsign" label="Callsign" sortable>
<router-link :to="makeActivatorLink(props.row.callsign)">{{ props.row.callsign }}</router-link>
</b-table-column>
<b-table-column field="summits" label="Summits" numeric sortable>
{{ props.row.summits }}
</b-table-column>
<b-table-column field="points" :label="$mq.mobile ? 'Pts.' : 'Points'" numeric sortable>
{{ props.row.points }}
</b-table-column>
<b-table-column field="bonusPoints" :label="$mq.mobile ? 'Bonus' : 'Bonus points'" numeric sortable>
{{ props.row.bonusPoints }}
</b-table-column>
<b-table-column field="score" label="Score" numeric sortable>
{{ props.row.score }}
</b-table-column>
<b-table-column v-if="!$mq.mobile" field="avgPoints" label="Avg. points" numeric sortable>
{{ props.row.avgPoints }}
</b-table-column>
</template>
</b-table>
<b-message v-if="activators !== null && activators.length === this.limit" type="is-warning" has-icon>
More than {{ this.limit }} activators found, so not all activators may be shown. Please make your search input more specific.
</b-message>
</div>
</section>
<section v-if="places !== null && places.length > 0" class="section">
<div class="container">
<h4 class="title is-4"><b-icon icon="map-marker-alt" pack="fas" />Places</h4>
<b-table class="auto-width" :narrowed="true" :striped="true" :data="places" :mobile-cards="false">
<template slot-scope="props">
<b-table-column field="label" label="Name">
<a @click="goToPlace(props.row)">{{ props.row.label }}</a>
</b-table-column>
<b-table-column field="detail" label="Detail">
{{ props.row.detail }}
</b-table-column>
</template>
</b-table>
</div>
</section>
<section class="section">
<div class="container">
<b-message v-if="activators !== null && activators.length === 0 && summits !== null && summits.length === 0 && places.length === 0" type="is-info" has-icon>
No matching summits, activators, or places for '{{ $route.query.q }}' found.
</b-message>
</div>
</section>
</template>
</PageLayout>
</template>
<script>
import axios from 'axios'
import moment from 'moment'
import utils from '../mixins/utils.js'
import * as maptilersdk from '@maptiler/sdk'
import PageLayout from '../components/PageLayout.vue'
import SummitList from '../components/SummitList.vue'
export default {
name: 'SearchAnything',
components: { PageLayout, SummitList },
mixins: [utils],
methods: {
doSearch () {
let loads = []
let q = this.$route.query.q.trim()
this.loadingComponent = this.$buefy.loading.open({ canCancel: true })
loads.push(axios.get(process.env.VUE_APP_API_URL + '/activators/search', { params: { q, limit: this.limit } })
.then(response => {
this.activators = response.data.activators
}))
loads.push(axios.get(process.env.VUE_APP_API_URL + '/summits/search', { params: { q, limit: this.limit } })
.then(response => {
let now = moment()
response.data.forEach(summit => {
summit.isValid = (moment(summit.validFrom).isBefore(now) && moment(summit.validTo).isAfter(now))
})
this.summits = response.data
}))
// Places search (MapTiler geocoding)
let proximity = null
if (this.$store.state.mapCenter) {
proximity = [this.$store.state.mapCenter.longitude, this.$store.state.mapCenter.latitude]
}
let geoOpts = {
limit: 10,
language: 'en',
proximity
}
maptilersdk.config.apiKey = process.env.VUE_APP_MAPTILER_KEY
loads.push(
maptilersdk.geocoding.forward(q, geoOpts)
.then(geoResp => {
this.places = (geoResp.features || []).map(f => ({
label: f.text,
detail: f.place_name.replace(f.text + ', ', ''),
coordinates: f.geometry.coordinates
}))
})
.catch(() => { this.places = [] })
)
Promise.all(loads)
.then(() => {
this.loadingComponent.close()
if (this.activators.length === 1 && this.summits.length === 0 && this.places.length === 0) {
this.$router.replace('/activators/' + this.activators[0].callsign)
} else if (this.summits.length === 1 && this.activators.length === 0 && this.places.length === 0) {
this.$router.replace('/summits/' + this.summits[0].code)
}
})
},
goToPlace (place) {
if (place && place.coordinates) {
// MapTiler returns [lon, lat]
this.$router.push(`/map/coordinates/${place.coordinates[1]},${place.coordinates[0]}/14.0?popup=1`)
}
}
},
watch: {
'$route' (to, from) {
this.doSearch()
}
},
computed: {
filteredSummits () {
return this.summits.filter(summit => {
return (summit.isValid || this.showInactive)
})
},
inactiveCount () {
if (this.summits === null) {
return 0
}
let inactiveCount = 0
this.summits.forEach(summit => {
if (!summit.isValid) {
inactiveCount++
}
})
return inactiveCount
}
},
mounted () {
document.title = 'Search results - SOTLAS'
this.doSearch()
},
data () {
return {
activators: null,
limit: 100,
summits: null,
showInactive: false,
places: []
}
}
}
</script>
<style scoped>
.summits >>> .is-invalid {
opacity: 0.5;
}
@media (max-width: 768px) {
.points {
padding: 0.2em;
min-width: 2em;
}
}
@media (max-width: 414px) {
.table .summit-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 10em;
}
}
@media (max-width: 414px) {
.table td, .table th {
padding: 0.25em 0.3em;
}
.table .summit-name {
max-width: 8em;
}
}
.message.is-warning {
margin-top: 1rem;
}
.title.is-4 {
margin-bottom: 1rem;
}
</style>