4636eaa7d0
- search.types.ts: IPageSearch is the #529 superset (pageId/snippet/score/ path/matchedFields/matchedTerms; rank/highlight nullable); add the IPageSearchResponse pagination envelope and match/limit/offset params. - search-spotlight.tsx: operator hint ("exact phrase", +required, -excluded). The web list already reads response.items, so it picks up the new OR order + superset with no code change; icon/space/highlight remain (acceptance #12). Full spotlight pagination UI is deferred within this PR — the API + MCP fully support total/hasMore/offset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
3.7 KiB
TypeScript
119 lines
3.7 KiB
TypeScript
import { Spotlight } from "@mantine/spotlight";
|
|
import { IconSearch } from "@tabler/icons-react";
|
|
import { Group, Text, VisuallyHidden } from "@mantine/core";
|
|
import { useState, useMemo } from "react";
|
|
import { useDebouncedValue } from "@mantine/hooks";
|
|
import { useTranslation } from "react-i18next";
|
|
import { searchSpotlightStore } from "../constants.ts";
|
|
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
|
|
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
|
|
import { SearchResultItem } from "./search-result-item.tsx";
|
|
|
|
interface SearchSpotlightProps {
|
|
spaceId?: string;
|
|
}
|
|
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
|
const { t } = useTranslation();
|
|
const [query, setQuery] = useState("");
|
|
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
|
|
const [filters, setFilters] = useState<{
|
|
spaceId?: string | null;
|
|
contentType?: string;
|
|
}>({
|
|
contentType: "page",
|
|
});
|
|
|
|
// Build unified search params
|
|
const searchParams = useMemo(() => {
|
|
const params: any = {
|
|
query: debouncedSearchQuery,
|
|
contentType: filters.contentType || "page", // Only used for frontend routing
|
|
};
|
|
|
|
// Handle space filtering - only pass spaceId if a specific space is selected
|
|
if (filters.spaceId) {
|
|
params.spaceId = filters.spaceId;
|
|
}
|
|
|
|
return params;
|
|
}, [debouncedSearchQuery, filters]);
|
|
|
|
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
|
|
|
|
const resultItems = (searchResults || []).map((result) => (
|
|
<SearchResultItem
|
|
key={result.id}
|
|
result={result}
|
|
isAttachmentResult={filters.contentType === "attachment"}
|
|
showSpace={!filters.spaceId}
|
|
/>
|
|
));
|
|
|
|
const handleFiltersChange = (newFilters: any) => {
|
|
setFilters(newFilters);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Spotlight.Root
|
|
size="xl"
|
|
maxHeight={600}
|
|
store={searchSpotlightStore}
|
|
query={query}
|
|
onQueryChange={setQuery}
|
|
scrollable
|
|
overlayProps={{
|
|
backgroundOpacity: 0.55,
|
|
}}
|
|
>
|
|
<Group gap="xs" px="sm" pt="sm" pb="xs">
|
|
<Spotlight.Search
|
|
placeholder={t("Search...")}
|
|
aria-label={t("Search")}
|
|
leftSection={<IconSearch size={20} stroke={1.5} />}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</Group>
|
|
|
|
<div
|
|
style={{
|
|
padding: "4px 16px",
|
|
}}
|
|
>
|
|
<SearchSpotlightFilters
|
|
onFiltersChange={handleFiltersChange}
|
|
spaceId={spaceId}
|
|
/>
|
|
{/* #529: operator hint — matches ANY word by default; "…" for an exact
|
|
phrase, +term to require, -term to exclude. */}
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
{t('Tip: "exact phrase", +required, -excluded')}
|
|
</Text>
|
|
</div>
|
|
|
|
<VisuallyHidden role="status" aria-live="polite">
|
|
{query.length > 0 && !isLoading
|
|
? resultItems.length === 0
|
|
? t("No results found")
|
|
: // Singular/plural handling so 1 result is not announced as
|
|
// "1 results found".
|
|
t("{{count}} result found", { count: resultItems.length })
|
|
: ""}
|
|
</VisuallyHidden>
|
|
|
|
<Spotlight.ActionsList>
|
|
{query.length === 0 && resultItems.length === 0 && (
|
|
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
|
|
)}
|
|
|
|
{query.length > 0 && !isLoading && resultItems.length === 0 && (
|
|
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
|
|
)}
|
|
|
|
{resultItems.length > 0 && <>{resultItems}</>}
|
|
</Spotlight.ActionsList>
|
|
</Spotlight.Root>
|
|
</>
|
|
);
|
|
}
|