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

@@ -36,6 +36,31 @@ type DataToolsBundle = {
scopes: DataToolScope[];
data: Partial<Record<DataToolScope, DataToolScopeData>>;
};
type GlobalSearchGroupType =
| 'pokemon'
| 'habitats'
| 'items'
| 'ancient-artifacts'
| 'recipes'
| 'daily-checklist'
| 'life';
type GlobalSearchItem = {
id: number;
type: GlobalSearchGroupType;
title: string;
url: string;
summary: string | null;
meta: string | null;
image: EntityImageValue | PokemonImage | null;
};
type GlobalSearchGroup = {
type: GlobalSearchGroupType;
items: GlobalSearchItem[];
};
type GlobalSearchResults = {
query: string;
groups: GlobalSearchGroup[];
};
type TranslationField = 'name' | 'title' | 'details' | 'genus';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
@@ -2411,6 +2436,179 @@ export async function listDailyChecklistItems(locale = defaultLocale) {
);
}
export async function globalSearch(paramsQuery: QueryParams = {}, locale = defaultLocale): Promise<GlobalSearchResults> {
const search = asString(paramsQuery.query)?.trim() ?? asString(paramsQuery.search)?.trim() ?? '';
if (!search) {
return { query: '', groups: [] };
}
const pattern = `%${search}%`;
const limit = 5;
const pokemonName = localizedName('pokemon', 'p', locale);
const habitatName = localizedName('habitats', 'h', locale);
const itemName = localizedName('items', 'i', locale);
const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
const artifactName = localizedName('ancient-artifacts', 'a', locale);
const artifactCategoryName = systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale);
const recipeItemName = localizedName('items', 'result_item', locale);
const recipeMaterialName = localizedName('items', 'material_item', locale);
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
const lifeCategoryName = localizedName('life-tags', 'lc', locale);
const [pokemon, habitats, items, artifacts, recipes, checklist, life] = await Promise.all([
query<GlobalSearchItem>(
`
SELECT
p.id,
'pokemon' AS type,
${pokemonName} AS title,
'/pokemon/' || p.id AS url,
NULLIF(p.genus, '') AS summary,
'#' || p.display_id::text AS meta,
${pokemonImageJson('p')} AS image
FROM pokemon p
WHERE ${pokemonName} ILIKE $1
ORDER BY ${orderByEntity('p')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
h.id,
'habitats' AS type,
${habitatName} AS title,
'/habitats/' || h.id AS url,
NULL AS summary,
NULL AS meta,
${uploadedImageJson('h.image_path')} AS image
FROM habitats h
WHERE ${habitatName} ILIKE $1
ORDER BY ${orderByEntity('h')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
i.id,
'items' AS type,
${itemName} AS title,
'/items/' || i.id AS url,
NULLIF(i.details, '') AS summary,
(${itemCategoryName}->>'name') AS meta,
${uploadedImageJson('i.image_path')} AS image
FROM items i
WHERE ${itemName} ILIKE $1
ORDER BY i.display_id, ${orderByEntity('i')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
a.id,
'ancient-artifacts' AS type,
${artifactName} AS title,
'/ancient-artifacts/' || a.id AS url,
NULLIF(a.details, '') AS summary,
(${artifactCategoryName}->>'name') AS meta,
${uploadedImageJson('a.image_path')} AS image
FROM ancient_artifacts a
WHERE ${artifactName} ILIKE $1
ORDER BY a.display_id, ${orderByEntity('a')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
r.id,
'recipes' AS type,
${recipeItemName} AS title,
'/recipes/' || r.id AS url,
(
SELECT string_agg(material_rows.name, ' / ' ORDER BY material_rows.name)
FROM (
SELECT DISTINCT ${recipeMaterialName} AS name
FROM recipe_materials rm
JOIN items material_item ON material_item.id = rm.item_id
WHERE rm.recipe_id = r.id
) material_rows
) AS summary,
NULL AS meta,
${uploadedImageJson('result_item.image_path')} AS image
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
WHERE ${recipeItemName} ILIKE $1
OR EXISTS (
SELECT 1
FROM recipe_materials rm
JOIN items material_item ON material_item.id = rm.item_id
WHERE rm.recipe_id = r.id
AND ${recipeMaterialName} ILIKE $1
)
ORDER BY ${orderByEntity('r')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
c.id,
'daily-checklist' AS type,
${checklistTitle} AS title,
'/checklist' AS url,
NULL AS summary,
NULL AS meta,
NULL AS image
FROM daily_checklist_items c
WHERE ${checklistTitle} ILIKE $1
ORDER BY ${orderByEntity('c')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
lp.id,
'life' AS type,
LEFT(lp.body, 120) AS title,
'/life/' || lp.id AS url,
NULL AS summary,
${lifeCategoryName} AS meta,
NULL AS image
FROM life_posts lp
LEFT JOIN life_tags lc ON lc.id = lp.category_id
WHERE lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
AND lp.body ILIKE $1
ORDER BY lp.created_at DESC, lp.id DESC
LIMIT $2
`,
[pattern, limit]
)
]);
const groups: GlobalSearchGroup[] = [
{ type: 'pokemon', items: pokemon },
{ type: 'habitats', items: habitats },
{ type: 'items', items: items },
{ type: 'ancient-artifacts', items: artifacts },
{ type: 'recipes', items: recipes },
{ type: 'daily-checklist', items: checklist },
{ type: 'life', items: life }
];
return { query: search, groups: groups.filter((group) => group.items.length > 0) };
}
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
return queryOne(

View File

@@ -73,6 +73,7 @@ import {
getPokemon,
getPublicUserProfile,
getRecipe,
globalSearch,
importAdminData,
isConfigType,
listAncientArtifacts,
@@ -219,6 +220,10 @@ app.setErrorHandler(async (error, _request, reply) => {
app.get('/health', async () => ({ ok: true }));
app.get('/api/search', async (request) =>
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
function getBearerToken(authorization: string | undefined): string | null {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;