298 lines
9.8 KiB
TypeScript
298 lines
9.8 KiB
TypeScript
/* ============================================================================
|
|||
|
|
RESOURCES AND PUBLICATIONS - one publication
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
/{locale}/resources-and-publications/{slug}
|
||
|
|
|
||
|
|
Registered in app/routes.ts as "resourcesDetail", a SIBLING of the listing
|
||
|
|
route under the ":lang" layout, and typed against its generated `./+types/`
|
||
|
|
module.
|
||
|
|
|
||
|
|
404s
|
||
|
|
A slug that does not exist is a 404, and so is a collection Strapi has not
|
||
|
|
been given yet - `fetchBySlug` maps Strapi's own 404 to null and rethrows
|
||
|
|
anything else, so an outage stays a 500 instead of being advertised to a
|
||
|
|
crawler as "this publication does not exist".
|
||
|
|
========================================================================= */
|
||
|
|
|
||
|
|
import { useLoaderData } from "react-router";
|
||
|
|
|
||
|
|
import { Button } from "~/components/ui/Button";
|
||
|
|
import {
|
||
|
|
formatDate,
|
||
|
|
formatFileSize,
|
||
|
|
sectionPath,
|
||
|
|
toIsoDate,
|
||
|
|
translate,
|
||
|
|
useTranslation,
|
||
|
|
type TranslationKey,
|
||
|
|
} from "~/i18n";
|
||
|
|
import { requireEntity } from "~/lib/strapi";
|
||
|
|
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||
|
|
|
||
|
|
import type { Route } from "./+types/resources-and-publications.$slug";
|
||
|
|
import {
|
||
|
|
collectLocalizedSlugs,
|
||
|
|
detailAlternateDescriptors,
|
||
|
|
LOCALIZATION_POPULATE,
|
||
|
|
SELF_ALTERNATES_HANDLE,
|
||
|
|
} from "./_shared/alternates";
|
||
|
|
import {
|
||
|
|
CMS,
|
||
|
|
fetchBySlug,
|
||
|
|
taxonomyNames,
|
||
|
|
toFile,
|
||
|
|
toImage,
|
||
|
|
type ResourceEntry,
|
||
|
|
type ResourceType,
|
||
|
|
} from "./_shared/cms";
|
||
|
|
import { readRootSeo, requireLocale, withParam } from "./_shared/listing";
|
||
|
|
import {
|
||
|
|
BackLink,
|
||
|
|
MetaList,
|
||
|
|
MetaLine,
|
||
|
|
PageHeader,
|
||
|
|
PageShell,
|
||
|
|
TopicChips,
|
||
|
|
type MetaItem,
|
||
|
|
} from "./_shared/listing-ui";
|
||
|
|
import { DETAIL_COPY, RESOURCES_COPY } from "./_shared/page-copy";
|
||
|
|
|
||
|
|
const TYPE_LABEL: Readonly<Record<ResourceType, TranslationKey>> = {
|
||
|
|
"research-perception": "resources.categoryResearch",
|
||
|
|
"policy-article": "resources.categoryPolicy",
|
||
|
|
"project-report": "resources.categoryReports",
|
||
|
|
"visibility-factsheet": "resources.categoryVisibility",
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A Strapi 5 localisation carries its own slug, so this page emits its own
|
||
|
|
* canonical + hreflang instead of letting the shell swap the locale prefix.
|
||
|
|
*/
|
||
|
|
export const handle = SELF_ALTERNATES_HANDLE;
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
LOADER
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
||
|
|
const locale = requireLocale(params.lang);
|
||
|
|
const slug = params.slug ?? "";
|
||
|
|
|
||
|
|
const entry = requireEntity(
|
||
|
|
await fetchBySlug<ResourceEntry>(CMS.resources, slug, {
|
||
|
|
locale,
|
||
|
|
populate: {
|
||
|
|
thumbnail: {
|
||
|
|
fields: ["url", "alternativeText", "width", "height", "formats"],
|
||
|
|
},
|
||
|
|
file: { fields: ["url", "name", "ext", "mime", "size"] },
|
||
|
|
partnerOrganisations: { fields: ["name", "slug"] },
|
||
|
|
topics: { fields: ["name", "slug"] },
|
||
|
|
localizations: LOCALIZATION_POPULATE,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
|
||
|
|
const file = toFile(entry.file);
|
||
|
|
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
|
||
|
|
const cover = toImage(entry.thumbnail, "large");
|
||
|
|
|
||
|
|
return {
|
||
|
|
locale,
|
||
|
|
slug: entry.slug,
|
||
|
|
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
|
||
|
|
title: entry.title,
|
||
|
|
summary: entry.description ?? undefined,
|
||
|
|
typeLabel: entry.resourceType
|
||
|
|
? translate(locale, TYPE_LABEL[entry.resourceType])
|
||
|
|
: undefined,
|
||
|
|
date,
|
||
|
|
dateIso: toIsoDate(date),
|
||
|
|
dateLabel: date ? formatDate(date, locale) : undefined,
|
||
|
|
modifiedIso: toIsoDate(entry.updatedAt),
|
||
|
|
/** Free text in the CMS, rendered as the editor wrote it. */
|
||
|
|
authors: entry.authors?.trim() || undefined,
|
||
|
|
partners: taxonomyNames(entry.partnerOrganisations),
|
||
|
|
topics: (entry.topics ?? [])
|
||
|
|
.filter((topic) => Boolean(topic?.slug && topic?.name))
|
||
|
|
.map((topic) => ({ label: topic.name, slug: topic.slug })),
|
||
|
|
cover,
|
||
|
|
file: file
|
||
|
|
? { ...file, label: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}` }
|
||
|
|
: undefined,
|
||
|
|
externalUrl: entry.externalUrl ?? undefined,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
META
|
||
|
|
The shell's prefix-swapped canonical + hreflang is suppressed for this
|
||
|
|
route by `handle = SELF_ALTERNATES_HANDLE`; the correct pair is appended
|
||
|
|
below from the counterpart's real slug.
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
export function meta({ matches, loaderData }: Route.MetaArgs) {
|
||
|
|
const root = readRootSeo(matches);
|
||
|
|
const entry = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
|
||
|
|
const fallback = RESOURCES_COPY[root.locale];
|
||
|
|
|
||
|
|
if (!entry) {
|
||
|
|
return buildMeta({
|
||
|
|
locale: root.locale,
|
||
|
|
pathname: root.pathname,
|
||
|
|
siteUrl: root.siteUrl,
|
||
|
|
title: fallback.heading,
|
||
|
|
description: fallback.metaDescription,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const description = entry.summary ?? fallback.metaDescription;
|
||
|
|
|
||
|
|
const descriptors = buildMeta({
|
||
|
|
locale: root.locale,
|
||
|
|
pathname: root.pathname,
|
||
|
|
siteUrl: root.siteUrl,
|
||
|
|
title: entry.title,
|
||
|
|
description,
|
||
|
|
type: "article",
|
||
|
|
...(entry.cover
|
||
|
|
? { image: { url: entry.cover.src, alt: entry.cover.alt } }
|
||
|
|
: {}),
|
||
|
|
...(entry.dateIso
|
||
|
|
? {
|
||
|
|
article: {
|
||
|
|
publishedTime: entry.dateIso,
|
||
|
|
...(entry.modifiedIso ? { modifiedTime: entry.modifiedIso } : {}),
|
||
|
|
...(entry.authors ? { authors: [entry.authors] } : {}),
|
||
|
|
...(entry.typeLabel ? { section: entry.typeLabel } : {}),
|
||
|
|
tags: entry.topics.map((topic) => topic.label),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
: {}),
|
||
|
|
jsonLd: [
|
||
|
|
buildBreadcrumbJsonLd(root.siteUrl, [
|
||
|
|
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
|
||
|
|
{ name: fallback.heading, pathname: sectionPath("resources", root.locale) },
|
||
|
|
{ name: entry.title, pathname: root.pathname },
|
||
|
|
]),
|
||
|
|
...(entry.dateIso
|
||
|
|
? [
|
||
|
|
buildArticleJsonLd({
|
||
|
|
siteUrl: root.siteUrl,
|
||
|
|
locale: root.locale,
|
||
|
|
headline: entry.title,
|
||
|
|
pathname: root.pathname,
|
||
|
|
datePublished: entry.dateIso,
|
||
|
|
...(entry.modifiedIso ? { dateModified: entry.modifiedIso } : {}),
|
||
|
|
...(entry.summary ? { description: entry.summary } : {}),
|
||
|
|
...(entry.cover ? { image: entry.cover.src } : {}),
|
||
|
|
...(entry.authors ? { authorName: entry.authors } : {}),
|
||
|
|
...(entry.typeLabel ? { section: entry.typeLabel } : {}),
|
||
|
|
keywords: entry.topics.map((topic) => topic.label),
|
||
|
|
}),
|
||
|
|
]
|
||
|
|
: []),
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
return [
|
||
|
|
...descriptors,
|
||
|
|
...detailAlternateDescriptors({
|
||
|
|
siteUrl: root.siteUrl,
|
||
|
|
section: "resources",
|
||
|
|
locale: root.locale,
|
||
|
|
slugs: entry.alternates,
|
||
|
|
}),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
PAGE
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
export default function ResourceDetail() {
|
||
|
|
const entry = useLoaderData<typeof loader>();
|
||
|
|
const { t, locale } = useTranslation();
|
||
|
|
|
||
|
|
const listingPath = sectionPath("resources", locale);
|
||
|
|
const detail = DETAIL_COPY[locale];
|
||
|
|
const downloadHref = entry.file?.url ?? entry.externalUrl;
|
||
|
|
|
||
|
|
const metaItems: MetaItem[] = [
|
||
|
|
...(entry.dateLabel
|
||
|
|
? [
|
||
|
|
{
|
||
|
|
label: detail.published,
|
||
|
|
value: <time dateTime={entry.dateIso ?? entry.date}>{entry.dateLabel}</time>,
|
||
|
|
},
|
||
|
|
]
|
||
|
|
: []),
|
||
|
|
...(entry.authors ? [{ label: detail.author, value: entry.authors }] : []),
|
||
|
|
...(entry.partners.length > 0
|
||
|
|
? [{ label: detail.partner, value: entry.partners.join(", ") }]
|
||
|
|
: []),
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<PageShell>
|
||
|
|
<BackLink to={listingPath} label={detail.backToListing} />
|
||
|
|
|
||
|
|
<article className="flex flex-col gap-2xl">
|
||
|
|
<PageHeader
|
||
|
|
eyebrow={entry.typeLabel}
|
||
|
|
heading={entry.title}
|
||
|
|
intro={entry.summary}
|
||
|
|
>
|
||
|
|
<MetaLine items={[entry.dateLabel ?? "", entry.authors ?? ""]} />
|
||
|
|
</PageHeader>
|
||
|
|
|
||
|
|
{entry.cover ? (
|
||
|
|
<img
|
||
|
|
src={entry.cover.src}
|
||
|
|
alt={entry.cover.alt}
|
||
|
|
width={entry.cover.width}
|
||
|
|
height={entry.cover.height}
|
||
|
|
decoding="async"
|
||
|
|
className="w-full rounded-card border border-border-subtle object-cover"
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<div className="grid gap-2xl lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
|
||
|
|
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-lg">
|
||
|
|
{/* A publication has no rich-text body in the CMS - it IS the
|
||
|
|
document. `description` is the standfirst above. */}
|
||
|
|
{downloadHref ? (
|
||
|
|
<div className="flex flex-wrap items-center gap-md">
|
||
|
|
<Button
|
||
|
|
as="a"
|
||
|
|
href={downloadHref}
|
||
|
|
variant="primary"
|
||
|
|
{...(entry.file
|
||
|
|
? { download: entry.file.name }
|
||
|
|
: { target: "_blank", rel: "noopener noreferrer" })}
|
||
|
|
>
|
||
|
|
{entry.file ? t("actions.download") : detail.openExternal}
|
||
|
|
</Button>
|
||
|
|
{entry.file ? (
|
||
|
|
<span className="body-sm text-text-tertiary">{entry.file.label}</span>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<aside className="flex flex-col gap-lg">
|
||
|
|
<MetaList heading={detail.aboutThis} items={metaItems} />
|
||
|
|
<TopicChips
|
||
|
|
heading={detail.topics}
|
||
|
|
topics={entry.topics.map((topic) => ({
|
||
|
|
label: topic.label,
|
||
|
|
href: `${listingPath}${withParam(new URLSearchParams(), "topic", topic.slug)}`,
|
||
|
|
}))}
|
||
|
|
/>
|
||
|
|
</aside>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
</PageShell>
|
||
|
|
);
|
||
|
|
}
|