web: highlight word matches, limit search results

This commit is contained in:
2026-05-25 23:39:11 +02:00
parent 2c8f8a218c
commit 1b5483602f
4 changed files with 82 additions and 11 deletions
+12 -2
View File
@@ -17,6 +17,8 @@ export default async function Page({
}) { }) {
const { q: query } = (await searchParams) ?? {}; const { q: query } = (await searchParams) ?? {};
let results: SearchResult[] = []; let results: SearchResult[] = [];
let totalCount = 0;
const RESULT_LIMIT = 500;
if (query) { if (query) {
const searchQuery = graphql(` const searchQuery = graphql(`
@@ -64,13 +66,21 @@ export default async function Page({
`); `);
const { data } = await getClient().query(searchQuery, { query }); 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 ( return (
<main className="site-main" id="main"> <main className="site-main" id="main">
<SearchShell initialQuery={query ?? ""}> <SearchShell initialQuery={query ?? ""}>
{query ? <SearchResults results={results} /> : null} {query ? (
<SearchResults
results={results}
totalCount={totalCount}
query={query}
/>
) : null}
</SearchShell> </SearchShell>
</main> </main>
); );
+45 -7
View File
@@ -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) { if (!results.length) {
return ( return (
<div className={styles.noResults} aria-live="polite"> <div className={styles.noResults} aria-live="polite">
Ingen resultater <p className={styles.noResultsHeading}>Ingen treff «{query}»</p>
</div> </div>
); );
} }
const supportedResults = results.filter(isSupported); const supportedResults = results.filter(isSupported);
const truncated = totalCount > results.length;
return ( return (
<div> <div>
<p className={styles.resultsCounter} aria-live="polite"> <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> </p>
{supportedResults.map((result) => ( {supportedResults.map((result) => (
<ResultRow key={result.id} result={result} /> <ResultRow key={result.id} result={result} query={query} />
))} ))}
</div> </div>
); );
} }
function ResultRow({ result }: { result: SupportedResult }) { function ResultRow({
result,
query,
}: {
result: SupportedResult;
query: string;
}) {
const image = getResultImage(result); const image = getResultImage(result);
const snippet = getResultSnippet(result); const snippet = getResultSnippet(result);
const date = getResultDate(result); const date = getResultDate(result);
@@ -108,9 +144,11 @@ function ResultRow({ result }: { result: SupportedResult }) {
<div className={styles.resultItem}> <div className={styles.resultItem}>
<div className={styles.resultBody}> <div className={styles.resultBody}>
<span className={styles.suphead}>{resultType}</span> <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>} {date && <p className={styles.date}>{date}</p>}
{snippet && <p className={styles.snippet}>{snippet}</p>} {snippet && (
<p className={styles.snippet}>{highlight(snippet, query)}</p>
)}
</div> </div>
{image?.url && ( {image?.url && (
<div className={styles.thumb}> <div className={styles.thumb}>
+7 -1
View File
@@ -22,8 +22,9 @@ export function SearchShell({
}) { }) {
const { replace } = useRouter(); const { replace } = useRouter();
const [inputValue, setInputValue] = useState(initialQuery); const [inputValue, setInputValue] = useState(initialQuery);
const [, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const lastPushedRef = useRef(initialQuery); const lastPushedRef = useRef(initialQuery);
const fetching = isPending || inputValue !== lastPushedRef.current;
const pushQuery = useDebouncedCallback((next: string) => { const pushQuery = useDebouncedCallback((next: string) => {
lastPushedRef.current = next; lastPushedRef.current = next;
@@ -73,7 +74,12 @@ export function SearchShell({
</div> </div>
</div> </div>
</form> </form>
<div
className={fetching ? styles.fetching : undefined}
aria-busy={fetching}
>
{children} {children}
</div> </div>
</div>
); );
} }
@@ -5,6 +5,13 @@
a { a {
text-decoration: none; text-decoration: none;
} }
mark {
background: var(--color-goldenBeige);
color: inherit;
padding: 0 .05em;
border-radius: 2px;
}
} }
.searchField { .searchField {
@@ -51,6 +58,16 @@
.snippet { .snippet {
margin-top: 0.25em; 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 { .thumb {