Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/web/src/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <SearchResultsClient initialQuery={initialQuery} />;
}
185 changes: 185 additions & 0 deletions apps/web/src/app/search/search-results-client.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [results, setResults] = useState<SearchResult[]>([]);
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 (
<div className="space-y-8">
<section className="space-y-5">
<div>
<p className="text-sm font-medium uppercase tracking-[0.18em] text-muted-foreground">
Global search
</p>
<h1 className="mt-3 max-w-3xl text-4xl font-semibold tracking-normal text-foreground">
Search worlds and published lore.
</h1>
</div>
<GlobalSearchForm className="max-w-2xl" initialQuery={initialQuery} inputClassName="h-12" />
</section>

{error ? (
<Alert variant="danger">
<AlertTitle>Search failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}

<section aria-labelledby="search-results-heading" className="space-y-4">
<div>
<h2 id="search-results-heading" className="text-2xl font-semibold tracking-normal">
Results
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{isLoading
? 'Searching...'
: normalizedQuery
? `${results.length} result${results.length === 1 ? '' : 's'} for "${normalizedQuery}".`
: 'Enter a search term to find matching worlds and canon lore.'}
</p>
</div>

{!isLoading && normalizedQuery && results.length === 0 && !error ? (
<Card>
<CardContent>
<p className="text-sm leading-6 text-muted-foreground">
No matching worlds or published lore entries found.
</p>
</CardContent>
</Card>
) : null}

<div className="grid gap-4">
{results.map((result) => {
const href = getResultHref(result);
const content = (
<Card className="h-full transition-colors hover:border-[var(--hive-red)]">
<CardHeader>
<div className="flex flex-wrap gap-2">
<Badge>{getResultTypeLabel(result)}</Badge>
{result.entityType === 'LORE_ENTRY' ? (
<Badge variant="canon">Canon</Badge>
) : null}
</div>
<CardTitle className="text-xl">{result.title}</CardTitle>
<CardDescription>
{result.entityType === 'WORLD' ? 'World match' : 'Published lore entry match'}
</CardDescription>
</CardHeader>
</Card>
);

return href ? (
<Link
className="rounded-panel focus-visible:outline focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"
href={href}
key={`${result.entityType}-${result.entityId}`}
>
{content}
</Link>
) : (
<div key={`${result.entityType}-${result.entityId}`}>{content}</div>
);
})}
</div>
</section>
</div>
);
}
49 changes: 49 additions & 0 deletions apps/web/src/components/layout/global-search-form.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) {
event.preventDefault();

const nextQuery = query.trim();

if (!nextQuery) {
return;
}

onSearch?.();
router.push(`/search?q=${encodeURIComponent(nextQuery)}`);
}

return (
<form action="/search" className={className} onSubmit={handleSubmit} role="search">
<SearchInput
aria-label="Search HiveLore"
className={inputClassName}
name="q"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search worlds and lore"
value={query}
/>
</form>
);
}
8 changes: 2 additions & 6 deletions apps/web/src/components/layout/mobile-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -103,11 +103,7 @@ export function MobileNavigation() {
</ul>
</nav>
<div className="grid gap-4 border-t border-border pt-4">
<SearchInput
aria-label="Search placeholder"
disabled
placeholder="Search coming soon"
/>
<GlobalSearchForm onSearch={() => setIsOpen(false)} />
<Link
className="inline-flex min-h-10 items-center justify-center rounded-control border border-border bg-surface px-4 text-sm font-semibold text-foreground shadow-soft transition-colors hover:bg-muted focus-visible:outline focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"
href="/login"
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/layout/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import Link from 'next/link';
import { HiveBrand } from '@/components/hive-brand';
import { ThemeSwitcher } from '@/components/theme-switcher';
import { DesktopNavigation } from '@/components/layout/desktop-navigation';
import { GlobalSearchForm } from '@/components/layout/global-search-form';
import { MobileNavigation } from '@/components/layout/mobile-navigation';
import { SearchInput } from '@/components/ui/search-input';

export function SiteHeader() {
return (
Expand All @@ -20,7 +20,7 @@ export function SiteHeader() {
<DesktopNavigation />

<div className="ml-auto hidden w-full max-w-xs lg:block">
<SearchInput aria-label="Search placeholder" disabled placeholder="Search coming soon" />
<GlobalSearchForm />
</div>

<div className="hidden items-center gap-3 md:flex">
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/lib/api/search.ts
Original file line number Diff line number Diff line change
@@ -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()}`);
}
Loading