feat(search): add global search across wiki entities

Implement /api/search endpoint for cross-entity querying
Add GlobalSearch component to top navigation bar with categorized results
This commit is contained in:
2026-05-04 14:20:12 +08:00
parent 3dd3998a5c
commit 06e0cbb1c1
8 changed files with 784 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import {
type AppIcon
} from '../icons';
import type { AuthUser, Language } from '../services/api';
import GlobalSearch from './GlobalSearch.vue';
import NotificationBell from './NotificationBell.vue';
import PokeBallMark from './PokeBallMark.vue';
import StatusBadge from './StatusBadge.vue';
@@ -271,6 +272,8 @@ onBeforeUnmount(() => {
</RouterLink>
</div>
<GlobalSearch class="site-topbar__search" @navigate="closeSidebar" />
<div class="site-topbar__spacer" aria-hidden="true"></div>
<div class="topbar-actions">

View File

@@ -0,0 +1,280 @@
<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'
};
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>

View File

@@ -318,6 +318,35 @@ export interface DailyChecklistItem {
translations?: TranslationMap;
}
export type GlobalSearchGroupType =
| 'pokemon'
| 'habitats'
| 'items'
| 'ancient-artifacts'
| 'recipes'
| 'daily-checklist'
| 'life';
export interface GlobalSearchItem {
id: number;
type: GlobalSearchGroupType;
title: string;
url: string;
summary: string | null;
meta: string | null;
image: EntityImage | PokemonImage | null;
}
export interface GlobalSearchGroup {
type: GlobalSearchGroupType;
items: GlobalSearchItem[];
}
export interface GlobalSearchResults {
query: string;
groups: GlobalSearchGroup[];
}
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
export interface DataToolScopeSummary {
@@ -1033,6 +1062,8 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
}
export const api = {
globalSearch: (query: string, signal?: AbortSignal) =>
getJson<GlobalSearchResults>(`/api/search${buildQuery({ query: query.trim() })}`, signal),
languages: () => getJson<Language[]>('/api/languages'),
projectUpdates: (params: ProjectUpdatesParams = {}) =>
getJson<ProjectUpdates>(

View File

@@ -159,6 +159,190 @@ svg {
flex: 1 1 auto;
}
.site-topbar__search {
flex: 0 1 520px;
}
.global-search {
position: relative;
min-width: 220px;
}
.global-search__toggle {
display: none;
}
.global-search__form {
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
box-shadow: 0 3px 0 var(--line-strong);
padding: 0 10px;
transition:
border-color 0.14s ease,
box-shadow 0.14s ease;
}
.global-search__form:focus-within {
border-color: var(--pokemon-blue);
box-shadow: 0 3px 0 var(--pokemon-blue-deep);
}
.global-search__form-icon {
width: 20px;
height: 20px;
color: var(--muted);
}
.global-search__input {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
font-size: 0.94rem;
font-weight: 700;
}
.global-search__input::placeholder {
color: var(--muted);
opacity: 1;
}
.global-search__clear {
width: 30px;
min-width: 30px;
min-height: 30px;
display: inline-grid;
place-items: center;
border-radius: var(--radius-small);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.global-search__clear:hover {
background: var(--surface-soft);
color: var(--ink-soft);
}
.global-search__panel {
position: absolute;
inset: calc(100% + 8px) 0 auto 0;
z-index: 80;
max-height: min(70dvh, 620px);
overflow: auto;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-raised);
padding: 10px;
}
.global-search__group + .global-search__group {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.global-search__group-title {
margin: 0 0 6px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.global-search__result {
min-height: 58px;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 8px;
border-radius: var(--radius-control);
color: var(--ink);
}
.global-search__result:hover {
background: var(--surface-soft);
}
.global-search__result-image,
.global-search__result-mark {
width: 40px;
height: 40px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
}
.global-search__result-image {
object-fit: contain;
}
.global-search__result-mark {
display: inline-grid;
place-items: center;
color: var(--muted);
}
.global-search__result-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.global-search__result-title,
.global-search__result-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.global-search__result-title {
color: var(--ink);
font-size: 0.94rem;
font-weight: 900;
}
.global-search__result-meta {
display: flex;
gap: 8px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
.global-search__message {
margin: 0;
padding: 14px 10px;
color: var(--muted);
font-size: 0.9rem;
font-weight: 800;
text-align: center;
}
.global-search__skeleton {
display: grid;
gap: 8px;
}
.global-search__skeleton span {
height: 48px;
border-radius: var(--radius-control);
background: linear-gradient(90deg, var(--surface-soft), var(--line), var(--surface-soft));
background-size: 220% 100%;
animation: shimmer 1.4s linear infinite;
}
.topbar-actions {
min-width: 0;
display: flex;
@@ -6864,6 +7048,53 @@ button:disabled,
display: none;
}
.site-topbar__search {
flex: 0 0 auto;
min-width: 0;
}
.global-search {
position: static;
min-width: 0;
}
.global-search__toggle {
width: 44px;
min-width: 44px;
min-height: 44px;
display: inline-grid;
place-items: center;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink-soft);
cursor: pointer;
}
.global-search__toggle:hover {
border-color: var(--pokemon-blue);
color: var(--pokemon-blue-deep);
}
.global-search__form {
display: none;
}
.global-search--mobile-open .global-search__form {
position: fixed;
top: 68px;
right: 12px;
left: 12px;
z-index: 80;
display: flex;
}
.global-search__panel {
position: fixed;
inset: 122px 12px auto 12px;
max-height: calc(100dvh - 138px);
}
.topbar-actions {
flex: 0 0 auto;
gap: 6px;