initial commit
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
/* ============================================================================
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user