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) ?? {};
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 på «{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}>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user