initial commit
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
/* ============================================================================
|
||||
NEWS AND UPDATES - listing
|
||||
----------------------------------------------------------------------------
|
||||
/{locale}/news-and-updates
|
||||
|
||||
§5 of docs/SHARPER_Website_Draft_Section_EN_AR.docx. Two streams, exactly as
|
||||
the draft describes them:
|
||||
|
||||
Tab 1 SHARP-ER News ?tab=sharper-news official project updates
|
||||
Tab 2 From the Network ?tab=from-the-network updates submitted by CSOs
|
||||
granted as part of the consortium,
|
||||
each card crediting the
|
||||
organisation
|
||||
|
||||
The tab values are the `news-article.category` enumeration in the CMS,
|
||||
verbatim, so the query string and the Strapi filter cannot drift apart.
|
||||
|
||||
The tabs are LINKS, not client-side state: each stream is its own URL, so it
|
||||
can be shared, bookmarked and crawled, and the server renders exactly what
|
||||
the address asks for. `aria-current="page"` marks the active one.
|
||||
========================================================================= */
|
||||
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { formatDate, sectionPath, useTranslation, type Locale } from "~/i18n";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
import type { Route } from "./+types/news-and-updates";
|
||||
import {
|
||||
CMS,
|
||||
NEWS_CATEGORIES,
|
||||
fetchCollection,
|
||||
toImage,
|
||||
type CmsImage,
|
||||
type NewsCategory,
|
||||
type NewsEntry,
|
||||
} from "./_shared/cms";
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
buildPagination,
|
||||
compactFilters,
|
||||
parseEnum,
|
||||
parsePage,
|
||||
readRootSeo,
|
||||
requireLocale,
|
||||
withParam,
|
||||
} from "./_shared/listing";
|
||||
import {
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
PageShell,
|
||||
Pagination,
|
||||
ResultSummary,
|
||||
TabLinks,
|
||||
type TabLink,
|
||||
} from "./_shared/listing-ui";
|
||||
import { NEWS_COPY } from "./_shared/page-copy";
|
||||
|
||||
/** Tab 1 is the default stream, matching the order of the draft. */
|
||||
const DEFAULT_TAB: NewsCategory = "sharper-news";
|
||||
|
||||
/** `news-article.publishDate` - required in the CMS, so it always sorts. */
|
||||
const DATE_FIELD = "publishDate";
|
||||
|
||||
interface NewsListItem {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly excerpt?: string;
|
||||
readonly organisation?: string;
|
||||
readonly dateLabel?: string;
|
||||
readonly image?: CmsImage;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
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 tab = parseEnum(search, "tab", NEWS_CATEGORIES, DEFAULT_TAB);
|
||||
|
||||
const listing = await fetchCollection<NewsEntry>(CMS.newsArticles, {
|
||||
locale,
|
||||
filters: compactFilters({ category: { $eq: tab } }),
|
||||
sort: [`${DATE_FIELD}:desc`, "title:asc"],
|
||||
populate: {
|
||||
coverImage: {
|
||||
fields: ["url", "alternativeText", "width", "height", "formats"],
|
||||
},
|
||||
// A relation, not a string: without this the byline is simply absent.
|
||||
authorOrganisation: { fields: ["name", "slug"] },
|
||||
},
|
||||
pagination: { page, pageSize: PAGE_SIZE, withCount: true },
|
||||
});
|
||||
|
||||
const entries: NewsListItem[] = listing.entries.map((entry) =>
|
||||
toListItem(entry, locale),
|
||||
);
|
||||
|
||||
return {
|
||||
entries,
|
||||
tab,
|
||||
pagination: buildPagination(page, PAGE_SIZE, listing.pagination.total),
|
||||
available: listing.available,
|
||||
};
|
||||
}
|
||||
|
||||
function toListItem(entry: NewsEntry, locale: Locale): NewsListItem {
|
||||
const image = toImage(entry.coverImage, "small");
|
||||
const date = entry.publishDate ?? entry.publishedAt ?? undefined;
|
||||
const organisation = entry.authorOrganisation?.name;
|
||||
|
||||
return {
|
||||
id: entry.documentId,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
...(entry.excerpt ? { excerpt: entry.excerpt } : {}),
|
||||
...(organisation ? { organisation } : {}),
|
||||
...(date ? { dateLabel: formatDate(date, locale) } : {}),
|
||||
...(image ? { image } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
META
|
||||
The canonical URL and the en/ar/x-default hreflang set are emitted for every
|
||||
route by the shell (root.tsx `Layout`), so `alternates` stays off here -
|
||||
passing it from a leaf would emit each of those links twice.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export function meta({ matches, loaderData }: Route.MetaArgs) {
|
||||
const root = readRootSeo(matches);
|
||||
const copy = NEWS_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("news", root.locale) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
PAGE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export default function NewsAndUpdates({ loaderData }: Route.ComponentProps) {
|
||||
const { entries, pagination, tab } = loaderData;
|
||||
const { t, locale } = useTranslation();
|
||||
const { pathname, search } = useLocation();
|
||||
const params = new URLSearchParams(search);
|
||||
|
||||
const copy = NEWS_COPY[locale];
|
||||
const empty = new URLSearchParams();
|
||||
|
||||
const tabs: TabLink[] = [
|
||||
{
|
||||
id: "sharper-news",
|
||||
label: t("news.tabProject"),
|
||||
href: `${pathname}${withParam(empty, "tab", "sharper-news")}`,
|
||||
isCurrent: tab === "sharper-news",
|
||||
},
|
||||
{
|
||||
id: "from-the-network",
|
||||
label: t("news.tabNetwork"),
|
||||
href: `${pathname}${withParam(empty, "tab", "from-the-network")}`,
|
||||
isCurrent: tab === "from-the-network",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader heading={copy.heading} intro={copy.intro} />
|
||||
|
||||
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
|
||||
<TabLinks label={t("nav.sections.news")} tabs={tabs} />
|
||||
|
||||
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
|
||||
{tab === "from-the-network" ? copy.networkBlurb : copy.projectBlurb}
|
||||
</p>
|
||||
|
||||
<ResultSummary pagination={pagination} t={t} />
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState title={copy.emptyTitle} body={copy.emptyBody}>
|
||||
{tab === "from-the-network" ? (
|
||||
<Button
|
||||
as={Link}
|
||||
to={sectionPath("contact", locale)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{t("actions.submitYourUpdate")}
|
||||
</Button>
|
||||
) : null}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{entries.map((entry) => (
|
||||
<Card.Content
|
||||
key={entry.id}
|
||||
as="li"
|
||||
href={`${sectionPath("news", locale)}/${entry.slug}`}
|
||||
title={entry.title}
|
||||
actionLabel={t("actions.readMore")}
|
||||
eyebrow={
|
||||
entry.organisation
|
||||
? t("news.postedBy", { organisation: entry.organisation })
|
||||
: entry.dateLabel
|
||||
}
|
||||
{...(entry.excerpt ? { excerpt: entry.excerpt } : {})}
|
||||
{...(entry.dateLabel && entry.organisation
|
||||
? { badge: entry.dateLabel }
|
||||
: {})}
|
||||
{...(entry.image ? { image: entry.image } : {})}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
|
||||
</section>
|
||||
|
||||
{/* "Are you a partner organisation? Submit your update ->" - §5 of the
|
||||
draft. It points at Contact, which is where a submission is handled
|
||||
until the CMS grows a submission form. */}
|
||||
<Card.CTA
|
||||
eyebrow={t("news.tabNetwork")}
|
||||
headline={t("news.partnerPrompt")}
|
||||
body={copy.networkBlurb}
|
||||
action={{
|
||||
href: sectionPath("contact", locale),
|
||||
label: t("actions.submitYourUpdate"),
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user