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:
@@ -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">
|
||||
|
||||
280
frontend/src/components/GlobalSearch.vue
Normal file
280
frontend/src/components/GlobalSearch.vue
Normal 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>
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user