web: more details in search results

This commit is contained in:
2026-05-25 22:56:24 +02:00
parent af8c3fe768
commit 433c88c921
6 changed files with 232 additions and 55 deletions
+35 -7
View File
@@ -1,6 +1,9 @@
import { graphql } from "@/gql"; import { graphql } from "@/gql";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { SearchContainer } from "@/components/search/SearchContainer"; import {
SearchContainer,
type SearchResult,
} from "@/components/search/SearchContainer";
import { Suspense } from "react"; import { Suspense } from "react";
// TODO: seo metadata? // TODO: seo metadata?
@@ -13,7 +16,7 @@ export default async function Page({
}>; }>;
}) { }) {
const { q: query } = (await searchParams) ?? {}; const { q: query } = (await searchParams) ?? {};
let results = []; let results: SearchResult[] = [];
if (query) { if (query) {
const searchQuery = graphql(` const searchQuery = graphql(`
@@ -25,18 +28,43 @@ export default async function Page({
title title
url url
} }
... on NewsPage {
excerpt
featuredImage {
...Image
}
firstPublishedAt
}
... on EventPage {
subtitle
featuredImage {
...Image
}
occurrences {
start
}
}
... on GenericPage {
lead
}
... on VenuePage {
featuredImage {
...Image
}
}
... on AssociationPage { ... on AssociationPage {
excerpt
associationType associationType
logo {
...Image
}
} }
} }
} }
`); `);
const { data, error } = await getClient().query(searchQuery, { const { data } = await getClient().query(searchQuery, { query });
query: query, results = (data?.results ?? []) as SearchResult[];
});
results = (data?.results ?? []) as any;
} }
return ( return (
+118 -38
View File
@@ -3,17 +3,38 @@ import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { PageHeader } from "../general/PageHeader"; import { PageHeader } from "../general/PageHeader";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { getSearchPath } from "@/lib/common"; import {
import styles from './searchContainer.module.scss'; ImageFragmentDefinition,
getSearchPath,
stripHtml,
} from "@/lib/common";
import { formatDate, formatOccurrenceMonths } from "@/lib/date";
import { unmaskFragment } from "@/gql";
import type { ImageFragment, SearchQuery } from "@/gql/graphql";
import styles from "./searchContainer.module.scss";
import { Icon } from "../general/Icon"; import { Icon } from "../general/Icon";
import { Image } from "../general/Image";
import Link from "next/link"; import Link from "next/link";
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 }>;
export function SearchContainer({ export function SearchContainer({
query, query,
results, results,
}: { }: {
query: string; query: string;
results: any; results: SearchResult[];
}) { }) {
const { replace } = useRouter(); const { replace } = useRouter();
const [inputValue, setInputValue] = useState(query); const [inputValue, setInputValue] = useState(query);
@@ -53,50 +74,109 @@ function capitalizeFirstLetter(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
} }
function linkTo(page: any): string | null { function isSupported(result: SearchResult): result is SupportedResult {
return page?.url ?? null; return (
result.__typename in PAGE_TYPES &&
"id" in result &&
!!result.id
);
} }
const PAGE_TYPES: Record<string, string> = { function getResultType(result: SupportedResult): string {
NewsPage: "Nyhet", if (result.__typename === "AssociationPage" && result.associationType) {
EventPage: "Arrangement", return capitalizeFirstLetter(result.associationType);
GenericPage: "Underside", }
VenuePage: "Lokale", return PAGE_TYPES[result.__typename];
AssociationPage: "Forening", }
};
function SearchResults({ results }: { results: any }) { 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 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>;
} }
const supportedResults = results.filter( const supportedResults = results.filter(isSupported);
(result: any) =>
!!result?.id && Object.keys(PAGE_TYPES).includes(result.__typename)
);
return ( return (
<div> <div>
<p className={styles.resultsCounter}>{results.length} resultater</p> <p className={styles.resultsCounter}>{results.length} resultater</p>
{supportedResults.map((result: any) => { {supportedResults.map((result) => (
let resultType = PAGE_TYPES[result.__typename] ?? ""; <ResultRow key={result.id} result={result} />
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> </div>
); );
} }
function ResultRow({ result }: { result: SupportedResult }) {
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}>{result.title}</h2>
{date && <p className={styles.date}>{date}</p>}
{snippet && <p className={styles.snippet}>{snippet}</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;
}
@@ -29,11 +29,42 @@
} }
.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;
}
.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 +79,4 @@
.noResults { .noResults {
margin: var(--spacing-s) 0 var(--spacing-section-bottom); margin: var(--spacing-s) 0 var(--spacing-section-bottom);
} }
+3 -3
View File
@@ -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 id\n title\n url\n }\n ... on AssociationPage {\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 id\n title\n url\n }\n ... on AssociationPage {\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 id\n title\n url\n }\n ... on AssociationPage {\n associationType\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 AssociationPage {\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.
*/ */
File diff suppressed because one or more lines are too long
+38
View File
@@ -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");