2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
RESOURCES AND PUBLICATIONS - listing
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
/{locale}/resources-and-publications
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
§6 of docs/SHARPER_Website_Draft_Section_EN_AR.docx with the four filters
|
|
|
|
|
the ToR mandates - topic, date, author, partner organisation - plus the type
|
|
|
|
|
categories the draft lists, a sort order and a free-text search.
|
|
|
|
|
|
|
|
|
|
Laid out to the 2026 redesign draft ("Resources-and-Publications"): a sticky
|
|
|
|
|
facet rail on the inline-start side, and a column of wide result rows, each
|
|
|
|
|
with its format chip, its metadata line, its language chip and a direct
|
|
|
|
|
download.
|
|
|
|
|
|
|
|
|
|
THE FACETS ARE LINKS, NOT CHECKBOXES
|
|
|
|
|
The draft draws them as checkboxes, which promises multi-select; the Strapi
|
|
|
|
|
query behind this listing takes one value per facet, so each option is a
|
|
|
|
|
link that sets the facet and clicking the active one clears it. Every view
|
|
|
|
|
is therefore still an ordinary URL - ?topic=access-to-justice&year=2026 -
|
|
|
|
|
that can be shared, bookmarked, walked back and followed by a crawler, and
|
|
|
|
|
it renders identically on the server and the client because nothing about it
|
|
|
|
|
is component state.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
THE AUTHOR FILTER IS NOT A TAXONOMY
|
|
|
|
|
`resource.authors` is a plain STRING field in the CMS - there is no author
|
|
|
|
|
content type and therefore no /api/authors to read. Its options are the
|
|
|
|
|
distinct values the published resources actually carry, which is also why
|
|
|
|
|
the filter offers nothing at all while the CMS is empty instead of 404ing.
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
NOTHING THE DRAFT LISTS IS PUBLISHED HERE. Its six sample publications -
|
|
|
|
|
the "Nationwide Perception Study", the "SHARP I and II Closing Report",
|
|
|
|
|
their page counts and their file sizes - were mockup content. Every row is
|
|
|
|
|
a Strapi entry; an empty CMS renders the localised empty state.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
The canonical URL and the en/ar/x-default hreflang set are emitted for this
|
2026-09-16 20:32:56 +02:00
|
|
|
route by the app shell (root.tsx `Layout`), which is why `buildMeta` is
|
|
|
|
|
called WITHOUT `alternates` here: a leaf `meta` replaces its parents'
|
|
|
|
|
wholesale, and passing it would emit each link twice. Every filter
|
2026-09-11 02:46:56 +02:00
|
|
|
combination therefore canonicalises to the unfiltered listing, which is the
|
|
|
|
|
behaviour we want from a facet.
|
|
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
import { Link, useLocation } from "react-router";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
CardActionHint,
|
|
|
|
|
CardEyebrow,
|
|
|
|
|
CardLink,
|
|
|
|
|
CardShell,
|
|
|
|
|
} from "~/components/ui/Card";
|
|
|
|
|
import { Button } from "~/components/ui/Button";
|
|
|
|
|
import {
|
|
|
|
|
formatDate,
|
|
|
|
|
formatFileSize,
|
|
|
|
|
sectionPath,
|
2026-09-16 20:32:56 +02:00
|
|
|
toIsoDate,
|
2026-09-11 02:46:56 +02:00
|
|
|
translate,
|
|
|
|
|
useTranslation,
|
|
|
|
|
type Locale,
|
|
|
|
|
type TranslationKey,
|
|
|
|
|
} from "~/i18n";
|
|
|
|
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
|
|
|
|
|
|
|
|
|
import type { Route } from "./+types/resources-and-publications";
|
|
|
|
|
import {
|
|
|
|
|
CMS,
|
|
|
|
|
RESOURCE_TYPES,
|
|
|
|
|
fetchCollection,
|
|
|
|
|
fetchDistinctValues,
|
|
|
|
|
fetchFilterOptions,
|
|
|
|
|
fetchYearOptions,
|
|
|
|
|
taxonomyNames,
|
|
|
|
|
toFile,
|
|
|
|
|
type CmsFile,
|
|
|
|
|
type ResourceEntry,
|
|
|
|
|
type ResourceType,
|
|
|
|
|
} from "./_shared/cms";
|
|
|
|
|
import {
|
|
|
|
|
PAGE_SIZE,
|
|
|
|
|
buildPagination,
|
|
|
|
|
compactFilters,
|
|
|
|
|
enumOptions,
|
|
|
|
|
parseExactText,
|
|
|
|
|
parseOptionalEnum,
|
|
|
|
|
parsePage,
|
2026-09-16 20:32:56 +02:00
|
|
|
parseQuery,
|
2026-09-11 02:46:56 +02:00
|
|
|
parseSlug,
|
|
|
|
|
parseSort,
|
|
|
|
|
parseYear,
|
|
|
|
|
readRootSeo,
|
|
|
|
|
SORT_ORDERS,
|
|
|
|
|
requireLocale,
|
|
|
|
|
sortFor,
|
2026-09-16 20:32:56 +02:00
|
|
|
withParam,
|
2026-09-11 02:46:56 +02:00
|
|
|
yearFilter,
|
|
|
|
|
yearOptions,
|
|
|
|
|
type SelectOption,
|
|
|
|
|
type SortOrder,
|
|
|
|
|
} from "./_shared/listing";
|
|
|
|
|
import {
|
|
|
|
|
EmptyState,
|
2026-09-16 20:32:56 +02:00
|
|
|
FacetGroup,
|
|
|
|
|
FacetRail,
|
|
|
|
|
ListingHero,
|
2026-09-11 02:46:56 +02:00
|
|
|
MetaLine,
|
|
|
|
|
PageShell,
|
|
|
|
|
Pagination,
|
|
|
|
|
ResultSummary,
|
2026-09-16 20:32:56 +02:00
|
|
|
SearchForm,
|
|
|
|
|
type FacetOption,
|
2026-09-11 02:46:56 +02:00
|
|
|
} from "./_shared/listing-ui";
|
2026-09-16 20:32:56 +02:00
|
|
|
import {
|
|
|
|
|
RESOURCES_COPY,
|
|
|
|
|
getListingChrome,
|
|
|
|
|
resourceLanguageLabel,
|
|
|
|
|
} from "./_shared/page-copy";
|
|
|
|
|
|
|
|
|
|
import "./_page-kit/motion.css";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The four publication kinds, as `resource.resourceType` publishes them,
|
|
|
|
|
* mapped to the message catalogue.
|
|
|
|
|
*/
|
|
|
|
|
const TYPE_LABEL: Readonly<Record<ResourceType, TranslationKey>> = {
|
|
|
|
|
"research-perception": "resources.categoryResearch",
|
|
|
|
|
"policy-article": "resources.categoryPolicy",
|
|
|
|
|
"project-report": "resources.categoryReports",
|
|
|
|
|
"visibility-factsheet": "resources.categoryVisibility",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SORT_LABEL = {
|
|
|
|
|
newest: "filters.sortNewest",
|
|
|
|
|
oldest: "filters.sortOldest",
|
|
|
|
|
} as const satisfies Readonly<Record<SortOrder, TranslationKey>>;
|
|
|
|
|
|
|
|
|
|
/** The date the listing sorts and filters on. */
|
|
|
|
|
const DATE_FIELD = "publicationDate";
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
LOADER DATA
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
interface ResourceListItem {
|
|
|
|
|
readonly id: string;
|
|
|
|
|
readonly slug: string;
|
|
|
|
|
readonly title: string;
|
|
|
|
|
readonly summary?: string;
|
|
|
|
|
readonly typeLabel?: string;
|
|
|
|
|
readonly date?: string;
|
|
|
|
|
readonly dateLabel?: string;
|
|
|
|
|
/** Free text from the CMS, rendered as written. */
|
|
|
|
|
readonly authors?: string;
|
|
|
|
|
readonly partners: readonly string[];
|
|
|
|
|
readonly topics: readonly string[];
|
|
|
|
|
readonly file?: CmsFile;
|
2026-09-16 20:32:56 +02:00
|
|
|
readonly fileKind?: string;
|
|
|
|
|
readonly fileSizeLabel?: string;
|
|
|
|
|
readonly languageLabel?: string;
|
2026-09-11 02:46:56 +02:00
|
|
|
readonly externalUrl?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
LOADER
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
|
|
|
const locale = requireLocale(params.lang);
|
|
|
|
|
const search = new URL(request.url).searchParams;
|
|
|
|
|
|
|
|
|
|
const page = parsePage(search);
|
|
|
|
|
const type = parseOptionalEnum(search, "type", RESOURCE_TYPES);
|
|
|
|
|
const topic = parseSlug(search, "topic");
|
|
|
|
|
const author = parseExactText(search, "author");
|
|
|
|
|
const partner = parseSlug(search, "partner");
|
|
|
|
|
const year = parseYear(search);
|
|
|
|
|
const sort = parseSort(search);
|
2026-09-16 20:32:56 +02:00
|
|
|
const query = parseQuery(search);
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
const filters = compactFilters({
|
|
|
|
|
resourceType: type ? { $eq: type } : undefined,
|
|
|
|
|
topics: topic ? { slug: { $eq: topic } } : undefined,
|
|
|
|
|
// A string field, so an exact match - not a relation lookup.
|
|
|
|
|
authors: author ? { $eq: author } : undefined,
|
|
|
|
|
partnerOrganisations: partner ? { slug: { $eq: partner } } : undefined,
|
|
|
|
|
[DATE_FIELD]: year ? yearFilter(year) : undefined,
|
2026-09-16 20:32:56 +02:00
|
|
|
/* The draft's "Title, author, keyword" box. The body of a document is not
|
|
|
|
|
searched - it is a file, not a field - so the three things a reader can
|
|
|
|
|
actually see on the card are what the query hits. */
|
|
|
|
|
$or: query
|
|
|
|
|
? [
|
|
|
|
|
{ title: { $containsi: query } },
|
|
|
|
|
{ description: { $containsi: query } },
|
|
|
|
|
{ authors: { $containsi: query } },
|
|
|
|
|
]
|
|
|
|
|
: undefined,
|
2026-09-11 02:46:56 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// One round trip per facet, all in flight together: the listing itself, the
|
|
|
|
|
// three taxonomies behind the ToR filters, and the years actually covered.
|
|
|
|
|
const [listing, topicOptions, authorOptions, partnerOptions, years] =
|
|
|
|
|
await Promise.all([
|
|
|
|
|
fetchCollection<ResourceEntry>(CMS.resources, {
|
|
|
|
|
locale,
|
|
|
|
|
filters,
|
|
|
|
|
sort: sortFor(DATE_FIELD, sort),
|
|
|
|
|
populate: {
|
2026-09-16 20:32:56 +02:00
|
|
|
// No thumbnail: the listing row draws the format chip instead of a
|
|
|
|
|
// cover, so the image is fetched by the detail route, not here.
|
2026-09-11 02:46:56 +02:00
|
|
|
file: { fields: ["url", "name", "ext", "mime", "size"] },
|
|
|
|
|
partnerOrganisations: { fields: ["name", "slug"] },
|
|
|
|
|
topics: { fields: ["name", "slug"] },
|
|
|
|
|
},
|
|
|
|
|
pagination: { page, pageSize: PAGE_SIZE, withCount: true },
|
|
|
|
|
}),
|
|
|
|
|
fetchFilterOptions(CMS.topics, locale),
|
|
|
|
|
fetchDistinctValues(CMS.resources, locale, "authors"),
|
|
|
|
|
fetchFilterOptions(CMS.partners, locale),
|
|
|
|
|
fetchYearOptions(CMS.resources, locale, DATE_FIELD),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const entries: ResourceListItem[] = listing.entries.map((entry) =>
|
|
|
|
|
toListItem(entry, locale),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
entries,
|
|
|
|
|
pagination: buildPagination(page, PAGE_SIZE, listing.pagination.total),
|
|
|
|
|
available: listing.available,
|
|
|
|
|
filters: {
|
|
|
|
|
type: type ?? "",
|
|
|
|
|
topic: topic ?? "",
|
|
|
|
|
author: author ?? "",
|
|
|
|
|
partner: partner ?? "",
|
|
|
|
|
year: year === undefined ? "" : String(year),
|
|
|
|
|
sort,
|
2026-09-16 20:32:56 +02:00
|
|
|
q: query ?? "",
|
2026-09-11 02:46:56 +02:00
|
|
|
},
|
|
|
|
|
facets: {
|
|
|
|
|
types: enumOptions(RESOURCE_TYPES, TYPE_LABEL, locale),
|
|
|
|
|
topics: topicOptions,
|
|
|
|
|
authors: authorOptions,
|
|
|
|
|
partners: partnerOptions,
|
|
|
|
|
years: yearOptions(years, locale),
|
|
|
|
|
sorts: enumOptions(SORT_ORDERS, SORT_LABEL, locale),
|
|
|
|
|
} satisfies Readonly<Record<string, readonly SelectOption[]>>,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toListItem(entry: ResourceEntry, locale: Locale): ResourceListItem {
|
|
|
|
|
const file = toFile(entry.file);
|
|
|
|
|
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
|
|
|
|
|
const authors = entry.authors?.trim();
|
2026-09-16 20:32:56 +02:00
|
|
|
const language = resourceLanguageLabel(entry.resourceLanguage, locale);
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: entry.documentId,
|
|
|
|
|
slug: entry.slug,
|
|
|
|
|
title: entry.title,
|
|
|
|
|
...(entry.description ? { summary: entry.description } : {}),
|
|
|
|
|
...(entry.resourceType
|
|
|
|
|
? { typeLabel: translate(locale, TYPE_LABEL[entry.resourceType]) }
|
|
|
|
|
: {}),
|
|
|
|
|
...(date ? { date, dateLabel: formatDate(date, locale) } : {}),
|
|
|
|
|
...(authors ? { authors } : {}),
|
|
|
|
|
partners: taxonomyNames(entry.partnerOrganisations),
|
|
|
|
|
topics: taxonomyNames(entry.topics),
|
|
|
|
|
...(file
|
|
|
|
|
? {
|
|
|
|
|
file,
|
2026-09-16 20:32:56 +02:00
|
|
|
fileKind: file.kind,
|
|
|
|
|
fileSizeLabel: formatFileSize(file.sizeBytes, locale),
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|
|
|
|
|
: {}),
|
2026-09-16 20:32:56 +02:00
|
|
|
...(language ? { languageLabel: language } : {}),
|
2026-09-11 02:46:56 +02:00
|
|
|
...(entry.externalUrl ? { externalUrl: entry.externalUrl } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
META
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
export function meta({ matches, loaderData }: Route.MetaArgs) {
|
|
|
|
|
const root = readRootSeo(matches);
|
|
|
|
|
const copy = RESOURCES_COPY[root.locale];
|
|
|
|
|
|
|
|
|
|
return buildMeta({
|
|
|
|
|
locale: root.locale,
|
|
|
|
|
pathname: root.pathname,
|
|
|
|
|
siteUrl: root.siteUrl,
|
|
|
|
|
title: copy.heading,
|
|
|
|
|
description: copy.metaDescription,
|
|
|
|
|
// If the CMS could not be reached the page is an empty shell, not an empty
|
|
|
|
|
// section - keep it out of the index rather than teaching a crawler that
|
|
|
|
|
// SHARP-ER has published nothing.
|
|
|
|
|
noIndex: loaderData?.available === false,
|
|
|
|
|
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
|
|
|
|
|
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
|
|
|
|
|
{ name: copy.heading, pathname: sectionPath("resources", root.locale) },
|
|
|
|
|
]),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
PAGE
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
export default function ResourcesAndPublications({
|
|
|
|
|
loaderData,
|
|
|
|
|
}: Route.ComponentProps) {
|
|
|
|
|
const { entries, pagination, filters, facets } = loaderData;
|
|
|
|
|
const { t, locale } = useTranslation();
|
|
|
|
|
const { pathname, search } = useLocation();
|
|
|
|
|
const params = new URLSearchParams(search);
|
|
|
|
|
|
|
|
|
|
const copy = RESOURCES_COPY[locale];
|
2026-09-16 20:32:56 +02:00
|
|
|
const chrome = getListingChrome(locale).resources;
|
|
|
|
|
|
|
|
|
|
const sortLabel =
|
|
|
|
|
facets.sorts.find((option) => option.value === filters.sort)?.label ?? "";
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
const isFiltered =
|
|
|
|
|
Boolean(filters.type) ||
|
|
|
|
|
Boolean(filters.topic) ||
|
|
|
|
|
Boolean(filters.author) ||
|
|
|
|
|
Boolean(filters.partner) ||
|
2026-09-16 20:32:56 +02:00
|
|
|
Boolean(filters.year) ||
|
|
|
|
|
Boolean(filters.q);
|
|
|
|
|
|
|
|
|
|
/** One facet option: sets the value, or clears it when it is already on. */
|
|
|
|
|
const facetOptions = (
|
|
|
|
|
key: string,
|
|
|
|
|
current: string,
|
|
|
|
|
options: readonly SelectOption[],
|
|
|
|
|
): FacetOption[] =>
|
|
|
|
|
options.map((option) => {
|
|
|
|
|
const isCurrent = current === option.value;
|
|
|
|
|
return {
|
|
|
|
|
value: option.value,
|
|
|
|
|
label: option.label,
|
|
|
|
|
href: `${pathname}${withParam(
|
|
|
|
|
params,
|
|
|
|
|
key,
|
|
|
|
|
isCurrent ? undefined : option.value,
|
|
|
|
|
)}`,
|
|
|
|
|
isCurrent,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* Everything except the search term, so submitting the box keeps the facets
|
|
|
|
|
the reader has already set. */
|
|
|
|
|
const keep = [...params.entries()]
|
|
|
|
|
.filter(([key]) => key !== "q" && key !== "page")
|
|
|
|
|
.map(([key, value]) => [key, value] as const);
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-09-16 20:32:56 +02:00
|
|
|
<>
|
|
|
|
|
<ListingHero
|
|
|
|
|
eyebrow={chrome.eyebrow}
|
|
|
|
|
heading={copy.heading}
|
|
|
|
|
intro={copy.intro}
|
|
|
|
|
ambient={[{ top: -250, from: "end", offset: "6%", width: 720, height: 560, durationSeconds: 22 }]}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<PageShell>
|
|
|
|
|
<div className="grid gap-2xl lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
|
|
|
|
<FacetRail label={chrome.facetsLabel}>
|
|
|
|
|
<SearchForm
|
|
|
|
|
action={pathname}
|
|
|
|
|
label={t("search.label")}
|
|
|
|
|
placeholder={chrome.searchPlaceholder}
|
|
|
|
|
defaultValue={filters.q}
|
|
|
|
|
submitLabel={t("search.submit")}
|
|
|
|
|
keep={keep}
|
|
|
|
|
className="flex-col items-stretch"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<FacetGroup
|
|
|
|
|
heading={t("filters.category")}
|
|
|
|
|
options={facetOptions("type", filters.type, facets.types)}
|
|
|
|
|
/>
|
|
|
|
|
<FacetGroup
|
|
|
|
|
heading={t("filters.topic")}
|
|
|
|
|
options={facetOptions("topic", filters.topic, facets.topics)}
|
|
|
|
|
/>
|
|
|
|
|
<FacetGroup
|
|
|
|
|
heading={t("filters.author")}
|
|
|
|
|
options={facetOptions("author", filters.author, facets.authors)}
|
|
|
|
|
/>
|
|
|
|
|
<FacetGroup
|
|
|
|
|
heading={t("filters.partner")}
|
|
|
|
|
options={facetOptions("partner", filters.partner, facets.partners)}
|
|
|
|
|
/>
|
|
|
|
|
<FacetGroup
|
|
|
|
|
heading={t("filters.date")}
|
|
|
|
|
options={facetOptions("year", filters.year, facets.years)}
|
|
|
|
|
/>
|
|
|
|
|
{/* Sorting always has a value, so its options never clear. */}
|
|
|
|
|
<FacetGroup
|
|
|
|
|
heading={t("filters.sortBy")}
|
|
|
|
|
options={facets.sorts.map((option) => ({
|
|
|
|
|
value: option.value,
|
|
|
|
|
label: option.label,
|
|
|
|
|
href: `${pathname}${withParam(params, "sort", option.value)}`,
|
|
|
|
|
isCurrent: filters.sort === option.value,
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{isFiltered || filters.sort !== "newest" ? (
|
|
|
|
|
<Button
|
|
|
|
|
as={Link}
|
|
|
|
|
to={pathname}
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="self-start"
|
|
|
|
|
>
|
2026-09-11 02:46:56 +02:00
|
|
|
{t("filters.clear")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
2026-09-16 20:32:56 +02:00
|
|
|
</FacetRail>
|
|
|
|
|
|
|
|
|
|
<section className="flex min-w-0 flex-col gap-lg" aria-label={copy.heading}>
|
|
|
|
|
<div className="flex flex-wrap items-baseline justify-between gap-md border-b border-border-subtle pb-md">
|
|
|
|
|
<div className="flex flex-wrap items-baseline gap-md">
|
|
|
|
|
<ResultSummary pagination={pagination} t={t} />
|
|
|
|
|
|
|
|
|
|
{filters.q ? (
|
|
|
|
|
<p className="body-sm text-text-secondary">
|
|
|
|
|
{t("search.resultsFor", { query: filters.q })}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* "Sorted by date · newest first" in the draft. The order in
|
|
|
|
|
force is read back from the query string, never asserted. */}
|
|
|
|
|
<p className="label-md uppercase text-text-tertiary">
|
|
|
|
|
{t("filters.sortBy")}
|
|
|
|
|
<span aria-hidden="true"> · </span>
|
|
|
|
|
{sortLabel}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{entries.length === 0 ? (
|
|
|
|
|
<EmptyState
|
|
|
|
|
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
|
|
|
|
|
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
|
|
|
|
|
>
|
|
|
|
|
{isFiltered ? (
|
|
|
|
|
<Button as={Link} to={pathname} variant="secondary" size="sm">
|
|
|
|
|
{t("filters.clear")}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</EmptyState>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="flex flex-col gap-md">
|
|
|
|
|
{entries.map((entry) => (
|
|
|
|
|
<ResourceRow
|
|
|
|
|
key={entry.id}
|
|
|
|
|
entry={entry}
|
|
|
|
|
href={`${sectionPath("resources", locale)}/${entry.slug}`}
|
|
|
|
|
readLabel={t("actions.viewDetails")}
|
|
|
|
|
downloadLabel={t("actions.download")}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Pagination
|
|
|
|
|
model={pagination}
|
|
|
|
|
pathname={pathname}
|
|
|
|
|
params={params}
|
|
|
|
|
t={t}
|
|
|
|
|
/>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
</PageShell>
|
|
|
|
|
</>
|
2026-09-11 02:46:56 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
ONE RESULT
|
|
|
|
|
Composed from the exported card primitives rather than Card.Horizontal:
|
2026-09-16 20:32:56 +02:00
|
|
|
a publication carries a metadata line (date · author · partner), a format
|
|
|
|
|
chip, a language chip and a direct download, none of which that card has a
|
|
|
|
|
slot for.
|
2026-09-11 02:46:56 +02:00
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
function ResourceRow({
|
|
|
|
|
entry,
|
|
|
|
|
href,
|
|
|
|
|
readLabel,
|
|
|
|
|
downloadLabel,
|
|
|
|
|
}: {
|
|
|
|
|
entry: ResourceListItem;
|
|
|
|
|
href: string;
|
|
|
|
|
readLabel: string;
|
|
|
|
|
downloadLabel: string;
|
|
|
|
|
}) {
|
|
|
|
|
const downloadHref = entry.file?.url ?? entry.externalUrl;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-16 20:32:56 +02:00
|
|
|
<CardShell
|
|
|
|
|
as="li"
|
|
|
|
|
linked
|
|
|
|
|
className="sh-reveal sh-reveal--in flex-col overflow-hidden sm:flex-row"
|
|
|
|
|
>
|
|
|
|
|
{/* No thumbnail: the draft anchors the row on the format chip below and
|
|
|
|
|
keeps every result the same height, whether or not the CMS entry
|
|
|
|
|
carries a cover. The thumbnail still renders on the detail page. */}
|
|
|
|
|
<div className="flex flex-1 flex-col gap-md p-lg lg:flex-row lg:items-center lg:justify-between">
|
|
|
|
|
<div className="flex min-w-0 gap-md">
|
|
|
|
|
{/* The draft's document chip. Only drawn for a file we actually
|
|
|
|
|
hold - an externally hosted document has no format to name. */}
|
|
|
|
|
{entry.fileKind ? (
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
2026-09-20 11:14:15 +02:00
|
|
|
className="flex h-14 w-11 shrink-0 items-center justify-center rounded-chip border border-border-default bg-surface-sunken label-md text-text-accent"
|
2026-09-16 20:32:56 +02:00
|
|
|
>
|
|
|
|
|
{entry.fileKind}
|
|
|
|
|
</span>
|
2026-09-11 02:46:56 +02:00
|
|
|
) : null}
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
<div className="flex min-w-0 flex-col gap-xs">
|
|
|
|
|
{entry.typeLabel ? <CardEyebrow>{entry.typeLabel}</CardEyebrow> : null}
|
|
|
|
|
|
|
|
|
|
<h2 className="sh-card-title body-lg-bold text-text-primary">
|
|
|
|
|
<CardLink href={href} className="sh-card-link">
|
|
|
|
|
{entry.title}
|
|
|
|
|
</CardLink>
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
<MetaLine
|
|
|
|
|
items={[
|
|
|
|
|
entry.authors ?? "",
|
|
|
|
|
entry.dateLabel ?? "",
|
|
|
|
|
entry.fileSizeLabel ?? "",
|
|
|
|
|
entry.partners.join(", "),
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{entry.date ? (
|
|
|
|
|
<time dateTime={toIsoDate(entry.date)} className="sr-only">
|
|
|
|
|
{entry.dateLabel}
|
|
|
|
|
</time>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{entry.summary ? (
|
|
|
|
|
<p className="body-sm text-text-secondary line-clamp-3">
|
|
|
|
|
{entry.summary}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<CardActionHint label={readLabel} />
|
|
|
|
|
</div>
|
2026-09-11 02:46:56 +02:00
|
|
|
</div>
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
<div className="flex shrink-0 flex-wrap items-center gap-md">
|
|
|
|
|
{entry.languageLabel ? (
|
|
|
|
|
<span className="rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
|
|
|
|
|
{entry.languageLabel}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
{downloadHref ? (
|
|
|
|
|
// `sh-card-above` lifts it over the stretched title link, so the
|
|
|
|
|
// download stays its own target instead of opening the detail page.
|
|
|
|
|
<Button
|
|
|
|
|
as="a"
|
|
|
|
|
href={downloadHref}
|
2026-09-16 20:32:56 +02:00
|
|
|
variant="primary"
|
2026-09-11 02:46:56 +02:00
|
|
|
size="sm"
|
|
|
|
|
className="sh-card-above"
|
|
|
|
|
{...(entry.file
|
|
|
|
|
? { download: entry.file.name }
|
|
|
|
|
: { target: "_blank", rel: "noopener noreferrer" })}
|
|
|
|
|
>
|
|
|
|
|
{downloadLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</CardShell>
|
|
|
|
|
);
|
|
|
|
|
}
|