Compare commits
6 Commits
a58e2b224e
...
09d1078dce
| Author | SHA1 | Date | |
|---|---|---|---|
|
09d1078dce
|
|||
|
1b5483602f
|
|||
|
2c8f8a218c
|
|||
|
b5c9188488
|
|||
|
433c88c921
|
|||
|
af8c3fe768
|
@@ -174,6 +174,9 @@ MEDIA_URL = "/media/"
|
|||||||
|
|
||||||
WAGTAIL_SITE_NAME = "dnscms"
|
WAGTAIL_SITE_NAME = "dnscms"
|
||||||
WAGTAIL_ALLOW_UNICODE_SLUGS = False
|
WAGTAIL_ALLOW_UNICODE_SLUGS = False
|
||||||
|
# Headless: the Next.js frontend uses trailing-slash-free URLs, so strip
|
||||||
|
# trailing slashes from links generated by Wagtail (e.g. the GraphQL `url` field).
|
||||||
|
WAGTAIL_APPEND_SLASH = False
|
||||||
|
|
||||||
WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage"
|
WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage"
|
||||||
WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"]
|
WAGTAILIMAGES_EXTENSIONS = ["avif", "gif", "jpg", "jpeg", "png", "webp", "svg"]
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
from django.apps import apps as django_apps
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
from grapple.registry import registry as grapple_registry
|
||||||
from wagtail import hooks
|
from wagtail import hooks
|
||||||
|
from wagtail.documents import get_document_model
|
||||||
|
from wagtail.images import get_image_model
|
||||||
|
from wagtail.models import Page
|
||||||
|
from wagtail.search.backends import get_search_backend
|
||||||
|
|
||||||
|
|
||||||
@hooks.register("register_rich_text_features")
|
@hooks.register("register_rich_text_features")
|
||||||
@@ -8,6 +14,36 @@ def enable_additional_rich_text_features(features):
|
|||||||
features.default_features.extend(["h5", "h6", "blockquote"])
|
features.default_features.extend(["h5", "h6", "blockquote"])
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("register_schema_query")
|
||||||
|
def filter_search_to_live_pages(query_mixins):
|
||||||
|
"""
|
||||||
|
Grapple's default `search` resolver hits every page regardless of publish
|
||||||
|
state, exposing drafts on the public API. Prepend a mixin so MRO picks our
|
||||||
|
`resolve_search`, which restricts Page subclasses to live + public.
|
||||||
|
"""
|
||||||
|
if not grapple_registry.class_models:
|
||||||
|
return
|
||||||
|
|
||||||
|
class SearchLivePublicMixin:
|
||||||
|
def resolve_search(self, info, **kwargs):
|
||||||
|
query = kwargs.get("query")
|
||||||
|
if not query:
|
||||||
|
return None
|
||||||
|
s = get_search_backend()
|
||||||
|
results = []
|
||||||
|
models = [get_document_model(), get_image_model()]
|
||||||
|
for app in grapple_registry.apps:
|
||||||
|
models += django_apps.all_models[app].values()
|
||||||
|
for model in models:
|
||||||
|
if issubclass(model, Page):
|
||||||
|
results += s.search(query, model.objects.live().public())
|
||||||
|
else:
|
||||||
|
results += s.search(query, model)
|
||||||
|
return results
|
||||||
|
|
||||||
|
query_mixins.insert(0, SearchLivePublicMixin)
|
||||||
|
|
||||||
|
|
||||||
@hooks.register("construct_page_action_menu")
|
@hooks.register("construct_page_action_menu")
|
||||||
def make_publish_default_action(menu_items, request, context):
|
def make_publish_default_action(menu_items, request, context):
|
||||||
for index, item in enumerate(menu_items):
|
for index, item in enumerate(menu_items):
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from wagtail.search.backends import get_search_backend
|
||||||
|
|
||||||
|
from tests.conftest import EventPageFactory, GenericPageFactory
|
||||||
|
|
||||||
|
|
||||||
|
SEARCH_QUERY = """
|
||||||
|
query Search($query: String) {
|
||||||
|
results: search(query: $query) {
|
||||||
|
__typename
|
||||||
|
... on PageInterface {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _index(page):
|
||||||
|
# Wagtail's post_save signal enqueues indexing via django-tasks, which isn't
|
||||||
|
# drained synchronously in tests. Call the backend directly so the page is
|
||||||
|
# findable through the live search code path.
|
||||||
|
get_search_backend().add(page)
|
||||||
|
|
||||||
|
|
||||||
|
def _titles_for(body, typename):
|
||||||
|
return [r["title"] for r in body["data"]["results"] if r["__typename"] == typename]
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_returns_live_generic_page(home_page, graphql_post):
|
||||||
|
page = GenericPageFactory(
|
||||||
|
parent=home_page,
|
||||||
|
title="PublishedGenericSearchToken",
|
||||||
|
slug="published-generic-search",
|
||||||
|
)
|
||||||
|
_index(page)
|
||||||
|
|
||||||
|
response, body = graphql_post(SEARCH_QUERY, {"query": "PublishedGenericSearchToken"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "errors" not in body, body
|
||||||
|
assert "PublishedGenericSearchToken" in _titles_for(body, "GenericPage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_excludes_draft_generic_page(home_page, graphql_post):
|
||||||
|
page = GenericPageFactory(
|
||||||
|
parent=home_page,
|
||||||
|
title="DraftGenericSearchToken",
|
||||||
|
slug="draft-generic-search",
|
||||||
|
live=False,
|
||||||
|
)
|
||||||
|
_index(page)
|
||||||
|
|
||||||
|
response, body = graphql_post(SEARCH_QUERY, {"query": "DraftGenericSearchToken"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "errors" not in body, body
|
||||||
|
assert "DraftGenericSearchToken" not in _titles_for(body, "GenericPage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_returns_live_event_page(home_page, event_index, graphql_post):
|
||||||
|
page = EventPageFactory(
|
||||||
|
parent=event_index,
|
||||||
|
title="PublishedEventSearchToken",
|
||||||
|
slug="published-event-search",
|
||||||
|
)
|
||||||
|
_index(page)
|
||||||
|
|
||||||
|
response, body = graphql_post(SEARCH_QUERY, {"query": "PublishedEventSearchToken"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "errors" not in body, body
|
||||||
|
assert "PublishedEventSearchToken" in _titles_for(body, "EventPage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_excludes_draft_event_page(home_page, event_index, graphql_post):
|
||||||
|
page = EventPageFactory(
|
||||||
|
parent=event_index,
|
||||||
|
title="DraftEventSearchToken",
|
||||||
|
slug="draft-event-search",
|
||||||
|
live=False,
|
||||||
|
)
|
||||||
|
_index(page)
|
||||||
|
|
||||||
|
response, body = graphql_post(SEARCH_QUERY, {"query": "DraftEventSearchToken"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "errors" not in body, body
|
||||||
|
assert "DraftEventSearchToken" not in _titles_for(body, "EventPage")
|
||||||
+45
-23
@@ -1,7 +1,10 @@
|
|||||||
import { graphql } from "@/gql";
|
|
||||||
import { getClient } from "@/app/client";
|
import { getClient } from "@/app/client";
|
||||||
import { SearchContainer } from "@/components/search/SearchContainer";
|
import {
|
||||||
import { Suspense } from "react";
|
type SearchResult,
|
||||||
|
SearchResults,
|
||||||
|
} from "@/components/search/SearchResults";
|
||||||
|
import { SearchShell } from "@/components/search/SearchShell";
|
||||||
|
import { graphql } from "@/gql";
|
||||||
|
|
||||||
// TODO: seo metadata?
|
// TODO: seo metadata?
|
||||||
|
|
||||||
@@ -13,7 +16,9 @@ export default async function Page({
|
|||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { q: query } = (await searchParams) ?? {};
|
const { q: query } = (await searchParams) ?? {};
|
||||||
let results = [];
|
let results: SearchResult[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
const RESULT_LIMIT = 500;
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const searchQuery = graphql(`
|
const searchQuery = graphql(`
|
||||||
@@ -21,45 +26,62 @@ export default async function Page({
|
|||||||
results: search(query: $query) {
|
results: search(query: $query) {
|
||||||
__typename
|
__typename
|
||||||
... on PageInterface {
|
... on PageInterface {
|
||||||
slug
|
id
|
||||||
|
title
|
||||||
|
url
|
||||||
}
|
}
|
||||||
... on NewsPage {
|
... on NewsPage {
|
||||||
id
|
excerpt
|
||||||
title
|
featuredImage {
|
||||||
|
...Image
|
||||||
|
}
|
||||||
|
firstPublishedAt
|
||||||
}
|
}
|
||||||
... on EventPage {
|
... on EventPage {
|
||||||
id
|
subtitle
|
||||||
title
|
featuredImage {
|
||||||
|
...Image
|
||||||
|
}
|
||||||
|
occurrences {
|
||||||
|
start
|
||||||
|
}
|
||||||
}
|
}
|
||||||
... on GenericPage {
|
... on GenericPage {
|
||||||
id
|
lead
|
||||||
title
|
|
||||||
}
|
}
|
||||||
... on VenuePage {
|
... on VenuePage {
|
||||||
id
|
featuredImage {
|
||||||
title
|
...Image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
... on AssociationPage {
|
... on AssociationPage {
|
||||||
id
|
excerpt
|
||||||
title
|
|
||||||
associationType
|
associationType
|
||||||
|
logo {
|
||||||
|
...Image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const { data, error } = await getClient().query(searchQuery, {
|
const { data } = await getClient().query(searchQuery, { query });
|
||||||
query: query,
|
const all = (data?.results ?? []) as SearchResult[];
|
||||||
});
|
totalCount = all.length;
|
||||||
|
results = all.slice(0, RESULT_LIMIT);
|
||||||
results = (data?.results ?? []) as any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ? (
|
||||||
</Suspense>
|
<SearchResults
|
||||||
|
results={results}
|
||||||
|
totalCount={totalCount}
|
||||||
|
query={query}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SearchShell>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import { PageHeader } from "../general/PageHeader";
|
|
||||||
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
|
||||||
import { getSearchPath } from "@/lib/common";
|
|
||||||
import styles from './searchContainer.module.scss';
|
|
||||||
import { Icon } from "../general/Icon";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export function SearchContainer({
|
|
||||||
query,
|
|
||||||
results,
|
|
||||||
}: {
|
|
||||||
query: string;
|
|
||||||
results: any;
|
|
||||||
}) {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { replace } = useRouter();
|
|
||||||
|
|
||||||
const onQueryChange = useDebouncedCallback((query) => {
|
|
||||||
replace(getSearchPath(query));
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.searchContainer}>
|
|
||||||
<PageHeader heading="Søk" />
|
|
||||||
<div className={styles.searchField}>
|
|
||||||
<input
|
|
||||||
name="query"
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
defaultValue={query ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
onQueryChange(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={styles.searchIcon}>
|
|
||||||
<Icon type="search" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{query && <SearchResults results={results} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalizeFirstLetter(s: string) {
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkTo(page: any): string | null {
|
|
||||||
if (page.__typename === "EventPage") {
|
|
||||||
return `/arrangementer/${page.slug}`;
|
|
||||||
}
|
|
||||||
if (page.__typename === "NewsPage") {
|
|
||||||
return `/aktuelt/${page.slug}`;
|
|
||||||
}
|
|
||||||
if (page.__typename === "AssociationPage") {
|
|
||||||
return `/foreninger/${page.slug}`;
|
|
||||||
}
|
|
||||||
if (page.__typename === "GenericPage") {
|
|
||||||
return `/{page.slug}`;
|
|
||||||
}
|
|
||||||
if (page.__typename === "VenuePage") {
|
|
||||||
return `/lokaler/${page.slug}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_TYPES: Record<string, string> = {
|
|
||||||
NewsPage: "Nyhet",
|
|
||||||
EventPage: "Arrangement",
|
|
||||||
GenericPage: "Underside",
|
|
||||||
VenuePage: "Lokale",
|
|
||||||
AssociationPage: "Forening",
|
|
||||||
};
|
|
||||||
|
|
||||||
function SearchResults({ results }: { results: any }) {
|
|
||||||
if (!results.length) {
|
|
||||||
return <div className={styles.noResults}>Ingen resultater</div>;
|
|
||||||
}
|
|
||||||
const supportedResults = results.filter(
|
|
||||||
(result: any) =>
|
|
||||||
!!result?.id && Object.keys(PAGE_TYPES).includes(result.__typename)
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className={styles.resultsCounter}>{results.length} resultater</p>
|
|
||||||
{supportedResults.map((result: any) => {
|
|
||||||
let resultType = PAGE_TYPES[result.__typename] ?? "";
|
|
||||||
if (result.__typename === "AssociationPage") {
|
|
||||||
resultType = capitalizeFirstLetter(result?.associationType);
|
|
||||||
}
|
|
||||||
const link = linkTo(result);
|
|
||||||
const ResultItem = () => (
|
|
||||||
<div className={styles.resultItem}>
|
|
||||||
<span className={styles.suphead}>{resultType}</span>
|
|
||||||
<h2 className={styles.title}>{result.title}</h2>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (link) {
|
|
||||||
return (
|
|
||||||
<Link key={result.id} href={link}>
|
|
||||||
<ResultItem />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <ResultItem key={result.id} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { unmaskFragment } from "@/gql";
|
||||||
|
import type { ImageFragment, SearchQuery } from "@/gql/graphql";
|
||||||
|
import { ImageFragmentDefinition, stripHtml } from "@/lib/common";
|
||||||
|
import { formatDate, formatOccurrenceMonths } from "@/lib/date";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Image } from "../general/Image";
|
||||||
|
import styles from "./searchContainer.module.scss";
|
||||||
|
|
||||||
|
export type SearchResult = SearchQuery["results"][number];
|
||||||
|
|
||||||
|
const PAGE_TYPES = {
|
||||||
|
NewsPage: "Nyhet",
|
||||||
|
EventPage: "Arrangement",
|
||||||
|
GenericPage: "Underside",
|
||||||
|
VenuePage: "Lokale",
|
||||||
|
AssociationPage: "Forening",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SupportedTypename = keyof typeof PAGE_TYPES;
|
||||||
|
type SupportedResult = Extract<SearchResult, { __typename: SupportedTypename }>;
|
||||||
|
|
||||||
|
function capitalizeFirstLetter(s: string) {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupported(result: SearchResult): result is SupportedResult {
|
||||||
|
return result.__typename in PAGE_TYPES && "id" in result && !!result.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultType(result: SupportedResult): string {
|
||||||
|
if (result.__typename === "AssociationPage" && result.associationType) {
|
||||||
|
return capitalizeFirstLetter(result.associationType);
|
||||||
|
}
|
||||||
|
return PAGE_TYPES[result.__typename];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultImage(result: SupportedResult): ImageFragment | null {
|
||||||
|
switch (result.__typename) {
|
||||||
|
case "NewsPage":
|
||||||
|
case "EventPage":
|
||||||
|
case "VenuePage":
|
||||||
|
return unmaskFragment(ImageFragmentDefinition, result.featuredImage);
|
||||||
|
case "AssociationPage":
|
||||||
|
return unmaskFragment(ImageFragmentDefinition, result.logo);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultDate(result: SupportedResult): string | null {
|
||||||
|
if (result.__typename === "EventPage") {
|
||||||
|
const starts = result.occurrences
|
||||||
|
.map((o) => o.start)
|
||||||
|
.filter((s): s is string => !!s);
|
||||||
|
if (starts.length === 0) return null;
|
||||||
|
if (starts.length === 1) return formatDate(starts[0], "d. MMMM yyyy");
|
||||||
|
return formatOccurrenceMonths(starts);
|
||||||
|
}
|
||||||
|
if (result.__typename === "NewsPage" && result.firstPublishedAt) {
|
||||||
|
return formatDate(result.firstPublishedAt, "d. MMMM yyyy");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultSnippet(result: SupportedResult): string | null {
|
||||||
|
switch (result.__typename) {
|
||||||
|
case "NewsPage":
|
||||||
|
case "AssociationPage":
|
||||||
|
return result.excerpt ?? null;
|
||||||
|
case "EventPage":
|
||||||
|
return result.subtitle ?? null;
|
||||||
|
case "GenericPage":
|
||||||
|
return result.lead ? stripHtml(result.lead).trim() : null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
{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} query={query} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultRow({
|
||||||
|
result,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
result: SupportedResult;
|
||||||
|
query: string;
|
||||||
|
}) {
|
||||||
|
const image = getResultImage(result);
|
||||||
|
const snippet = getResultSnippet(result);
|
||||||
|
const date = getResultDate(result);
|
||||||
|
const resultType = getResultType(result);
|
||||||
|
const link = result.url;
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<div className={styles.resultItem}>
|
||||||
|
<div className={styles.resultBody}>
|
||||||
|
<span className={styles.suphead}>{resultType}</span>
|
||||||
|
<h2 className={styles.title}>{highlight(result.title, query)}</h2>
|
||||||
|
{date && <p className={styles.date}>{date}</p>}
|
||||||
|
{snippet && (
|
||||||
|
<p className={styles.snippet}>{highlight(snippet, query)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{image?.url && (
|
||||||
|
<div className={styles.thumb}>
|
||||||
|
<Image
|
||||||
|
src={image.url}
|
||||||
|
alt={image.alt ?? ""}
|
||||||
|
width={image.width}
|
||||||
|
height={image.height}
|
||||||
|
sizes="100px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
return <Link href={link}>{body}</Link>;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
import { getSearchPath } from "@/lib/common";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
|
} from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { Icon } from "../general/Icon";
|
||||||
|
import { PageHeader } from "../general/PageHeader";
|
||||||
|
import styles from "./searchContainer.module.scss";
|
||||||
|
|
||||||
|
export function SearchShell({
|
||||||
|
initialQuery,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialQuery: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { replace } = useRouter();
|
||||||
|
const [inputValue, setInputValue] = useState(initialQuery);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const lastPushedRef = useRef(initialQuery);
|
||||||
|
const fetching = isPending || inputValue !== lastPushedRef.current;
|
||||||
|
|
||||||
|
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={initialQuery ? `Søk: «${initialQuery}»` : "Søk"} />
|
||||||
|
<form
|
||||||
|
action="/sok"
|
||||||
|
method="get"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pushQuery.cancel();
|
||||||
|
lastPushedRef.current = inputValue;
|
||||||
|
startTransition(() => {
|
||||||
|
replace(getSearchPath(inputValue));
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.searchField}>
|
||||||
|
<label htmlFor="search-query" className="sr-only">
|
||||||
|
Søk
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="search-query"
|
||||||
|
name="q"
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInputValue(e.target.value);
|
||||||
|
pushQuery(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.searchIcon} aria-hidden="true">
|
||||||
|
<Icon type="search" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
className={fetching ? styles.fetching : undefined}
|
||||||
|
aria-busy={fetching}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</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 {
|
||||||
@@ -29,11 +36,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resultItem {
|
.resultItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-m);
|
||||||
border-top: var(--border);
|
border-top: var(--border);
|
||||||
margin-top: var(--spacing-m);
|
margin-top: var(--spacing-m);
|
||||||
padding-top: var(--spacing-s);
|
padding-top: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resultBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-chateauBlue-05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
flex: 0 0 5rem;
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.suphead {
|
.suphead {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--font-size-caption);
|
font-size: var(--font-size-caption);
|
||||||
@@ -48,4 +96,4 @@
|
|||||||
|
|
||||||
.noResults {
|
.noResults {
|
||||||
margin: var(--spacing-s) 0 var(--spacing-section-bottom);
|
margin: var(--spacing-s) 0 var(--spacing-section-bottom);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@ type Documents = {
|
|||||||
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument,
|
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument,
|
||||||
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument,
|
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument,
|
||||||
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument,
|
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument,
|
||||||
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n ": typeof types.SearchDocument,
|
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n ": typeof types.SearchDocument,
|
||||||
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": typeof types.AssociationIndexFragmentDoc,
|
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": typeof types.AssociationIndexFragmentDoc,
|
||||||
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": typeof types.AssociationFragmentDoc,
|
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": typeof types.AssociationFragmentDoc,
|
||||||
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": typeof types.AllAssociationsDocument,
|
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": typeof types.AllAssociationsDocument,
|
||||||
@@ -88,7 +88,7 @@ const documents: Documents = {
|
|||||||
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
|
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
|
||||||
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument,
|
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument,
|
||||||
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument,
|
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument,
|
||||||
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n ": types.SearchDocument,
|
"\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n ": types.SearchDocument,
|
||||||
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": types.AssociationIndexFragmentDoc,
|
"\n fragment AssociationIndex on AssociationIndex {\n __typename\n title\n seoTitle\n searchDescription\n lead\n body {\n ...Blocks\n }\n }\n": types.AssociationIndexFragmentDoc,
|
||||||
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": types.AssociationFragmentDoc,
|
"\n fragment Association on AssociationPage {\n __typename\n id\n slug\n title\n seoTitle\n searchDescription\n excerpt\n lead\n body {\n ...Blocks\n }\n logo {\n url\n width\n height\n }\n associationType\n websiteUrl\n }\n": types.AssociationFragmentDoc,
|
||||||
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": types.AllAssociationsDocument,
|
"\n query allAssociations {\n index: associationIndex {\n ... on AssociationIndex {\n ...AssociationIndex\n }\n }\n associations: pages(\n contentType: \"associations.AssociationPage\"\n limit: 1000\n ) {\n ... on AssociationPage {\n ...Association\n }\n }\n }\n": types.AllAssociationsDocument,
|
||||||
@@ -191,7 +191,7 @@ export function graphql(source: "\n query previewPage($token: String!) {\n p
|
|||||||
/**
|
/**
|
||||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
*/
|
*/
|
||||||
export function graphql(source: "\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n "): (typeof documents)["\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n slug\n }\n ... on NewsPage {\n id\n title\n }\n ... on EventPage {\n id\n title\n }\n ... on GenericPage {\n id\n title\n }\n ... on VenuePage {\n id\n title\n }\n ... on AssociationPage {\n id\n title\n associationType\n }\n }\n }\n "];
|
export function graphql(source: "\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n "): (typeof documents)["\n query search($query: String) {\n results: search(query: $query) {\n __typename\n ... on PageInterface {\n id\n title\n url\n }\n ... on NewsPage {\n excerpt\n featuredImage {\n ...Image\n }\n firstPublishedAt\n }\n ... on EventPage {\n subtitle\n featuredImage {\n ...Image\n }\n occurrences {\n start\n }\n }\n ... on GenericPage {\n lead\n }\n ... on VenuePage {\n featuredImage {\n ...Image\n }\n }\n ... on AssociationPage {\n excerpt\n associationType\n logo {\n ...Image\n }\n }\n }\n }\n "];
|
||||||
/**
|
/**
|
||||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+16
-16
File diff suppressed because one or more lines are too long
@@ -95,6 +95,44 @@ export function groupConsecutiveDates(dates: string[]): string[][] {
|
|||||||
return groupedDates;
|
return groupedDates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatOccurrenceMonths(starts: string[]): string {
|
||||||
|
if (starts.length === 0) return "";
|
||||||
|
|
||||||
|
const months = unique(
|
||||||
|
starts.map((s) => format(toLocalTime(s), "yyyy-MM"))
|
||||||
|
).sort() as string[];
|
||||||
|
|
||||||
|
const monthIndex = (ym: string) => {
|
||||||
|
const [y, m] = ym.split("-").map(Number);
|
||||||
|
return y * 12 + (m - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: string[][] = [];
|
||||||
|
for (const ym of months) {
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (last && monthIndex(ym) === monthIndex(last[last.length - 1]) + 1) {
|
||||||
|
last.push(ym);
|
||||||
|
} else {
|
||||||
|
groups.push([ym]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
.map((g) => {
|
||||||
|
const first = parse(g[0], "yyyy-MM", new Date());
|
||||||
|
if (g.length === 1) {
|
||||||
|
return formatDate(first, "MMMM yyyy");
|
||||||
|
}
|
||||||
|
const last = parse(g[g.length - 1], "yyyy-MM", new Date());
|
||||||
|
const firstFmt =
|
||||||
|
first.getFullYear() === last.getFullYear()
|
||||||
|
? formatDate(first, "MMMM")
|
||||||
|
: formatDate(first, "MMMM yyyy");
|
||||||
|
return `${firstFmt} – ${formatDate(last, "MMMM yyyy")}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDateRange(dates: string[]): string {
|
export function formatDateRange(dates: string[]): string {
|
||||||
if (dates.length === 1) {
|
if (dates.length === 1) {
|
||||||
return formatDate(dates[0], "d. MMMM");
|
return formatDate(dates[0], "d. MMMM");
|
||||||
|
|||||||
Reference in New Issue
Block a user