Files
sharper.ly/apps/web/app/routes/resources-and-publications.tsx
T

458 lines
15 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
RESOURCES AND PUBLICATIONS - listing
----------------------------------------------------------------------------
/{locale}/resources-and-publications
§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 tabs the draft lists and a sort order.
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.
FILTER STATE LIVES IN THE URL
Every control is a field of one `<form method="get">`, so a filtered view is
an ordinary link: ?topic=access-to-justice&year=2026&sort=oldest. It can be
shared, bookmarked, walked back with the browser's Back button and followed
by a crawler, and it renders identically on the server and the client
because nothing about it is component state.
The canonical URL and the en/ar/x-default hreflang set are emitted for this
route by the app shell (root.tsx `Layout`) from the pathname, 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
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,
CardMedia,
CardShell,
} from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
import {
formatDate,
formatFileSize,
sectionPath,
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,
toImage,
type CmsFile,
type CmsImage,
type ResourceEntry,
type ResourceType,
} from "./_shared/cms";
import {
PAGE_SIZE,
buildPagination,
compactFilters,
enumOptions,
parseExactText,
parseOptionalEnum,
parsePage,
parseSlug,
parseSort,
parseYear,
readRootSeo,
SORT_ORDERS,
requireLocale,
sortFor,
yearFilter,
yearOptions,
type SelectOption,
type SortOrder,
} from "./_shared/listing";
import {
EmptyState,
FilterBar,
FilterSelect,
MetaLine,
PageHeader,
PageShell,
Pagination,
ResultSummary,
} from "./_shared/listing-ui";
import { RESOURCES_COPY } from "./_shared/page-copy";
/**
* 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 image?: CmsImage;
readonly file?: CmsFile;
readonly fileLabel?: string;
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);
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,
});
// 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: {
thumbnail: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
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,
},
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 image = toImage(entry.thumbnail, "small");
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
const authors = entry.authors?.trim();
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),
...(image ? { image } : {}),
...(file
? {
file,
fileLabel: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}`,
}
: {}),
...(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];
const isFiltered =
Boolean(filters.type) ||
Boolean(filters.topic) ||
Boolean(filters.author) ||
Boolean(filters.partner) ||
Boolean(filters.year);
return (
<PageShell>
<PageHeader heading={copy.heading} intro={copy.intro} />
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
<FilterBar
legend={t("filters.heading")}
action={pathname}
applyLabel={t("filters.apply")}
clearLabel={t("filters.clear")}
clearHref={pathname}
showClear={isFiltered || filters.sort !== "newest"}
>
<FilterSelect
name="type"
label={t("filters.category")}
value={filters.type}
options={facets.types}
allLabel={t("filters.all")}
/>
<FilterSelect
name="topic"
label={t("filters.topic")}
value={filters.topic}
options={facets.topics}
allLabel={t("filters.all")}
/>
<FilterSelect
name="author"
label={t("filters.author")}
value={filters.author}
options={facets.authors}
allLabel={t("filters.all")}
/>
<FilterSelect
name="partner"
label={t("filters.partner")}
value={filters.partner}
options={facets.partners}
allLabel={t("filters.all")}
/>
<FilterSelect
name="year"
label={t("filters.date")}
value={filters.year}
options={facets.years}
allLabel={t("filters.all")}
/>
{/* No "All" option: sorting always has a value. */}
<FilterSelect
name="sort"
label={t("filters.sortBy")}
value={filters.sort}
options={facets.sorts}
/>
</FilterBar>
<ResultSummary pagination={pagination} t={t} />
{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-lg">
{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>
</PageShell>
);
}
/* ---------------------------------------------------------------------------
ONE RESULT
Composed from the exported card primitives rather than Card.Horizontal:
a publication carries a metadata line (date · author · partner · PDF · size)
and a direct download, neither of which that card has a slot for.
------------------------------------------------------------------------ */
function ResourceRow({
entry,
href,
readLabel,
downloadLabel,
}: {
entry: ResourceListItem;
href: string;
readLabel: string;
downloadLabel: string;
}) {
const downloadHref = entry.file?.url ?? entry.externalUrl;
return (
<CardShell as="li" linked className="flex-col overflow-hidden sm:flex-row">
{entry.image ? (
<CardMedia
image={entry.image}
className="aspect-[6/5] w-full sm:aspect-auto sm:w-60 sm:shrink-0"
/>
) : null}
<div className="flex flex-1 flex-col gap-md p-lg">
<div className="flex 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.dateLabel ?? "",
entry.authors ?? "",
entry.partners.join(", "),
entry.fileLabel ?? "",
]}
/>
{entry.summary ? (
<p className="body-sm text-text-secondary line-clamp-3">{entry.summary}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-md">
<CardActionHint label={readLabel} />
{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}
variant="secondary"
size="sm"
className="sh-card-above"
{...(entry.file
? { download: entry.file.name }
: { target: "_blank", rel: "noopener noreferrer" })}
>
{downloadLabel}
</Button>
) : null}
</div>
</div>
</CardShell>
);
}