Files
pokopiawiki.tootaio.com/frontend/src/components/GlobalSearch.vue
xiaomai 504849c14a feat(search): include user profiles in global search results
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
2026-05-04 16:04:58 +08:00

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>