Add users group to global search API and frontend types Query users by display name and link to their public profiles Update system wordings for the new search group
282 lines
7.4 KiB
Vue
282 lines
7.4 KiB
Vue
<script setup lang="ts">
|
|
import { Icon } from '@iconify/vue';
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRouter } from 'vue-router';
|
|
import { iconClose, iconSearch } from '../icons';
|
|
import { api, type GlobalSearchGroup, type GlobalSearchGroupType, type GlobalSearchItem } from '../services/api';
|
|
|
|
const emit = defineEmits<{
|
|
navigate: [];
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const root = ref<HTMLElement | null>(null);
|
|
const input = ref<HTMLInputElement | null>(null);
|
|
const query = ref('');
|
|
const groups = ref<GlobalSearchGroup[]>([]);
|
|
const open = ref(false);
|
|
const mobileOpen = ref(false);
|
|
const loading = ref(false);
|
|
const failed = ref(false);
|
|
let searchTimeout: number | null = null;
|
|
let abortController: AbortController | null = null;
|
|
let requestId = 0;
|
|
|
|
const cleanQuery = computed(() => query.value.trim());
|
|
const hasResults = computed(() => groups.value.some((group) => group.items.length > 0));
|
|
const firstResult = computed(() => groups.value.find((group) => group.items.length > 0)?.items[0] ?? null);
|
|
const panelVisible = computed(() => open.value && cleanQuery.value !== '' && (loading.value || failed.value || groups.value.length > 0));
|
|
|
|
const groupLabels: Record<GlobalSearchGroupType, string> = {
|
|
pokemon: 'search.groups.pokemon',
|
|
habitats: 'search.groups.habitats',
|
|
items: 'search.groups.items',
|
|
'ancient-artifacts': 'search.groups.ancientArtifacts',
|
|
recipes: 'search.groups.recipes',
|
|
'daily-checklist': 'search.groups.dailyChecklist',
|
|
life: 'search.groups.life',
|
|
users: 'search.groups.users'
|
|
};
|
|
|
|
function clearSearchTimeout() {
|
|
if (searchTimeout !== null) {
|
|
window.clearTimeout(searchTimeout);
|
|
searchTimeout = null;
|
|
}
|
|
}
|
|
|
|
function abortSearch() {
|
|
abortController?.abort();
|
|
abortController = null;
|
|
}
|
|
|
|
function resetResults() {
|
|
groups.value = [];
|
|
failed.value = false;
|
|
loading.value = false;
|
|
}
|
|
|
|
async function runSearch(value: string) {
|
|
const currentRequestId = ++requestId;
|
|
abortSearch();
|
|
const controller = new AbortController();
|
|
abortController = controller;
|
|
loading.value = true;
|
|
failed.value = false;
|
|
|
|
try {
|
|
const response = await api.globalSearch(value, controller.signal);
|
|
if (currentRequestId === requestId) {
|
|
groups.value = response.groups;
|
|
}
|
|
} catch (error) {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
if (currentRequestId === requestId) {
|
|
groups.value = [];
|
|
failed.value = true;
|
|
}
|
|
} finally {
|
|
if (currentRequestId === requestId) {
|
|
loading.value = false;
|
|
if (abortController === controller) {
|
|
abortController = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function scheduleSearch() {
|
|
clearSearchTimeout();
|
|
const value = cleanQuery.value;
|
|
if (!value) {
|
|
requestId += 1;
|
|
abortSearch();
|
|
resetResults();
|
|
return;
|
|
}
|
|
|
|
requestId += 1;
|
|
abortSearch();
|
|
loading.value = true;
|
|
failed.value = false;
|
|
searchTimeout = window.setTimeout(() => {
|
|
searchTimeout = null;
|
|
void runSearch(value);
|
|
}, 240);
|
|
}
|
|
|
|
function openPanel() {
|
|
open.value = true;
|
|
}
|
|
|
|
function closePanel() {
|
|
open.value = false;
|
|
}
|
|
|
|
function toggleMobileSearch() {
|
|
mobileOpen.value = !mobileOpen.value;
|
|
openPanel();
|
|
if (mobileOpen.value) {
|
|
void nextTick(() => input.value?.focus());
|
|
}
|
|
}
|
|
|
|
function clearQuery() {
|
|
query.value = '';
|
|
resetResults();
|
|
openPanel();
|
|
void nextTick(() => input.value?.focus());
|
|
}
|
|
|
|
function onSubmit() {
|
|
const item = firstResult.value;
|
|
if (!item) {
|
|
openPanel();
|
|
return;
|
|
}
|
|
|
|
void navigateTo(item);
|
|
}
|
|
|
|
async function navigateTo(item: GlobalSearchItem) {
|
|
selectResult();
|
|
await router.push(item.url);
|
|
}
|
|
|
|
function selectResult() {
|
|
closePanel();
|
|
mobileOpen.value = false;
|
|
emit('navigate');
|
|
}
|
|
|
|
function onRootKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
closePanel();
|
|
input.value?.blur();
|
|
}
|
|
}
|
|
|
|
function onDocumentPointerDown(event: PointerEvent) {
|
|
if (root.value && !root.value.contains(event.target as Node)) {
|
|
closePanel();
|
|
}
|
|
}
|
|
|
|
function groupLabel(type: GlobalSearchGroupType) {
|
|
return t(groupLabels[type]);
|
|
}
|
|
|
|
watch(query, scheduleSearch);
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('pointerdown', onDocumentPointerDown);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
clearSearchTimeout();
|
|
abortSearch();
|
|
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="root"
|
|
class="global-search"
|
|
:class="{ 'global-search--mobile-open': mobileOpen }"
|
|
@keydown="onRootKeydown"
|
|
>
|
|
<button
|
|
class="global-search__toggle"
|
|
type="button"
|
|
:aria-label="t('search.open')"
|
|
:aria-expanded="mobileOpen"
|
|
@click="toggleMobileSearch"
|
|
>
|
|
<Icon :icon="mobileOpen ? iconClose : iconSearch" class="ui-icon" aria-hidden="true" />
|
|
</button>
|
|
|
|
<form class="global-search__form" role="search" @submit.prevent="onSubmit">
|
|
<Icon :icon="iconSearch" class="ui-icon global-search__form-icon" aria-hidden="true" />
|
|
<input
|
|
ref="input"
|
|
v-model="query"
|
|
class="global-search__input"
|
|
type="search"
|
|
:placeholder="t('search.placeholder')"
|
|
:aria-label="t('search.label')"
|
|
:aria-controls="panelVisible ? 'global-search-results' : undefined"
|
|
:aria-expanded="panelVisible"
|
|
autocomplete="off"
|
|
@focus="openPanel"
|
|
/>
|
|
<button
|
|
v-if="cleanQuery"
|
|
class="global-search__clear"
|
|
type="button"
|
|
:aria-label="t('search.clear')"
|
|
@click="clearQuery"
|
|
>
|
|
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
|
|
</button>
|
|
</form>
|
|
|
|
<div
|
|
v-if="panelVisible"
|
|
id="global-search-results"
|
|
class="global-search__panel"
|
|
:aria-busy="loading"
|
|
>
|
|
<div v-if="loading" class="global-search__skeleton" aria-hidden="true">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</div>
|
|
|
|
<p v-else-if="failed" class="global-search__message">{{ t('search.failed') }}</p>
|
|
<p v-else-if="!hasResults" class="global-search__message">{{ t('search.empty') }}</p>
|
|
|
|
<template v-else>
|
|
<section
|
|
v-for="group in groups"
|
|
:key="group.type"
|
|
class="global-search__group"
|
|
:aria-label="groupLabel(group.type)"
|
|
>
|
|
<h2 class="global-search__group-title">{{ groupLabel(group.type) }}</h2>
|
|
<RouterLink
|
|
v-for="item in group.items"
|
|
:key="`${group.type}-${item.id}`"
|
|
class="global-search__result"
|
|
:to="item.url"
|
|
@click="selectResult"
|
|
>
|
|
<img
|
|
v-if="item.image"
|
|
class="global-search__result-image"
|
|
:src="item.image.url"
|
|
:alt="item.title"
|
|
loading="lazy"
|
|
/>
|
|
<span v-else class="global-search__result-mark" aria-hidden="true">
|
|
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
|
</span>
|
|
<span class="global-search__result-copy">
|
|
<span class="global-search__result-title">{{ item.title }}</span>
|
|
<span v-if="item.summary || item.meta" class="global-search__result-meta">
|
|
<span v-if="item.meta">{{ item.meta }}</span>
|
|
<span v-if="item.summary">{{ item.summary }}</span>
|
|
</span>
|
|
</span>
|
|
</RouterLink>
|
|
</section>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|