2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
NEWS AND UPDATES - listing
|
|
|
|
|
----------------------------------------------------------------------------
|
|
|
|
|
/{locale}/news-and-updates
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
§5 of docs/SHARPER_Website_Draft_Section_EN_AR.docx, laid out to the 2026
|
|
|
|
|
redesign draft ("News-and-Updates"): an ambient hero, the two-stream tab
|
|
|
|
|
strip, one wide lead card over a three-up grid, and the partner callout.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
Tab 1 SHARP-ER News ?tab=sharper-news official project updates
|
2026-09-11 02:46:56 +02:00
|
|
|
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.
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
NOTHING FROM THE DRAFT'S SAMPLE FEED IS HERE. Every headline, date, tag,
|
|
|
|
|
excerpt and byline it shows - the launch event, the Misrata sub-project, the
|
|
|
|
|
fact sheet, "30 JUN 2026" - was sample content written for the mockup. Each
|
|
|
|
|
card is built from a Strapi entry or the localised empty state renders
|
|
|
|
|
instead (CLAUDE.md rule 5).
|
2026-09-11 02:46:56 +02:00
|
|
|
========================================================================= */
|
|
|
|
|
|
|
|
|
|
import { Link, useLocation } from "react-router";
|
|
|
|
|
|
|
|
|
|
import { Button } from "~/components/ui/Button";
|
2026-09-16 20:32:56 +02:00
|
|
|
import {
|
|
|
|
|
CardActionHint,
|
|
|
|
|
CardLink,
|
|
|
|
|
CardMedia,
|
|
|
|
|
CardShell,
|
|
|
|
|
} from "~/components/ui/Card";
|
|
|
|
|
import {
|
|
|
|
|
formatDate,
|
|
|
|
|
sectionPath,
|
|
|
|
|
toIsoDate,
|
|
|
|
|
useTranslation,
|
|
|
|
|
type Locale,
|
|
|
|
|
} from "~/i18n";
|
2026-09-11 02:46:56 +02:00
|
|
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
|
|
|
|
|
|
|
|
|
import type { Route } from "./+types/news-and-updates";
|
|
|
|
|
import {
|
|
|
|
|
CMS,
|
|
|
|
|
NEWS_CATEGORIES,
|
|
|
|
|
fetchCollection,
|
2026-09-16 20:32:56 +02:00
|
|
|
taxonomyNames,
|
2026-09-11 02:46:56 +02:00
|
|
|
toImage,
|
|
|
|
|
type CmsImage,
|
|
|
|
|
type NewsCategory,
|
|
|
|
|
type NewsEntry,
|
|
|
|
|
} from "./_shared/cms";
|
|
|
|
|
import {
|
|
|
|
|
PAGE_SIZE,
|
|
|
|
|
buildPagination,
|
|
|
|
|
compactFilters,
|
|
|
|
|
parseEnum,
|
|
|
|
|
parsePage,
|
|
|
|
|
readRootSeo,
|
|
|
|
|
requireLocale,
|
|
|
|
|
withParam,
|
|
|
|
|
} from "./_shared/listing";
|
|
|
|
|
import {
|
2026-09-16 20:32:56 +02:00
|
|
|
CalloutBand,
|
2026-09-11 02:46:56 +02:00
|
|
|
EmptyState,
|
2026-09-16 20:32:56 +02:00
|
|
|
ListingHero,
|
2026-09-11 02:46:56 +02:00
|
|
|
PageShell,
|
|
|
|
|
Pagination,
|
|
|
|
|
ResultSummary,
|
|
|
|
|
TabLinks,
|
|
|
|
|
type TabLink,
|
|
|
|
|
} from "./_shared/listing-ui";
|
2026-09-16 20:32:56 +02:00
|
|
|
import { NEWS_COPY, getListingChrome } from "./_shared/page-copy";
|
|
|
|
|
|
|
|
|
|
import "./_page-kit/motion.css";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
/** 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;
|
2026-09-16 20:32:56 +02:00
|
|
|
readonly date?: string;
|
2026-09-11 02:46:56 +02:00
|
|
|
readonly dateLabel?: string;
|
2026-09-16 20:32:56 +02:00
|
|
|
/** First topic of the entry - the draft's coloured chip. From the CMS only. */
|
|
|
|
|
readonly tag?: string;
|
2026-09-11 02:46:56 +02:00
|
|
|
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"] },
|
2026-09-16 20:32:56 +02:00
|
|
|
// Drives the draft's chip above the headline. Nothing is invented: a
|
|
|
|
|
// card with no topic simply has no chip.
|
|
|
|
|
topics: { fields: ["name", "slug"] },
|
2026-09-11 02:46:56 +02:00
|
|
|
},
|
|
|
|
|
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 {
|
2026-09-16 20:32:56 +02:00
|
|
|
const image = toImage(entry.coverImage, "medium");
|
2026-09-11 02:46:56 +02:00
|
|
|
const date = entry.publishDate ?? entry.publishedAt ?? undefined;
|
|
|
|
|
const organisation = entry.authorOrganisation?.name;
|
2026-09-16 20:32:56 +02:00
|
|
|
const tag = taxonomyNames(entry.topics)[0];
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: entry.documentId,
|
|
|
|
|
slug: entry.slug,
|
|
|
|
|
title: entry.title,
|
|
|
|
|
...(entry.excerpt ? { excerpt: entry.excerpt } : {}),
|
|
|
|
|
...(organisation ? { organisation } : {}),
|
2026-09-16 20:32:56 +02:00
|
|
|
...(date ? { date, dateLabel: formatDate(date, locale) } : {}),
|
|
|
|
|
...(tag ? { tag } : {}),
|
2026-09-11 02:46:56 +02:00
|
|
|
...(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];
|
2026-09-16 20:32:56 +02:00
|
|
|
const chrome = getListingChrome(locale).news;
|
2026-09-11 02:46:56 +02:00
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
/* The draft leads the page with one wide card. It is the newest entry of the
|
|
|
|
|
stream, and only on page 1 - page 4 of an archive has no "lead story". */
|
|
|
|
|
const isFirstPage = pagination.page === 1;
|
|
|
|
|
const [lead, ...rest] = entries;
|
|
|
|
|
const featured = isFirstPage ? lead : undefined;
|
|
|
|
|
const grid = featured ? rest : entries;
|
|
|
|
|
|
|
|
|
|
const hrefOf = (entry: NewsListItem) =>
|
|
|
|
|
`${sectionPath("news", locale)}/${entry.slug}`;
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
return (
|
2026-09-16 20:32:56 +02:00
|
|
|
<>
|
|
|
|
|
<ListingHero
|
|
|
|
|
eyebrow={chrome.eyebrow}
|
|
|
|
|
heading={copy.heading}
|
|
|
|
|
intro={copy.intro}
|
|
|
|
|
ambient={[{ top: -250, from: "start", offset: "8%", width: 720, height: 560, durationSeconds: 20 }]}
|
2026-09-11 02:46:56 +02:00
|
|
|
/>
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
<PageShell>
|
|
|
|
|
<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>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{featured ? (
|
|
|
|
|
<>
|
|
|
|
|
<h2 className="sr-only">{chrome.featuredLabel}</h2>
|
|
|
|
|
<FeaturedNewsCard
|
|
|
|
|
entry={featured}
|
|
|
|
|
href={hrefOf(featured)}
|
|
|
|
|
actionLabel={chrome.readFull}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{grid.length > 0 ? (
|
|
|
|
|
<>
|
|
|
|
|
<h2 className="sr-only">{chrome.moreLabel}</h2>
|
|
|
|
|
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
|
|
|
|
{grid.map((entry) => (
|
|
|
|
|
<NewsCard
|
|
|
|
|
key={entry.id}
|
|
|
|
|
entry={entry}
|
|
|
|
|
href={hrefOf(entry)}
|
|
|
|
|
creditLabel={
|
|
|
|
|
entry.organisation
|
|
|
|
|
? t("news.postedBy", {
|
|
|
|
|
organisation: entry.organisation,
|
|
|
|
|
})
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<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. */}
|
|
|
|
|
<CalloutBand
|
|
|
|
|
eyebrow={t("news.tabNetwork")}
|
|
|
|
|
headline={t("news.partnerPrompt")}
|
|
|
|
|
body={copy.networkBlurb}
|
|
|
|
|
action={{
|
|
|
|
|
to: sectionPath("contact", locale),
|
|
|
|
|
label: t("actions.submitYourUpdate"),
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</PageShell>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
THE TWO CARDS
|
|
|
|
|
Composed from the exported card primitives rather than Card.Content: the
|
|
|
|
|
draft's newsroom cards carry a chip-and-date row above the headline and a
|
|
|
|
|
credit line pinned to the foot, neither of which that card has a slot for.
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
/** The chip-and-date row. Both halves are optional and both come from Strapi. */
|
|
|
|
|
function NewsMetaRow({ entry }: { entry: NewsListItem }) {
|
|
|
|
|
if (!entry.tag && !entry.dateLabel) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-wrap items-center gap-sm">
|
|
|
|
|
{entry.tag ? (
|
2026-09-20 11:14:15 +02:00
|
|
|
<span className="rounded-chip bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent">
|
2026-09-16 20:32:56 +02:00
|
|
|
{entry.tag}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{entry.dateLabel ? (
|
|
|
|
|
<time
|
|
|
|
|
dateTime={toIsoDate(entry.date)}
|
|
|
|
|
className="label-md text-text-tertiary"
|
|
|
|
|
>
|
|
|
|
|
{entry.dateLabel}
|
|
|
|
|
</time>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The wide lead card: picture beside the story, stacked on a phone. */
|
|
|
|
|
function FeaturedNewsCard({
|
|
|
|
|
entry,
|
|
|
|
|
href,
|
|
|
|
|
actionLabel,
|
|
|
|
|
}: {
|
|
|
|
|
entry: NewsListItem;
|
|
|
|
|
href: string;
|
|
|
|
|
actionLabel: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<CardShell
|
|
|
|
|
linked
|
|
|
|
|
className="sh-reveal sh-reveal--in flex-col overflow-hidden md:flex-row"
|
|
|
|
|
>
|
|
|
|
|
{entry.image ? (
|
|
|
|
|
<div className="relative md:w-1/2 md:shrink-0">
|
|
|
|
|
<CardMedia image={entry.image} className="aspect-[16/10] w-full md:h-full" />
|
|
|
|
|
{/* The highlight travelling across the picture - the draft's sheen. */}
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="sh-sheen pointer-events-none absolute inset-0 bg-[linear-gradient(100deg,transparent_35%,var(--overlay-veil)_50%,transparent_65%)]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-1 flex-col justify-center gap-md p-xl">
|
|
|
|
|
<NewsMetaRow entry={entry} />
|
|
|
|
|
|
|
|
|
|
<h3 className="sh-card-title heading-h3 text-text-primary">
|
|
|
|
|
<CardLink href={href} className="sh-card-link">
|
|
|
|
|
{entry.title}
|
|
|
|
|
</CardLink>
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
{entry.excerpt ? (
|
|
|
|
|
<p className="body-md text-text-secondary">{entry.excerpt}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<CardActionHint label={actionLabel} />
|
|
|
|
|
</div>
|
|
|
|
|
</CardShell>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One card of the three-up grid. */
|
|
|
|
|
function NewsCard({
|
|
|
|
|
entry,
|
|
|
|
|
href,
|
|
|
|
|
creditLabel,
|
|
|
|
|
}: {
|
|
|
|
|
entry: NewsListItem;
|
|
|
|
|
href: string;
|
|
|
|
|
creditLabel?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<CardShell
|
|
|
|
|
as="li"
|
|
|
|
|
linked
|
|
|
|
|
className="sh-reveal sh-reveal--in flex-col overflow-hidden"
|
|
|
|
|
>
|
|
|
|
|
{entry.image ? (
|
|
|
|
|
<CardMedia image={entry.image} className="aspect-[9/5] w-full" />
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-1 flex-col gap-md p-lg">
|
|
|
|
|
<div className="flex flex-col gap-xs">
|
|
|
|
|
<NewsMetaRow entry={entry} />
|
|
|
|
|
|
|
|
|
|
<h3 className="sh-card-title body-lg-bold text-text-primary">
|
|
|
|
|
<CardLink href={href} className="sh-card-link">
|
|
|
|
|
{entry.title}
|
|
|
|
|
</CardLink>
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
{entry.excerpt ? (
|
|
|
|
|
<p className="body-sm text-text-secondary line-clamp-3">
|
|
|
|
|
{entry.excerpt}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* The draft closes the small card with the crediting byline and
|
|
|
|
|
nothing else - the read-more affordance belongs to the lead card
|
|
|
|
|
only, and the headline is already the link. */}
|
|
|
|
|
{creditLabel ? (
|
|
|
|
|
<p className="mt-auto label-md uppercase text-text-tertiary">
|
|
|
|
|
{creditLabel}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</CardShell>
|
2026-09-11 02:46:56 +02:00
|
|
|
);
|
|
|
|
|
}
|