/* ============================================================================ 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 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. 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. 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. The canonical URL and the en/ar/x-default hreflang set are emitted for this 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 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, toIsoDate, 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, parseQuery, parseSlug, parseSort, parseYear, readRootSeo, SORT_ORDERS, requireLocale, sortFor, withParam, yearFilter, yearOptions, type SelectOption, type SortOrder, } from "./_shared/listing"; import { EmptyState, FacetGroup, FacetRail, ListingHero, MetaLine, PageShell, Pagination, ResultSummary, SearchForm, type FacetOption, } from "./_shared/listing-ui"; import { RESOURCES_COPY, getListingChrome, resourceLanguageLabel, } from "./_shared/page-copy"; import "./_page-kit/motion.css"; /** * 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 file?: CmsFile; readonly fileKind?: string; readonly fileSizeLabel?: string; readonly languageLabel?: 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 query = parseQuery(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, /* 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, }); // 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: { // No thumbnail: the listing row draws the format chip instead of a // cover, so the image is fetched by the detail route, not here. 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, q: query ?? "", }, 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 date = entry.publicationDate ?? entry.publishedAt ?? undefined; const authors = entry.authors?.trim(); const language = resourceLanguageLabel(entry.resourceLanguage, locale); 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, fileKind: file.kind, fileSizeLabel: formatFileSize(file.sizeBytes, locale), } : {}), ...(language ? { languageLabel: language } : {}), ...(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 chrome = getListingChrome(locale).resources; const sortLabel = facets.sorts.find((option) => option.value === filters.sort)?.label ?? ""; const isFiltered = Boolean(filters.type) || Boolean(filters.topic) || Boolean(filters.author) || Boolean(filters.partner) || 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); return ( <>
{/* Sorting always has a value, so its options never clear. */} ({ value: option.value, label: option.label, href: `${pathname}${withParam(params, "sort", option.value)}`, isCurrent: filters.sort === option.value, }))} /> {isFiltered || filters.sort !== "newest" ? ( ) : null}
{filters.q ? (

{t("search.resultsFor", { query: filters.q })}

) : null}
{/* "Sorted by date · newest first" in the draft. The order in force is read back from the query string, never asserted. */}

{t("filters.sortBy")} {sortLabel}

{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), a format chip, a language chip and a direct download, none 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 ( {/* 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. */}
{/* The draft's document chip. Only drawn for a file we actually hold - an externally hosted document has no format to name. */} {entry.fileKind ? ( ) : null}
{entry.typeLabel ? {entry.typeLabel} : null}

{entry.title}

{entry.date ? ( ) : null} {entry.summary ? (

{entry.summary}

) : null}
{entry.languageLabel ? ( {entry.languageLabel} ) : 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}
); }