From 5a4641320ad1a27fe48a97f40bc705c87718cb77 Mon Sep 17 00:00:00 2001 From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:57:21 +0300 Subject: [PATCH] feat(web): add global search UI --- apps/web/src/app/search/page.tsx | 15 ++ .../src/app/search/search-results-client.tsx | 185 ++++++++++++++++++ .../components/layout/global-search-form.tsx | 49 +++++ .../components/layout/mobile-navigation.tsx | 8 +- .../web/src/components/layout/site-header.tsx | 4 +- apps/web/src/lib/api/search.ts | 43 ++++ 6 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/app/search/page.tsx create mode 100644 apps/web/src/app/search/search-results-client.tsx create mode 100644 apps/web/src/components/layout/global-search-form.tsx create mode 100644 apps/web/src/lib/api/search.ts diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/search/page.tsx new file mode 100644 index 0000000..06d9864 --- /dev/null +++ b/apps/web/src/app/search/page.tsx @@ -0,0 +1,15 @@ +import { SearchResultsClient } from '@/app/search/search-results-client'; + +type SearchPageProps = { + searchParams?: Promise<{ + q?: string | string[]; + }>; +}; + +export default async function SearchPage({ searchParams }: SearchPageProps) { + const params = await searchParams; + const queryParam = params?.q; + const initialQuery = Array.isArray(queryParam) ? (queryParam[0] ?? '') : (queryParam ?? ''); + + return ; +} diff --git a/apps/web/src/app/search/search-results-client.tsx b/apps/web/src/app/search/search-results-client.tsx new file mode 100644 index 0000000..80a2b60 --- /dev/null +++ b/apps/web/src/app/search/search-results-client.tsx @@ -0,0 +1,185 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; + +import { GlobalSearchForm } from '@/components/layout/global-search-form'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ApiError } from '@/lib/api/errors'; +import { searchWorldLore, type SearchResult } from '@/lib/api/search'; + +function getErrorMessage(error: unknown) { + if (error instanceof ApiError) { + return error.body?.error ?? 'Unable to search HiveLore.'; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Unable to search HiveLore.'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getStringMetadata(result: SearchResult, key: string) { + if (!isRecord(result.metadata)) { + return null; + } + + const value = result.metadata[key]; + return typeof value === 'string' && value ? value : null; +} + +function getResultHref(result: SearchResult) { + if (result.entityType === 'WORLD') { + return `/worlds/${result.entityId}`; + } + + if (result.worldId) { + return `/worlds/${result.worldId}/lore/${result.entityId}`; + } + + return null; +} + +function getResultTypeLabel(result: SearchResult) { + if (result.entityType === 'WORLD') { + return 'World'; + } + + return getStringMetadata(result, 'loreType')?.toLowerCase().replaceAll('_', ' ') ?? 'Lore'; +} + +type SearchResultsClientProps = { + initialQuery: string; +}; + +export function SearchResultsClient({ initialQuery }: SearchResultsClientProps) { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [results, setResults] = useState([]); + const normalizedQuery = useMemo(() => initialQuery.trim(), [initialQuery]); + + useEffect(() => { + if (!normalizedQuery) { + setResults([]); + setError(null); + setIsLoading(false); + return; + } + + let isMounted = true; + + setIsLoading(true); + setError(null); + + searchWorldLore({ q: normalizedQuery }) + .then((response) => { + if (isMounted) { + setResults(response.results); + } + }) + .catch((nextError) => { + if (isMounted) { + setError(getErrorMessage(nextError)); + setResults([]); + } + }) + .finally(() => { + if (isMounted) { + setIsLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, [normalizedQuery]); + + return ( +
+
+
+

+ Global search +

+

+ Search worlds and published lore. +

+
+ +
+ + {error ? ( + + Search failed + {error} + + ) : null} + +
+
+

+ Results +

+

+ {isLoading + ? 'Searching...' + : normalizedQuery + ? `${results.length} result${results.length === 1 ? '' : 's'} for "${normalizedQuery}".` + : 'Enter a search term to find matching worlds and canon lore.'} +

+
+ + {!isLoading && normalizedQuery && results.length === 0 && !error ? ( + + +

+ No matching worlds or published lore entries found. +

+
+
+ ) : null} + +
+ {results.map((result) => { + const href = getResultHref(result); + const content = ( + + +
+ {getResultTypeLabel(result)} + {result.entityType === 'LORE_ENTRY' ? ( + Canon + ) : null} +
+ {result.title} + + {result.entityType === 'WORLD' ? 'World match' : 'Published lore entry match'} + +
+
+ ); + + return href ? ( + + {content} + + ) : ( +
{content}
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/src/components/layout/global-search-form.tsx b/apps/web/src/components/layout/global-search-form.tsx new file mode 100644 index 0000000..f12d8df --- /dev/null +++ b/apps/web/src/components/layout/global-search-form.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, type FormEvent } from 'react'; + +import { SearchInput } from '@/components/ui/search-input'; + +type GlobalSearchFormProps = { + className?: string; + initialQuery?: string; + inputClassName?: string; + onSearch?: () => void; +}; + +export function GlobalSearchForm({ + className, + initialQuery = '', + inputClassName, + onSearch, +}: GlobalSearchFormProps) { + const router = useRouter(); + const [query, setQuery] = useState(initialQuery); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const nextQuery = query.trim(); + + if (!nextQuery) { + return; + } + + onSearch?.(); + router.push(`/search?q=${encodeURIComponent(nextQuery)}`); + } + + return ( +
+ setQuery(event.target.value)} + placeholder="Search worlds and lore" + value={query} + /> + + ); +} diff --git a/apps/web/src/components/layout/mobile-navigation.tsx b/apps/web/src/components/layout/mobile-navigation.tsx index 4a5f499..bb90af0 100644 --- a/apps/web/src/components/layout/mobile-navigation.tsx +++ b/apps/web/src/components/layout/mobile-navigation.tsx @@ -4,8 +4,8 @@ import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; import { ThemeSwitcher } from '@/components/theme-switcher'; import { Button } from '@/components/ui/button'; +import { GlobalSearchForm } from '@/components/layout/global-search-form'; import { navigationLinks } from '@/components/layout/navigation-links'; -import { SearchInput } from '@/components/ui/search-input'; export function MobileNavigation() { const [isOpen, setIsOpen] = useState(false); @@ -103,11 +103,7 @@ export function MobileNavigation() {
- + setIsOpen(false)} />
- +
diff --git a/apps/web/src/lib/api/search.ts b/apps/web/src/lib/api/search.ts new file mode 100644 index 0000000..9402c17 --- /dev/null +++ b/apps/web/src/lib/api/search.ts @@ -0,0 +1,43 @@ +import { apiClient } from '@/lib/api/client'; + +export type SearchEntityType = 'WORLD' | 'LORE_ENTRY'; + +export type SearchResult = { + entityId: string; + entityType: SearchEntityType; + metadata: unknown; + rank: number; + title: string; + worldId: string | null; +}; + +export function searchWorldLore(params: { + page?: number; + pageSize?: number; + q: string; + type?: SearchEntityType; + worldId?: string; +}) { + const searchParams = new URLSearchParams({ + q: params.q, + page: String(params.page ?? 1), + pageSize: String(params.pageSize ?? 20), + }); + + if (params.type) { + searchParams.set('type', params.type); + } + + if (params.worldId) { + searchParams.set('worldId', params.worldId); + } + + return apiClient.get<{ + pageInfo: { + hasMore: boolean; + page: number; + pageSize: number; + }; + results: SearchResult[]; + }>(`/search?${searchParams.toString()}`); +}