/* ============================================================================ 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 `
`, 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> = { "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>; /** 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(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>, }; } 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 (
{/* No "All" option: sorting always has a value. */} {entries.length === 0 ? ( {isFiltered ? ( ) : null} ) : (
    {entries.map((entry) => ( ))}
)}
); } /* --------------------------------------------------------------------------- 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 ( {entry.image ? ( ) : null}
{entry.typeLabel ? {entry.typeLabel} : null}

{entry.title}

{entry.summary ? (

{entry.summary}

) : null}
{downloadHref ? ( // `sh-card-above` lifts it over the stretched title link, so the // download stays its own target instead of opening the detail page. ) : null}
); }