web: fix search debounce and client/server boundary
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { graphql } from "@/gql";
|
|
||||||
import { getClient } from "@/app/client";
|
import { getClient } from "@/app/client";
|
||||||
import {
|
import {
|
||||||
SearchContainer,
|
|
||||||
type SearchResult,
|
type SearchResult,
|
||||||
} from "@/components/search/SearchContainer";
|
SearchResults,
|
||||||
import { Suspense } from "react";
|
} from "@/components/search/SearchResults";
|
||||||
|
import { SearchShell } from "@/components/search/SearchShell";
|
||||||
|
import { graphql } from "@/gql";
|
||||||
|
|
||||||
// TODO: seo metadata?
|
// TODO: seo metadata?
|
||||||
|
|
||||||
@@ -69,9 +69,9 @@ export default async function Page({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="site-main" id="main">
|
<main className="site-main" id="main">
|
||||||
<Suspense key={query}>
|
<SearchShell initialQuery={query ?? ""}>
|
||||||
<SearchContainer query={query ?? ""} results={results} />
|
{query ? <SearchResults results={results} /> : null}
|
||||||
</Suspense>
|
</SearchShell>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-58
@@ -1,18 +1,8 @@
|
|||||||
"use client";
|
import { ImageFragmentDefinition, stripHtml } from "@/lib/common";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import { PageHeader } from "../general/PageHeader";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
ImageFragmentDefinition,
|
|
||||||
getSearchPath,
|
|
||||||
stripHtml,
|
|
||||||
} from "@/lib/common";
|
|
||||||
import { formatDate, formatOccurrenceMonths } from "@/lib/date";
|
import { formatDate, formatOccurrenceMonths } from "@/lib/date";
|
||||||
import { unmaskFragment } from "@/gql";
|
import { unmaskFragment } from "@/gql";
|
||||||
import type { ImageFragment, SearchQuery } from "@/gql/graphql";
|
import type { ImageFragment, SearchQuery } from "@/gql/graphql";
|
||||||
import styles from "./searchContainer.module.scss";
|
import styles from "./searchContainer.module.scss";
|
||||||
import { Icon } from "../general/Icon";
|
|
||||||
import { Image } from "../general/Image";
|
import { Image } from "../general/Image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -29,57 +19,12 @@ const PAGE_TYPES = {
|
|||||||
type SupportedTypename = keyof typeof PAGE_TYPES;
|
type SupportedTypename = keyof typeof PAGE_TYPES;
|
||||||
type SupportedResult = Extract<SearchResult, { __typename: SupportedTypename }>;
|
type SupportedResult = Extract<SearchResult, { __typename: SupportedTypename }>;
|
||||||
|
|
||||||
export function SearchContainer({
|
|
||||||
query,
|
|
||||||
results,
|
|
||||||
}: {
|
|
||||||
query: string;
|
|
||||||
results: SearchResult[];
|
|
||||||
}) {
|
|
||||||
const { replace } = useRouter();
|
|
||||||
const [inputValue, setInputValue] = useState(query);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setInputValue(query);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const pushQuery = useDebouncedCallback((next: string) => {
|
|
||||||
replace(getSearchPath(next));
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.searchContainer}>
|
|
||||||
<PageHeader heading="Søk" />
|
|
||||||
<div className={styles.searchField}>
|
|
||||||
<input
|
|
||||||
name="query"
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInputValue(e.target.value);
|
|
||||||
pushQuery(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={styles.searchIcon}>
|
|
||||||
<Icon type="search" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{query && <SearchResults results={results} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalizeFirstLetter(s: string) {
|
function capitalizeFirstLetter(s: string) {
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupported(result: SearchResult): result is SupportedResult {
|
function isSupported(result: SearchResult): result is SupportedResult {
|
||||||
return (
|
return result.__typename in PAGE_TYPES && "id" in result && !!result.id;
|
||||||
result.__typename in PAGE_TYPES &&
|
|
||||||
"id" in result &&
|
|
||||||
!!result.id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResultType(result: SupportedResult): string {
|
function getResultType(result: SupportedResult): string {
|
||||||
@@ -131,7 +76,7 @@ function getResultSnippet(result: SupportedResult): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchResults({ results }: { results: SearchResult[] }) {
|
export function SearchResults({ results }: { results: SearchResult[] }) {
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
return <div className={styles.noResults}>Ingen resultater</div>;
|
return <div className={styles.noResults}>Ingen resultater</div>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef, useState, useTransition, type ReactNode } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PageHeader } from "../general/PageHeader";
|
||||||
|
import { getSearchPath } from "@/lib/common";
|
||||||
|
import styles from "./searchContainer.module.scss";
|
||||||
|
import { Icon } from "../general/Icon";
|
||||||
|
|
||||||
|
export function SearchShell({
|
||||||
|
initialQuery,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialQuery: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { replace } = useRouter();
|
||||||
|
const [inputValue, setInputValue] = useState(initialQuery);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const lastPushedRef = useRef(initialQuery);
|
||||||
|
|
||||||
|
const pushQuery = useDebouncedCallback((next: string) => {
|
||||||
|
lastPushedRef.current = next;
|
||||||
|
startTransition(() => {
|
||||||
|
replace(getSearchPath(next));
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialQuery !== lastPushedRef.current) {
|
||||||
|
lastPushedRef.current = initialQuery;
|
||||||
|
setInputValue(initialQuery);
|
||||||
|
}
|
||||||
|
}, [initialQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.searchContainer}>
|
||||||
|
<PageHeader heading="Søk" />
|
||||||
|
<div className={styles.searchField}>
|
||||||
|
<input
|
||||||
|
name="query"
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
pushQuery(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.searchIcon}>
|
||||||
|
<Icon type="search" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user