web: highlight word matches, limit search results
This commit is contained in:
@@ -17,6 +17,8 @@ export default async function Page({
|
||||
}) {
|
||||
const { q: query } = (await searchParams) ?? {};
|
||||
let results: SearchResult[] = [];
|
||||
let totalCount = 0;
|
||||
const RESULT_LIMIT = 500;
|
||||
|
||||
if (query) {
|
||||
const searchQuery = graphql(`
|
||||
@@ -64,13 +66,21 @@ export default async function Page({
|
||||
`);
|
||||
|
||||
const { data } = await getClient().query(searchQuery, { query });
|
||||
results = (data?.results ?? []) as SearchResult[];
|
||||
const all = (data?.results ?? []) as SearchResult[];
|
||||
totalCount = all.length;
|
||||
results = all.slice(0, RESULT_LIMIT);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="site-main" id="main">
|
||||
<SearchShell initialQuery={query ?? ""}>
|
||||
{query ? <SearchResults results={results} /> : null}
|
||||
{query ? (
|
||||
<SearchResults
|
||||
results={results}
|
||||
totalCount={totalCount}
|
||||
query={query}
|
||||
/>
|
||||
) : null}
|
||||
</SearchShell>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -76,28 +76,64 @@ function getResultSnippet(result: SupportedResult): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function SearchResults({ results }: { results: SearchResult[] }) {
|
||||
function highlight(text: string, query: string): React.ReactNode {
|
||||
if (query.length < 2) return text;
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(escaped, "gi");
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
nodes.push(<mark key={`m-${match.index}`}>{match[0]}</mark>);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(text.slice(lastIndex));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
results,
|
||||
totalCount,
|
||||
query,
|
||||
}: {
|
||||
results: SearchResult[];
|
||||
totalCount: number;
|
||||
query: string;
|
||||
}) {
|
||||
if (!results.length) {
|
||||
return (
|
||||
<div className={styles.noResults} aria-live="polite">
|
||||
Ingen resultater
|
||||
<p className={styles.noResultsHeading}>Ingen treff på «{query}»</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const supportedResults = results.filter(isSupported);
|
||||
const truncated = totalCount > results.length;
|
||||
return (
|
||||
<div>
|
||||
<p className={styles.resultsCounter} aria-live="polite">
|
||||
{results.length} resultater
|
||||
{truncated
|
||||
? `Viser de første ${results.length} av ${totalCount} treff — prøv et mer spesifikt søk.`
|
||||
: `${results.length} resultater`}
|
||||
</p>
|
||||
{supportedResults.map((result) => (
|
||||
<ResultRow key={result.id} result={result} />
|
||||
<ResultRow key={result.id} result={result} query={query} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({ result }: { result: SupportedResult }) {
|
||||
function ResultRow({
|
||||
result,
|
||||
query,
|
||||
}: {
|
||||
result: SupportedResult;
|
||||
query: string;
|
||||
}) {
|
||||
const image = getResultImage(result);
|
||||
const snippet = getResultSnippet(result);
|
||||
const date = getResultDate(result);
|
||||
@@ -108,9 +144,11 @@ function ResultRow({ result }: { result: SupportedResult }) {
|
||||
<div className={styles.resultItem}>
|
||||
<div className={styles.resultBody}>
|
||||
<span className={styles.suphead}>{resultType}</span>
|
||||
<h2 className={styles.title}>{result.title}</h2>
|
||||
<h2 className={styles.title}>{highlight(result.title, query)}</h2>
|
||||
{date && <p className={styles.date}>{date}</p>}
|
||||
{snippet && <p className={styles.snippet}>{snippet}</p>}
|
||||
{snippet && (
|
||||
<p className={styles.snippet}>{highlight(snippet, query)}</p>
|
||||
)}
|
||||
</div>
|
||||
{image?.url && (
|
||||
<div className={styles.thumb}>
|
||||
|
||||
@@ -22,8 +22,9 @@ export function SearchShell({
|
||||
}) {
|
||||
const { replace } = useRouter();
|
||||
const [inputValue, setInputValue] = useState(initialQuery);
|
||||
const [, startTransition] = useTransition();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const lastPushedRef = useRef(initialQuery);
|
||||
const fetching = isPending || inputValue !== lastPushedRef.current;
|
||||
|
||||
const pushQuery = useDebouncedCallback((next: string) => {
|
||||
lastPushedRef.current = next;
|
||||
@@ -73,7 +74,12 @@ export function SearchShell({
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{children}
|
||||
<div
|
||||
className={fetching ? styles.fetching : undefined}
|
||||
aria-busy={fetching}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
mark {
|
||||
background: var(--color-goldenBeige);
|
||||
color: inherit;
|
||||
padding: 0 .05em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.searchField {
|
||||
@@ -51,6 +58,16 @@
|
||||
|
||||
.snippet {
|
||||
margin-top: 0.25em;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fetching {
|
||||
opacity: 0.5;
|
||||
transition: opacity .15s ease;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
|
||||
Reference in New Issue
Block a user