refaktor címjegyzék
This commit is contained in:
323
frontend_admin/components/SmartLocationInput.vue
Normal file
323
frontend_admin/components/SmartLocationInput.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<label v-if="label" class="block text-sm font-medium text-slate-300 mb-1">{{ label }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="inputRef"
|
||||
:value="displayText"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown.down.prevent="highlightNext"
|
||||
@keydown.up.prevent="highlightPrev"
|
||||
@keydown.enter.prevent="selectHighlighted"
|
||||
@keydown.escape="closeDropdown"
|
||||
:placeholder="placeholder"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
|
||||
/>
|
||||
<!-- Loading spinner -->
|
||||
<svg
|
||||
v-if="searching"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-slate-400"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
v-if="selectedItem && !searching"
|
||||
@click="clearSelection"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition"
|
||||
type="button"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
v-if="showDropdown && filteredItems.length > 0"
|
||||
class="absolute z-50 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl max-h-60 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.id"
|
||||
@mousedown.prevent="selectItem(item)"
|
||||
class="w-full text-left px-3 py-2 text-sm transition flex items-center gap-2"
|
||||
:class="index === highlightedIndex
|
||||
? 'bg-indigo-500/20 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-700'"
|
||||
>
|
||||
<span class="font-mono text-indigo-400">{{ item.zip_code }}</span>
|
||||
<span>{{ item.city }}</span>
|
||||
<span class="text-xs text-slate-500 ml-auto">{{ item.country_code }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- No results -->
|
||||
<div
|
||||
v-if="showDropdown && query.length >= minChars && filteredItems.length === 0 && !searching"
|
||||
class="absolute z-50 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl p-3 text-sm text-slate-400"
|
||||
>
|
||||
Nincs találat
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SmartLocationInput.vue
|
||||
* ======================
|
||||
*
|
||||
* Autocomplete/combobox component for selecting a city/zip code pair.
|
||||
* Calls the backend GET /api/v1/locations/autocomplete endpoint.
|
||||
*
|
||||
* Usage:
|
||||
* <SmartLocationInput v-model="selected" />
|
||||
*
|
||||
* The v-model emits the selected GeoPostalCode object:
|
||||
* { id: number, zip_code: string, city: string, country_code: string }
|
||||
*
|
||||
* Props:
|
||||
* modelValue: The currently selected location (null or the object)
|
||||
* label: Optional label text above the input
|
||||
* placeholder: Placeholder text
|
||||
* minChars: Minimum characters before triggering search (default: 2)
|
||||
* countryCode: Filter by country code (default: 'HU')
|
||||
* debounceMs: Debounce delay in ms (default: 300)
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
|
||||
interface LocationItem {
|
||||
id: number
|
||||
zip_code: string
|
||||
city: string
|
||||
country_code: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: LocationItem | null
|
||||
label?: string
|
||||
placeholder?: string
|
||||
minChars?: number
|
||||
countryCode?: string
|
||||
debounceMs?: number
|
||||
}>(), {
|
||||
modelValue: null,
|
||||
label: '',
|
||||
placeholder: 'Kezdj el gépelni (irányítószám vagy város)...',
|
||||
minChars: 2,
|
||||
countryCode: 'HU',
|
||||
debounceMs: 300,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: LocationItem | null]
|
||||
'select': [value: LocationItem]
|
||||
}>()
|
||||
|
||||
// ── State ──────────────────────────────────────────────
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const query = ref('')
|
||||
const selectedItem = ref<LocationItem | null>(props.modelValue)
|
||||
const items = ref<LocationItem[]>([])
|
||||
const searching = ref(false)
|
||||
const showDropdown = ref(false)
|
||||
const highlightedIndex = ref(0)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Computed ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The display text shown in the input.
|
||||
* If a location is selected, shows "zip_code city".
|
||||
* Otherwise shows the raw query text.
|
||||
*/
|
||||
const displayText = computed(() => {
|
||||
if (selectedItem.value) {
|
||||
return `${selectedItem.value.zip_code} ${selectedItem.value.city}`
|
||||
}
|
||||
return query.value
|
||||
})
|
||||
|
||||
/**
|
||||
* Filtered items based on the current query.
|
||||
* The backend already filters, but we keep client-side filtering
|
||||
* for instant feedback when the user types more after results load.
|
||||
*/
|
||||
const filteredItems = computed(() => {
|
||||
if (!query.value || query.value.length < props.minChars) return []
|
||||
const q = query.value.toLowerCase()
|
||||
return items.value.filter(
|
||||
(item) =>
|
||||
item.zip_code.toLowerCase().includes(q) ||
|
||||
item.city.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
// ── Watch modelValue from parent ───────────────────────
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
selectedItem.value = val
|
||||
}
|
||||
)
|
||||
|
||||
// ── Methods ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle input events from the text field.
|
||||
* Starts debounced search when the user types.
|
||||
*/
|
||||
function onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
query.value = target.value
|
||||
|
||||
// If the user is typing, clear the selected item
|
||||
if (selectedItem.value) {
|
||||
selectedItem.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
// Debounced search
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (query.value.length >= props.minChars) {
|
||||
debounceTimer = setTimeout(() => {
|
||||
searchLocations(query.value)
|
||||
}, props.debounceMs)
|
||||
} else {
|
||||
items.value = []
|
||||
showDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the backend autocomplete API.
|
||||
*/
|
||||
async function searchLocations(searchQuery: string) {
|
||||
if (!searchQuery || searchQuery.length < props.minChars) return
|
||||
searching.value = true
|
||||
try {
|
||||
const res = await $fetch('/api/v1/locations/autocomplete', {
|
||||
params: {
|
||||
q: searchQuery,
|
||||
country_code: props.countryCode,
|
||||
limit: 15,
|
||||
},
|
||||
headers: getHeaders(),
|
||||
})
|
||||
items.value = (res as LocationItem[]) || []
|
||||
highlightedIndex.value = 0
|
||||
showDropdown.value = items.value.length > 0
|
||||
} catch (err: any) {
|
||||
console.error('Location autocomplete error:', err)
|
||||
items.value = []
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers for API calls.
|
||||
*/
|
||||
function getHeaders(): Record<string, string> {
|
||||
const token = useCookie('access_token').value
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an item from the dropdown.
|
||||
*/
|
||||
function selectItem(item: LocationItem) {
|
||||
selectedItem.value = item
|
||||
query.value = ''
|
||||
showDropdown.value = false
|
||||
emit('update:modelValue', item)
|
||||
emit('select', item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection.
|
||||
*/
|
||||
function clearSelection() {
|
||||
selectedItem.value = null
|
||||
query.value = ''
|
||||
items.value = []
|
||||
showDropdown.value = false
|
||||
emit('update:modelValue', null)
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dropdown.
|
||||
*/
|
||||
function closeDropdown() {
|
||||
showDropdown.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle focus - show dropdown if there are results.
|
||||
*/
|
||||
function onFocus() {
|
||||
if (filteredItems.value.length > 0) {
|
||||
showDropdown.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle blur - close dropdown with a delay to allow click.
|
||||
*/
|
||||
function onBlur() {
|
||||
// Delay to allow mousedown on dropdown items
|
||||
setTimeout(() => {
|
||||
showDropdown.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the next item in the dropdown.
|
||||
*/
|
||||
function highlightNext() {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % filteredItems.value.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the previous item in the dropdown.
|
||||
*/
|
||||
function highlightPrev() {
|
||||
if (filteredItems.value.length > 0) {
|
||||
highlightedIndex.value = (highlightedIndex.value - 1 + filteredItems.value.length) % filteredItems.value.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the currently highlighted item.
|
||||
*/
|
||||
function selectHighlighted() {
|
||||
if (filteredItems.value.length > 0 && highlightedIndex.value >= 0) {
|
||||
selectItem(filteredItems.value[highlightedIndex.value])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(100, 116, 139, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user