initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
/* ============================================================================
RICH TEXT
----------------------------------------------------------------------------
Renders a Strapi 5 blocks field, or a plain-text field, as React elements.
No `dangerouslySetInnerHTML` anywhere: CMS prose is authored by project and
partner staff, and turning it into markup would make an editor account an
XSS vector. Every node below is mapped to a known element, and text is
rendered as text.
That is not enough on its own for links: an `href` is executable if its
scheme says so, so every link node's URL goes through `safeHref`, and a URL
that is not http/https/mailto/tel or an in-site path renders as plain text
rather than as an anchor.
Colour, size and rhythm come from tier-2 tokens and the published type
roles, and direction is left entirely to the document: RTL falls out of
`<html dir>` plus logical properties, with no mirrored stylesheet.
========================================================================= */
import type { ReactNode } from "react";
import { safeHref } from "~/lib/safe-href";
import type { CmsBlockNode, CmsInlineNode, CmsRichText } from "./cms";
/* ---------------------------------------------------------------------------
INLINE
------------------------------------------------------------------------ */
function isLinkNode(node: CmsInlineNode): boolean {
return node.type === "link" && typeof node.url === "string";
}
/**
* External links open in a new tab; in-site links stay a plain anchor. This is
* presentation only - `safeHref` decides whether there is a link at all.
*/
const EXTERNAL_HREF = /^(?:https?:)?\/\//i;
function InlineText({ node }: { node: CmsInlineNode }) {
if (isLinkNode(node)) {
const href = safeHref(node.url);
// A URL this site refuses to navigate to still keeps its words: the label
// renders as text instead of becoming an anchor with a dangerous target.
if (href === undefined) return <>{renderInline(node.children)}</>;
const external = EXTERNAL_HREF.test(href);
return (
<a
href={href}
className="text-text-link underline underline-offset-2 hover:text-text-link-hover"
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{renderInline(node.children)}
</a>
);
}
const text = node.text ?? "";
if (text === "") return null;
let element: ReactNode = text;
if (node.code) {
element = (
<code className="rounded-control bg-surface-sunken px-2xs py-0 font-mono text-[0.9em]">
{element}
</code>
);
}
if (node.bold) element = <strong className="font-bold">{element}</strong>;
if (node.italic) element = <em>{element}</em>;
if (node.underline) element = <u>{element}</u>;
if (node.strikethrough) element = <s>{element}</s>;
return <>{element}</>;
}
function renderInline(children: readonly CmsInlineNode[] | undefined): ReactNode {
if (!children || children.length === 0) return null;
return children.map((child, index) => (
<InlineText key={index} node={child} />
));
}
/* ---------------------------------------------------------------------------
BLOCKS
------------------------------------------------------------------------ */
/**
* Heading levels are clamped so a document that starts at H1 cannot introduce
* a second H1 into a page that already has one.
*/
const HEADING_CLASS: Readonly<Record<2 | 3 | 4, string>> = {
2: "heading-h2 text-text-primary",
3: "heading-h3 text-text-primary",
4: "body-lg-bold text-text-primary",
};
function Block({ node }: { node: CmsBlockNode }) {
switch (node.type) {
case "heading": {
const level = Math.min(4, Math.max(2, node.level ?? 2)) as 2 | 3 | 4;
const content = renderInline(node.children);
if (level === 2) return <h2 className={HEADING_CLASS[2]}>{content}</h2>;
if (level === 3) return <h3 className={HEADING_CLASS[3]}>{content}</h3>;
return <h4 className={HEADING_CLASS[4]}>{content}</h4>;
}
case "list": {
const ordered = node.format === "ordered";
const List = ordered ? "ol" : "ul";
return (
<List
className={`flex list-outside flex-col gap-xs ps-lg body-md text-text-secondary ${
ordered ? "list-decimal" : "list-disc"
}`}
>
{(node.children ?? []).map((item, index) => (
<li key={index}>
{item.type === "list" ? (
<Block node={item} />
) : (
renderInline((item as { children?: readonly CmsInlineNode[] }).children)
)}
</li>
))}
</List>
);
}
case "quote":
return (
<blockquote className="border-s-2 border-border-accent ps-md body-lg text-text-primary">
{renderInline(node.children)}
</blockquote>
);
case "code":
return (
<pre className="overflow-x-auto rounded-card bg-surface-sunken p-md body-sm text-text-secondary">
<code>{renderInline(node.children)}</code>
</pre>
);
case "image": {
const image = node.image;
if (!image?.url) return null;
return (
<figure className="flex flex-col gap-xs">
<img
src={image.url}
alt={image.alternativeText ?? ""}
width={image.width ?? undefined}
height={image.height ?? undefined}
loading="lazy"
decoding="async"
className="w-full rounded-card border border-border-subtle"
/>
{image.alternativeText ? (
<figcaption className="body-sm text-text-tertiary">
{image.alternativeText}
</figcaption>
) : null}
</figure>
);
}
case "paragraph":
default:
return (
<p className="body-md text-text-secondary">
{renderInline((node as { children?: readonly CmsInlineNode[] }).children)}
</p>
);
}
}
/* ---------------------------------------------------------------------------
ENTRY POINT
------------------------------------------------------------------------ */
/** A blank paragraph separates plain-text bodies into paragraphs. */
function paragraphsOf(value: string): string[] {
return value
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean);
}
export interface RichTextProps {
/** A Strapi blocks value, a plain string, or nothing at all. */
value: CmsRichText | undefined;
className?: string;
}
/**
* Renders whichever of the two shapes the field turned out to be, and nothing
* at all when it is empty - so a body that has not been written yet leaves no
* stray heading or empty box behind.
*/
export function RichText({ value, className }: RichTextProps) {
if (!value) return null;
const stack = `flex flex-col gap-md${className ? ` ${className}` : ""}`;
if (typeof value === "string") {
const paragraphs = paragraphsOf(value);
if (paragraphs.length === 0) return null;
return (
<div className={stack}>
{paragraphs.map((paragraph, index) => (
// `whitespace-pre-line` keeps single newlines inside a paragraph.
<p key={index} className="body-md whitespace-pre-line text-text-secondary">
{paragraph}
</p>
))}
</div>
);
}
if (value.length === 0) return null;
return (
<div className={stack}>
{value.map((node, index) => (
<Block key={index} node={node} />
))}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
/* ============================================================================
LOCALISED DETAIL URLS - canonical and hreflang
----------------------------------------------------------------------------
PURE and client-safe: this runs in a `meta` function, which also executes in
the browser on a client-side navigation, so nothing here may touch the
server environment.
WHY THIS EXISTS
Strapi 5 gives every localisation of an entry its OWN `slug` - the field is
a `uid` with `i18n.localized: true`, so the Arabic translation of
"civil-society-law-session" is a different string, not the same one behind
another prefix. Swapping only the "/en/" segment for "/ar/" therefore points
the hreflang alternate at a URL that does not exist, and a crawler follows
it into a 404. That is defect 18.
THE CONVENTION
1. A detail loader populates the counterpart:
populate: { localizations: LOCALIZATION_POPULATE }
and returns `alternates: collectLocalizedSlugs(...)` in its payload.
2. The route's `meta` appends `detailAlternateDescriptors(...)` to whatever
`buildMeta` produced, which emits <link rel="canonical"> plus one
<link rel="alternate" hreflang> per locale that actually has a slug, and
x-default pointing at English when English exists.
3. The route declares `export const handle = SELF_ALTERNATES_HANDLE`, which
tells the app shell that this page emits its own pair and that the
shell's prefix-swapped set must be suppressed for it. The shell reads it
with `readSelfAlternates`, exactly the way it already reads
`readCanonicalPath` from ~/lib/route-handle.
A locale with no localisation in the CMS gets NO alternate at all, which is
the honest answer: the page has not been translated yet.
========================================================================= */
import type { MetaDescriptor } from "react-router";
import { SUPPORTED_LOCALES, type Locale } from "~/i18n/config";
import { sectionPath, type RouteSlugKey } from "~/i18n/paths";
import { canonicalUrl } from "~/lib/seo";
/* ---------------------------------------------------------------------------
THE STRAPI SIDE
------------------------------------------------------------------------ */
/**
* The `populate` fragment every detail loader passes for `localizations`.
* Only the two fields the alternate needs are asked for, so the counterpart
* entry's whole body is not dragged across the wire.
*/
export const LOCALIZATION_POPULATE = {
fields: ["slug", "locale"],
} as const;
/** One entry of Strapi 5's `localizations` array, with everything optional. */
export interface CmsLocalizationRef {
readonly slug?: string | null;
readonly locale?: string | null;
}
/** An entity that may carry its counterpart translations. */
export interface CmsLocalizable {
readonly localizations?: readonly CmsLocalizationRef[] | null;
}
/** Slug per locale. A locale is absent when the CMS has no translation. */
export type LocalizedSlugs = Readonly<Partial<Record<Locale, string>>>;
function isLocaleCode(value: unknown): value is Locale {
return (
typeof value === "string" &&
(SUPPORTED_LOCALES as readonly string[]).includes(value)
);
}
/**
* The slug of this entry in every language the CMS actually holds it in:
* the locale being rendered plus each entry of `localizations`.
*
* Serialisable, so a loader returns it straight to the route.
*/
export function collectLocalizedSlugs(
locale: Locale,
slug: string,
localizations: readonly CmsLocalizationRef[] | null | undefined,
): LocalizedSlugs {
const slugs: Partial<Record<Locale, string>> = {};
if (slug) slugs[locale] = slug;
for (const entry of localizations ?? []) {
const code = entry?.locale;
const value = entry?.slug;
if (isLocaleCode(code) && typeof value === "string" && value.length > 0) {
slugs[code] = value;
}
}
return slugs;
}
/* ---------------------------------------------------------------------------
THE URL SIDE
------------------------------------------------------------------------ */
export interface LocalizedDetailPath {
readonly locale: Locale;
readonly path: string;
}
/** `/ar/news-and-updates/<arabic-slug>` for every locale that has a slug. */
export function localizedDetailPaths(
section: RouteSlugKey,
slugs: LocalizedSlugs,
): LocalizedDetailPath[] {
return SUPPORTED_LOCALES.flatMap((locale) => {
const slug = slugs[locale];
if (!slug) return [];
return [
{
locale,
path: `${sectionPath(section, locale)}/${encodeURIComponent(slug)}`,
},
];
});
}
export interface DetailAlternatesInput {
readonly siteUrl: string;
readonly section: RouteSlugKey;
/** The locale being rendered. */
readonly locale: Locale;
readonly slugs: LocalizedSlugs;
}
/**
* `<link rel="canonical">` for this page plus the hreflang set built from the
* REAL slugs. Returns [] when the current locale has no slug at all, so an
* error render never claims a canonical it cannot build.
*/
export function detailAlternateDescriptors({
siteUrl,
section,
locale,
slugs,
}: DetailAlternatesInput): MetaDescriptor[] {
const paths = localizedDetailPaths(section, slugs);
const own = paths.find((entry) => entry.locale === locale);
if (!own) return [];
const descriptors: MetaDescriptor[] = [
{ tagName: "link", rel: "canonical", href: canonicalUrl(siteUrl, own.path) },
];
for (const entry of paths) {
descriptors.push({
tagName: "link",
rel: "alternate",
hrefLang: entry.locale,
href: canonicalUrl(siteUrl, entry.path),
});
}
// x-default is the English URL, because bare "/" negotiates towards English.
const fallback = paths.find((entry) => entry.locale === "en");
if (fallback) {
descriptors.push({
tagName: "link",
rel: "alternate",
hrefLang: "x-default",
href: canonicalUrl(siteUrl, fallback.path),
});
}
return descriptors;
}
/* ---------------------------------------------------------------------------
THE SHELL SIDE
------------------------------------------------------------------------ */
/**
* A route handle saying "this page emits its own canonical and hreflang - do
* not add the prefix-swapped pair". Read by the shell with `readSelfAlternates`.
*/
export const SELF_ALTERNATES_HANDLE = { selfAlternates: true } as const;
/* The reader lives with the other handle readers in ~/lib/route-handle, so the
shell does not have to reach into a route folder. Re-exported here because
this module is where the convention is documented. */
export { readSelfAlternates } from "~/lib/route-handle";
+480
View File
@@ -0,0 +1,480 @@
/* ============================================================================
CMS CONTENT MODEL - Knowledge and Impact + News
----------------------------------------------------------------------------
SERVER ONLY. Everything here reaches ~/lib/strapi, which imports
~/lib/env.server, so these helpers may only be called from a `loader`.
The three collections below are what the Strapi agent has to create; the
API ids and field names are the contract. Nothing in this file invents
content: when a collection does not exist yet, or Strapi is unreachable, a
listing degrades to `available: false` and the route renders its localised
empty state instead of a 500.
resources Reports, research and official documents (§6 of the draft)
knowledge-items Training modules, guides, case studies, FAQs (§7)
news-articles SHARP-ER news and network updates (§5)
plus the two taxonomies that supply filter options:
topics partners
FIELD NAMES ARE THE ONES IN apps/cms/src/api/<type>/content-types/<type>/
schema.json - not a guess. `resource.authors` in particular is a plain
STRING field, not a relation, so there is no /api/authors to read.
========================================================================= */
import type { CmsLocalizable } from "./alternates";
import {
findFirst,
getBySlug,
getMany,
isStrapiError,
mediaAlt,
mediaFormatUrl,
mediaUrl,
type StrapiEntity,
type StrapiMedia,
type StrapiPagination,
type StrapiQuery,
} from "~/lib/strapi";
/* ---------------------------------------------------------------------------
RESOURCE NAMES
------------------------------------------------------------------------ */
export const CMS = {
resources: "resources",
knowledgeItems: "knowledge-items",
newsArticles: "news-articles",
topics: "topics",
partners: "partners",
} as const;
/* ---------------------------------------------------------------------------
SHARED SHAPES
------------------------------------------------------------------------ */
/** A taxonomy entry. Only `name` and `slug` are ever read. */
export interface CmsTaxonomy extends StrapiEntity {
readonly name: string;
readonly slug: string;
}
/** One option of a filter `<select>`. */
export interface FilterOption {
readonly value: string;
readonly label: string;
}
/**
* A Strapi blocks-rich-text value, or a plain markdown string when the field
* was modelled as `text`. Both are rendered safely - see _shared/RichText.tsx.
*/
export type CmsRichText = string | readonly CmsBlockNode[] | null;
export type CmsBlockNode =
| { readonly type: "paragraph"; readonly children?: readonly CmsInlineNode[] }
| {
readonly type: "heading";
readonly level?: number;
readonly children?: readonly CmsInlineNode[];
}
| {
readonly type: "list";
readonly format?: "ordered" | "unordered";
readonly children?: readonly CmsBlockNode[];
}
| { readonly type: "list-item"; readonly children?: readonly CmsInlineNode[] }
| { readonly type: "quote"; readonly children?: readonly CmsInlineNode[] }
| { readonly type: "code"; readonly children?: readonly CmsInlineNode[] }
| {
readonly type: "image";
readonly image?: {
readonly url?: string;
readonly alternativeText?: string | null;
readonly width?: number | null;
readonly height?: number | null;
} | null;
};
/**
* One inline node: a run of text, or a link wrapping more inline nodes.
* Modelled as a single open shape rather than a discriminated union because
* this is untrusted CMS JSON - `type` may be absent on a text run, which is
* exactly the case a union cannot narrow, and every field has to be treated
* as possibly missing anyway.
*/
export interface CmsInlineNode {
readonly type?: "text" | "link";
readonly text?: string;
/** Present on a link node. */
readonly url?: string;
/** Present on a link node. */
readonly children?: readonly CmsInlineNode[];
readonly bold?: boolean;
readonly italic?: boolean;
readonly underline?: boolean;
readonly strikethrough?: boolean;
readonly code?: boolean;
}
/* ---------------------------------------------------------------------------
COLLECTIONS
------------------------------------------------------------------------ */
/** §6 Resources and Publications - `resource.resourceType`. */
export type ResourceType =
| "research-perception"
| "policy-article"
| "project-report"
| "visibility-factsheet";
export const RESOURCE_TYPES = [
"research-perception",
"policy-article",
"project-report",
"visibility-factsheet",
] as const satisfies readonly ResourceType[];
/** `resource.resourceLanguage`. */
export type ResourceLanguage = "en" | "ar" | "bilingual";
export interface ResourceEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
/** The card excerpt. `description` in the CMS, not `summary`. */
readonly description?: string | null;
readonly resourceType?: ResourceType | null;
/** Publication date of the document itself, not of the CMS entry. */
readonly publicationDate?: string | null;
/**
* A free-text credit line ("Ahmed Ali, Fatima Saleh"), NOT a relation:
* there is no author content type, so the author filter is built from the
* distinct values this field actually holds - see `fetchDistinctValues`.
*/
readonly authors?: string | null;
readonly partnerOrganisations?: readonly CmsTaxonomy[] | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly thumbnail?: StrapiMedia | null;
readonly file?: StrapiMedia | null;
/** Used when the document is hosted elsewhere instead of uploaded. */
readonly externalUrl?: string | null;
readonly fileSizeLabel?: string | null;
readonly pageCount?: number | null;
readonly resourceLanguage?: ResourceLanguage | null;
}
/** §7 Knowledge Hub - `knowledge-item.itemType`. */
export type KnowledgeItemType =
| "training-module"
| "toolkit"
| "law-session-guide"
| "case-study"
| "faq";
export const KNOWLEDGE_TYPES = [
"training-module",
"toolkit",
"law-session-guide",
"case-study",
"faq",
] as const satisfies readonly KnowledgeItemType[];
export interface KnowledgeEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
readonly summary?: string | null;
readonly body?: CmsRichText;
readonly itemType?: KnowledgeItemType | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly thumbnail?: StrapiMedia | null;
/**
* Multiple media. There is NO `externalUrl` on this type: a knowledge item
* is either downloadable or it is read on the page.
*/
readonly attachments?: readonly StrapiMedia[] | null;
readonly publicationDate?: string | null;
readonly keywords?: unknown;
}
/** §5 News and Updates. `category` drives the two tabs of the listing. */
export type NewsCategory = "sharper-news" | "from-the-network";
export const NEWS_CATEGORIES = [
"sharper-news",
"from-the-network",
] as const satisfies readonly NewsCategory[];
export interface NewsEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
readonly excerpt?: string | null;
readonly body?: CmsRichText;
readonly category?: NewsCategory | null;
/**
* Credit line for a "From the Network" update. A RELATION to a partner, so
* it has to be populated - an unpopulated relation comes back as undefined
* and the card silently loses its byline.
*/
readonly authorOrganisation?: CmsTaxonomy | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly regions?: readonly CmsTaxonomy[] | null;
readonly coverImage?: StrapiMedia | null;
readonly publishDate?: string | null;
readonly featured?: boolean | null;
}
/* ---------------------------------------------------------------------------
MEDIA -> PLAIN LOADER DATA
`mediaUrl` reads the environment, so media is flattened in the loader and
the component never sees a StrapiMedia.
------------------------------------------------------------------------ */
export interface CmsImage {
readonly src: string;
readonly alt: string;
readonly width?: number;
readonly height?: number;
}
export interface CmsFile {
readonly url: string;
readonly name: string;
/** "PDF", "DOCX", ... - upper case, without the dot. */
readonly kind: string;
readonly mime: string;
/** Bytes. Strapi stores `size` in kilobytes, so it is converted here. */
readonly sizeBytes: number;
}
export function toImage(
media: StrapiMedia | null | undefined,
format: "thumbnail" | "small" | "medium" | "large" = "medium",
): CmsImage | undefined {
const src = mediaFormatUrl(media, format);
if (!src || !media) return undefined;
const chosen = media.formats?.[format];
const width = chosen?.width ?? media.width ?? undefined;
const height = chosen?.height ?? media.height ?? undefined;
return {
src,
alt: mediaAlt(media),
...(width ? { width } : {}),
...(height ? { height } : {}),
};
}
export function toFile(media: StrapiMedia | null | undefined): CmsFile | undefined {
const url = mediaUrl(media?.url);
if (!url || !media) return undefined;
const extension = (media.ext ?? "").replace(/^\./, "").toUpperCase();
const fallback = media.mime.split("/").pop()?.toUpperCase() ?? "FILE";
return {
url,
name: media.name,
kind: extension || fallback,
mime: media.mime,
// Strapi's upload plugin records `size` in KB (a float), not in bytes.
sizeBytes: Math.round(Math.max(0, media.size) * 1024),
};
}
/** Names of a taxonomy list, in CMS order. */
export function taxonomyNames(
entries: readonly CmsTaxonomy[] | null | undefined,
): string[] {
return (entries ?? [])
.map((entry) => entry?.name)
.filter((name): name is string => typeof name === "string" && name.length > 0);
}
/* ---------------------------------------------------------------------------
FAULT-TOLERANT FETCHING
The CMS is empty today and its collections may not exist at all, so a
listing must degrade rather than throw. A DETAIL page does the opposite:
a missing entry is a genuine 404 and a transport failure is a real error.
------------------------------------------------------------------------ */
export interface CollectionResult<T> {
readonly entries: readonly T[];
readonly pagination: StrapiPagination;
/** False when the collection could not be read at all. */
readonly available: boolean;
}
function emptyPagination(page: number, pageSize: number): StrapiPagination {
return { page, pageSize, pageCount: 0, total: 0 };
}
function warn(resource: string, error: unknown): void {
const reason = isStrapiError(error)
? `${error.status || "no response"} ${error.message}`
: String(error);
console.warn(`[cms] "${resource}" is unavailable - rendering the empty state (${reason})`);
}
/**
* A paginated collection, or an empty one when Strapi cannot answer.
* Only `StrapiError` is swallowed: a bug in this module still throws.
*/
export async function fetchCollection<T>(
resource: string,
query: StrapiQuery,
): Promise<CollectionResult<T>> {
const page = query.pagination?.page ?? 1;
const pageSize = query.pagination?.pageSize ?? 25;
try {
const result = await getMany<T>(resource, query);
return {
entries: result.data,
pagination: result.pagination ?? {
page,
pageSize,
pageCount: result.data.length > 0 ? 1 : 0,
total: result.data.length,
},
available: true,
};
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return { entries: [], pagination: emptyPagination(page, pageSize), available: false };
}
}
/**
* Filter options from a taxonomy collection. Returns [] - never throws - so a
* taxonomy that has not been created yet simply renders as "All".
*/
export async function fetchFilterOptions(
resource: string,
locale: string,
): Promise<FilterOption[]> {
try {
const result = await getMany<CmsTaxonomy>(resource, {
locale,
fields: ["name", "slug"],
sort: "name:asc",
pagination: { pageSize: 100 },
});
return result.data
.filter((entry) => Boolean(entry?.slug && entry?.name))
.map((entry) => ({ value: entry.slug, label: entry.name }));
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
/**
* The distinct values a plain STRING field actually holds, as filter options.
*
* `resource.authors` is free text, not a taxonomy, so there is no /api/authors
* to read - asking for one is a 404. The options are derived from the entries
* themselves instead, which also means the filter never offers a name that
* has nothing behind it, and offers nothing at all while the CMS is empty.
*/
export async function fetchDistinctValues(
resource: string,
locale: string,
field: string,
limit = 200,
): Promise<FilterOption[]> {
try {
const result = await getMany<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:asc`,
pagination: { page: 1, pageSize: limit },
});
const seen = new Map<string, string>();
for (const entry of result.data) {
const raw = entry?.[field];
if (typeof raw !== "string") continue;
const value = raw.trim();
if (value.length === 0 || value.length > 200) continue;
if (!seen.has(value)) seen.set(value, value);
}
return [...seen.values()]
.sort((a, b) => a.localeCompare(b, locale))
.map((value) => ({ value, label: value }));
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
/**
* The calendar years actually covered by a collection, newest first. Derived
* from the oldest and newest entry rather than hard-coded, so the "Date"
* filter never offers a year that has nothing behind it - and offers nothing
* at all while the CMS is empty.
*/
export async function fetchYearOptions(
resource: string,
locale: string,
field: string,
): Promise<number[]> {
try {
const [newest, oldest] = await Promise.all([
findFirst<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:desc`,
}),
findFirst<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:asc`,
}),
]);
const to = yearOf(newest?.[field]);
const from = yearOf(oldest?.[field]);
if (to === undefined || from === undefined) return [];
const years: number[] = [];
for (let year = Math.max(from, to); year >= Math.min(from, to); year -= 1) {
years.push(year);
}
return years;
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
function yearOf(value: unknown): number | undefined {
if (typeof value !== "string" && typeof value !== "number") return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.getUTCFullYear();
}
/**
* One entry by slug, or null. A 404 from Strapi (including "this collection
* does not exist yet") resolves to null so the route can throw its own 404;
* every other failure propagates, because a CMS outage is not a missing page.
*/
export async function fetchBySlug<T>(
resource: string,
slug: string,
query: StrapiQuery,
): Promise<T | null> {
try {
return await getBySlug<T>(resource, slug, query);
} catch (error) {
if (isStrapiError(error) && error.status === 404) return null;
throw error;
}
}
+526
View File
@@ -0,0 +1,526 @@
/* ============================================================================
LISTING UI
----------------------------------------------------------------------------
The pieces the three listings share: page header, filter bar, result
summary, empty state, tabs, pagination, metadata list and detail-page
scaffolding.
None of this duplicates the component library - Button, Input and the Card
family come from ~/components/ui and are used as they are; what is here is
layout and the handful of controls (a native <select>, a tab strip, a
pagination list) the library does not ship.
RULES FOLLOWED THROUGHOUT
* colour, radius, spacing and motion are tier-2 semantic tokens only,
spent through the Tailwind bridge (bg-surface-raised, text-text-primary)
or as arbitrary values (shadow-[var(--elevation-card)]);
* every directional property is logical (ps-/pe-/ms-/me-/start-/end-,
border-s), so the Arabic build mirrors with no second stylesheet;
* type comes from the published roles - display-sm, heading-h2, body-md,
label-md - never a raw font-size.
========================================================================= */
import type { ReactNode } from "react";
import { Form, Link, useSubmit } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import type { Translator } from "~/i18n";
import type { PaginationModel, SelectOption } from "./listing";
import { withParam } from "./listing";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ---------------------------------------------------------------------------
PAGE SCAFFOLDING
------------------------------------------------------------------------ */
/** The centred column every page in this section uses. */
export function PageShell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cx(
"mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col",
"gap-2xl px-[var(--layout-gutter)] py-3xl",
className,
)}
>
{children}
</div>
);
}
export function PageHeader({
heading,
intro,
eyebrow,
children,
}: {
heading: string;
intro?: string;
eyebrow?: string;
children?: ReactNode;
}) {
return (
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
{eyebrow ? <p className="label-md uppercase text-text-accent">{eyebrow}</p> : null}
<h1 className="display-sm text-text-primary">{heading}</h1>
{intro ? <p className="body-lg text-text-secondary">{intro}</p> : null}
{children}
</header>
);
}
/* ---------------------------------------------------------------------------
FILTERS
------------------------------------------------------------------------ */
/**
* A native <select>. Native on purpose: it is keyboard- and screen-reader
* correct in every browser, it works before hydration, and `color-scheme` on
* the surface already makes the dropdown itself paint dark or light.
*/
export function FilterSelect({
name,
label,
value,
options,
allLabel,
disabled,
}: {
name: string;
label: string;
value: string;
options: readonly SelectOption[];
/**
* Label of the "no filter" option, e.g. "All". Omit it for a control that
* always has a value - a sort order has no "unset" state, and offering one
* would duplicate whichever option is the default.
*/
allLabel?: string;
disabled?: boolean;
}) {
return (
<div className="flex min-w-0 flex-col gap-2xs">
<label className="label-md text-text-tertiary" htmlFor={`filter-${name}`}>
{label}
</label>
<select
id={`filter-${name}`}
name={name}
defaultValue={value}
disabled={disabled || options.length === 0}
className={cx(
"body-md w-full rounded-[var(--radius-control)] border bg-field-bg",
"border-field-border px-md py-sm text-[var(--field-fg)]",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)] hover:border-[var(--field-border-hover)]",
"disabled:cursor-not-allowed disabled:bg-[var(--field-bg-disabled)]",
"disabled:text-[var(--field-fg-disabled)]",
)}
>
{allLabel === undefined ? null : <option value="">{allLabel}</option>}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
}
/**
* The filter bar. A real `<form method="get">`, so every combination of
* filters is a URL: shareable, indexable, and usable with scripting off. When
* scripting is on, changing a control submits immediately; the Apply button
* stays for the case where it is not.
*
* `page` is deliberately NOT a field: submitting the form drops it, which
* returns the reader to page 1 of the new result set.
*/
export function FilterBar({
legend,
action,
applyLabel,
clearLabel,
clearHref,
showClear,
children,
}: {
legend: string;
/** The listing's own pathname - keeps the GET target explicit. */
action: string;
applyLabel: string;
clearLabel: string;
clearHref: string;
showClear: boolean;
children: ReactNode;
}) {
const submit = useSubmit();
return (
<Form
method="get"
action={action}
aria-label={legend}
onChange={(event) => {
// Progressive enhancement: with JS, changing a control applies at once.
if (event.target instanceof HTMLSelectElement) {
submit(event.currentTarget, { method: "get" });
}
}}
className={cx(
"flex flex-col gap-md rounded-card border border-border-subtle",
"bg-surface-raised p-lg shadow-[var(--elevation-raised)]",
)}
>
<div className="grid gap-md sm:grid-cols-2 lg:grid-cols-4">{children}</div>
<div className="flex flex-wrap items-center gap-sm">
<Button type="submit" variant="secondary" size="sm">
{applyLabel}
</Button>
{showClear ? (
<Button as={Link} to={clearHref} variant="ghost" size="sm">
{clearLabel}
</Button>
) : null}
</div>
</Form>
);
}
/** The search box of the knowledge hub. Submits with the surrounding form. */
export function FilterSearch({
label,
placeholder,
defaultValue,
name = "q",
}: {
label: string;
placeholder: string;
defaultValue: string;
name?: string;
}) {
return (
<Input
type="search"
name={name}
label={label}
placeholder={placeholder}
defaultValue={defaultValue}
className="min-w-0"
/>
);
}
/* ---------------------------------------------------------------------------
RESULTS
------------------------------------------------------------------------ */
/** "Showing 1–12 of 34". Announced politely when a filter changes the count. */
export function ResultSummary({
pagination,
t,
}: {
pagination: PaginationModel;
t: Translator;
}) {
return (
<p className="body-sm text-text-tertiary" role="status">
{pagination.total === 0
? null
: t("pagination.showing", {
from: pagination.from,
to: pagination.to,
total: pagination.total,
})}
</p>
);
}
export function EmptyState({
title,
body,
children,
}: {
title: string;
body: string;
children?: ReactNode;
}) {
return (
<div
className={cx(
"flex flex-col items-start gap-md rounded-card border border-dashed",
"border-border-default bg-surface-sunken p-2xl",
)}
>
<p className="heading-h3 text-text-primary">{title}</p>
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">{body}</p>
{children}
</div>
);
}
/* ---------------------------------------------------------------------------
TABS
------------------------------------------------------------------------ */
export interface TabLink {
readonly id: string;
readonly label: string;
readonly href: string;
readonly isCurrent: boolean;
}
/**
* The News tab strip. Links rather than buttons, because each tab is its own
* URL - the reader can share "From the Network" and a crawler can reach it.
*/
export function TabLinks({ label, tabs }: { label: string; tabs: readonly TabLink[] }) {
return (
<nav aria-label={label}>
<ul className="flex flex-wrap gap-xs border-b border-border-subtle">
{tabs.map((tab) => (
<li key={tab.id}>
<Link
to={tab.href}
aria-current={tab.isCurrent ? "page" : undefined}
className={cx(
"-mb-px inline-block border-b-2 px-md py-sm body-md-bold",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)]",
tab.isCurrent
? "border-border-accent text-text-accent"
: "border-transparent text-text-secondary hover:text-text-primary",
)}
>
{tab.label}
</Link>
</li>
))}
</ul>
</nav>
);
}
/* ---------------------------------------------------------------------------
PAGINATION
------------------------------------------------------------------------ */
const PAGE_LINK = cx(
"inline-flex min-w-[2.75rem] items-center justify-center rounded-[var(--radius-control)]",
"border px-sm py-2xs body-sm-bold transition-colors",
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
);
/**
* Numbered pagination, rendered as ordinary links so the whole archive is
* crawlable. The arrows follow reading order: `ArrowIcon` mirrors itself in
* RTL, so "next" points left on /ar without a second component.
*/
export function Pagination({
model,
pathname,
params,
t,
}: {
model: PaginationModel;
pathname: string;
params: URLSearchParams;
t: Translator;
}) {
if (model.pageCount <= 1) return null;
const href = (page: number) => `${pathname}${withParam(params, "page", page)}`;
return (
<nav aria-label={t("pagination.label")} className="flex flex-col gap-md">
<ul className="flex flex-wrap items-center gap-xs">
<li>
{model.previous === undefined ? (
<Button variant="ghost" size="sm" disabled iconStart={<ArrowIcon direction="backward" />}>
{t("pagination.previous")}
</Button>
) : (
<Button
as={Link}
to={href(model.previous)}
variant="ghost"
size="sm"
iconStart={<ArrowIcon direction="backward" />}
>
{t("pagination.previous")}
</Button>
)}
</li>
{model.pages.map((entry, index) => {
const previousPage = model.pages[index - 1]?.page;
const gap = previousPage !== undefined && entry.page - previousPage > 1;
return (
<li key={entry.page} className="flex items-center gap-xs">
{gap ? (
<span aria-hidden="true" className="body-sm text-text-tertiary">
…
</span>
) : null}
<Link
to={href(entry.page)}
aria-current={entry.isCurrent ? "page" : undefined}
aria-label={t("pagination.goToPage", { page: entry.page })}
className={cx(
PAGE_LINK,
entry.isCurrent
? "border-border-accent bg-surface-accent-subtle text-text-accent"
: "border-border-subtle text-text-secondary hover:border-border-default hover:text-text-primary",
)}
>
{entry.page}
</Link>
</li>
);
})}
<li>
{model.next === undefined ? (
<Button variant="ghost" size="sm" disabled iconEnd={<ArrowIcon />}>
{t("pagination.next")}
</Button>
) : (
<Button
as={Link}
to={href(model.next)}
variant="ghost"
size="sm"
iconEnd={<ArrowIcon />}
>
{t("pagination.next")}
</Button>
)}
</li>
</ul>
<p className="body-sm text-text-tertiary">
{t("pagination.page", { page: model.page, pageCount: model.pageCount })}
</p>
</nav>
);
}
/* ---------------------------------------------------------------------------
DETAIL PAGES
------------------------------------------------------------------------ */
export interface MetaItem {
readonly label: string;
readonly value: ReactNode;
}
/** The "Published / Author / Partner organisation" block of a detail page. */
export function MetaList({ heading, items }: { heading: string; items: readonly MetaItem[] }) {
if (items.length === 0) return null;
return (
<section
aria-label={heading}
className={cx(
"flex flex-col gap-md rounded-card border border-border-subtle",
"bg-surface-raised p-lg",
)}
>
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
<dl className="flex flex-col gap-sm">
{items.map((item) => (
<div key={item.label} className="flex flex-col gap-2xs">
<dt className="body-sm text-text-tertiary">{item.label}</dt>
<dd className="body-md-bold text-text-primary">{item.value}</dd>
</div>
))}
</dl>
</section>
);
}
/** Topic chips. Each one links back into the listing, pre-filtered. */
export function TopicChips({
heading,
topics,
}: {
heading: string;
topics: readonly { label: string; href?: string }[];
}) {
if (topics.length === 0) return null;
return (
<section aria-label={heading} className="flex flex-col gap-xs">
<h2 className="label-md uppercase text-text-tertiary">{heading}</h2>
<ul className="flex flex-wrap gap-xs">
{topics.map((topic) => (
<li key={topic.label}>
{topic.href ? (
<Link
to={topic.href}
className={cx(
"inline-block rounded-pill border border-border-subtle px-sm py-2xs",
"body-sm text-text-secondary transition-colors",
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
"hover:border-border-accent hover:text-text-accent",
)}
>
{topic.label}
</Link>
) : (
<span className="inline-block rounded-pill border border-border-subtle px-sm py-2xs body-sm text-text-secondary">
{topic.label}
</span>
)}
</li>
))}
</ul>
</section>
);
}
/** The link out of a detail page, arrow pointing against reading order. */
export function BackLink({ to, label }: { to: string; label: string }) {
return (
<Button
as={Link}
to={to}
variant="ghost"
size="sm"
iconStart={<ArrowIcon direction="backward" />}
>
{label}
</Button>
);
}
/** A short, dot-separated metadata line under a card or a detail title. */
export function MetaLine({ items }: { items: readonly string[] }) {
const present = items.filter(Boolean);
if (present.length === 0) return null;
return (
<p className="body-sm text-text-tertiary">
{present.map((item, index) => (
<span key={`${item}-${index}`}>
{index > 0 ? <span aria-hidden="true"> · </span> : null}
{item}
</span>
))}
</p>
);
}
+321
View File
@@ -0,0 +1,321 @@
/* ============================================================================
LISTING STATE - URL search parameters
----------------------------------------------------------------------------
Every filter, the sort order and the page number live in the query string,
so a filtered listing is a real URL: shareable, bookmarkable, back-button
correct, and reachable by a crawler that only follows links.
Pure and client-safe - the same helpers parse in the loader and build hrefs
in the component, which is what keeps the two in step.
PARAMETER NAMES (shared by /en and /ar - the locale lives in the path):
resources ?type= &topic= &author= &partner= &year= &sort= &page=
knowledge-hub ?type= &topic= &q= &page=
news-and-updates ?tab= &page=
========================================================================= */
import { data } from "react-router";
import {
DEFAULT_LOCALE,
isLocale,
localizedPath,
translate,
type Locale,
type TranslationKey,
} from "~/i18n";
/** Entries per page. 12 divides cleanly into the 1 / 2 / 3 column grids. */
export const PAGE_SIZE = 12;
/** Longest search term we will forward to the CMS. */
const MAX_QUERY_LENGTH = 100;
/** Slugs and enum values are `[a-z0-9-]` - anything else is dropped, never sent. */
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type SortOrder = "newest" | "oldest";
export const SORT_ORDERS = ["newest", "oldest"] as const satisfies readonly SortOrder[];
/* ---------------------------------------------------------------------------
PARSING
------------------------------------------------------------------------ */
/** A 1-based page number. Anything unparseable, zero or negative becomes 1. */
export function parsePage(params: URLSearchParams): number {
const raw = Number.parseInt(params.get("page") ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 10_000) : 1;
}
/** One of `allowed`, or `fallback`. Used for every enum-backed filter. */
export function parseEnum<T extends string>(
params: URLSearchParams,
key: string,
allowed: readonly T[],
fallback: T,
): T {
const value = params.get(key);
return allowed.find((option) => option === value) ?? fallback;
}
/** One of `allowed`, or undefined when the parameter is absent or unknown. */
export function parseOptionalEnum<T extends string>(
params: URLSearchParams,
key: string,
allowed: readonly T[],
): T | undefined {
const value = params.get(key);
return allowed.find((option) => option === value);
}
/**
* A taxonomy slug. Validated rather than trusted: the value is interpolated
* into a Strapi filter, so only the shape a slug can actually have is passed on.
*/
export function parseSlug(params: URLSearchParams, key: string): string | undefined {
const value = params.get(key)?.trim().toLowerCase();
if (!value || value.length > 80 || !SLUG_PATTERN.test(value)) return undefined;
return value;
}
/**
* An exact value for a filter on a plain STRING field - `resource.authors` is
* free text, not a taxonomy, so its filter cannot be a slug. Trimmed, length
* capped and stripped of control characters before it is interpolated into a
* Strapi `$eq`, so only a value a reader could have picked out of the select
* is ever forwarded.
*/
export function parseExactText(
params: URLSearchParams,
key: string,
maxLength = 200,
): string | undefined {
const raw = params.get(key);
if (typeof raw !== "string") return undefined;
// eslint-disable-next-line no-control-regex
const value = raw.replace(/[\u0000-\u001F\u007F]/g, "").trim();
if (value.length === 0 || value.length > maxLength) return undefined;
return value;
}
/** A four-digit year inside a plausible range, or undefined. */
export function parseYear(params: URLSearchParams, key = "year"): number | undefined {
const raw = Number.parseInt(params.get(key) ?? "", 10);
if (!Number.isFinite(raw) || raw < 1900 || raw > 2200) return undefined;
return raw;
}
/** A free-text search term, trimmed and length-capped. */
export function parseQuery(params: URLSearchParams, key = "q"): string | undefined {
const value = params.get(key)?.trim();
if (!value) return undefined;
return value.slice(0, MAX_QUERY_LENGTH);
}
export function parseSort(params: URLSearchParams): SortOrder {
return parseEnum(params, "sort", SORT_ORDERS, "newest");
}
/* ---------------------------------------------------------------------------
STRAPI QUERY FRAGMENTS
------------------------------------------------------------------------ */
/** `"publicationDate:desc"` / `":asc"`, with a stable tiebreaker. */
export function sortFor(field: string, order: SortOrder): string[] {
return order === "oldest" ? [`${field}:asc`, "title:asc"] : [`${field}:desc`, "title:asc"];
}
/** A whole calendar year as a half-open range on a date field. */
export function yearFilter(year: number): { $gte: string; $lt: string } {
return {
$gte: `${year}-01-01`,
$lt: `${year + 1}-01-01`,
};
}
/** Drop the keys whose value is undefined, so an empty filter is not sent. */
export function compactFilters(
filters: Record<string, unknown>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(filters).filter(([, value]) => value !== undefined),
);
}
/* ---------------------------------------------------------------------------
HREFS
------------------------------------------------------------------------ */
/**
* The same listing with one parameter changed. `undefined` removes it, and
* changing anything other than the page resets back to page 1 - a filter that
* kept you on page 7 of a shorter result set is the classic empty page.
*/
export function withParam(
params: URLSearchParams,
key: string,
value: string | number | undefined,
): string {
const next = new URLSearchParams(params);
if (value === undefined || value === "") next.delete(key);
else next.set(key, String(value));
if (key !== "page") next.delete("page");
if (key === "page" && String(value) === "1") next.delete("page");
const search = next.toString();
return search ? `?${search}` : "";
}
/** The listing with every filter cleared. */
export function clearedHref(pathname: string): string {
return pathname;
}
/* ---------------------------------------------------------------------------
PAGINATION MODEL
------------------------------------------------------------------------ */
export interface PageLink {
readonly page: number;
readonly isCurrent: boolean;
}
export interface PaginationModel {
readonly page: number;
readonly pageCount: number;
readonly total: number;
readonly from: number;
readonly to: number;
readonly previous: number | undefined;
readonly next: number | undefined;
readonly pages: readonly PageLink[];
}
/**
* A windowed page list: first, last, and up to two neighbours either side of
* the current page. Gaps are rendered as an ellipsis by the component.
*/
export function buildPagination(
page: number,
pageSize: number,
total: number,
): PaginationModel {
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
const current = Math.min(Math.max(1, page), pageCount);
const pageWindow = new Set<number>([1, pageCount, current]);
for (let offset = 1; offset <= 2; offset += 1) {
if (current - offset >= 1) pageWindow.add(current - offset);
if (current + offset <= pageCount) pageWindow.add(current + offset);
}
const pages = [...pageWindow]
.sort((a, b) => a - b)
.map((value) => ({ page: value, isCurrent: value === current }));
return {
page: current,
pageCount,
total,
from: total === 0 ? 0 : (current - 1) * pageSize + 1,
to: Math.min(current * pageSize, total),
previous: current > 1 ? current - 1 : undefined,
next: current < pageCount ? current + 1 : undefined,
pages,
};
}
/* ---------------------------------------------------------------------------
OPTION LISTS
------------------------------------------------------------------------ */
export interface SelectOption {
readonly value: string;
readonly label: string;
}
/** Turn an enum plus its catalogue keys into `<option>`s in the page locale. */
export function enumOptions<T extends string>(
values: readonly T[],
keys: Readonly<Record<T, TranslationKey>>,
locale: Locale,
): SelectOption[] {
return values.map((value) => ({
value,
label: translate(locale, keys[value]),
}));
}
/** Years as `<option>`s. Digits follow the locale's `latn` numbering system. */
export function yearOptions(years: readonly number[], locale: Locale): SelectOption[] {
return years.map((year) => ({
value: String(year),
label: new Intl.NumberFormat(`${locale}-u-nu-latn`, {
useGrouping: false,
}).format(year),
}));
}
/* ---------------------------------------------------------------------------
ROUTE CONTEXT
------------------------------------------------------------------------ */
/**
* `params.lang` as a `Locale`. The ":lang" layout has already validated the
* segment, so this only ever narrows the type - but it narrows it honestly:
* an impossible value throws the same 404 the layout would have.
*/
export function requireLocale(value: string | undefined): Locale {
if (isLocale(value)) return value;
throw data("Not Found", { status: 404, statusText: "Not Found" });
}
/** What `buildMeta` needs from the root loader. */
export interface RootSeo {
readonly locale: Locale;
readonly pathname: string;
readonly siteUrl: string;
}
const FALLBACK_SITE_URL = "https://sharper.ly";
/**
* The root loader's data, read out of a `meta` function's `matches`.
*
* React Router 8 exposes it as `match.loaderData` (v7's `match.data` is gone),
* and it is looked up BY ROUTE ID rather than by index: `matches[0]` is only
* the root by convention, and the entry is undefined while the root loader is
* still in flight or on an error render - a `meta` that throws takes the whole
* document with it.
*/
export function readRootSeo(matches: readonly unknown[]): RootSeo {
for (const match of matches) {
if (!match || typeof match !== "object") continue;
const { id, loaderData } = match as { id?: unknown; loaderData?: unknown };
if (id !== "root" || !loaderData || typeof loaderData !== "object") continue;
const record = loaderData as Record<string, unknown>;
const locale = isLocale(record.locale) ? record.locale : DEFAULT_LOCALE;
return {
locale,
pathname:
typeof record.pathname === "string"
? record.pathname
: localizedPath("/", locale),
siteUrl:
typeof record.siteUrl === "string" ? record.siteUrl : FALLBACK_SITE_URL,
};
}
return {
locale: DEFAULT_LOCALE,
pathname: localizedPath("/", DEFAULT_LOCALE),
siteUrl: FALLBACK_SITE_URL,
};
}
+229
View File
@@ -0,0 +1,229 @@
/* ============================================================================
PAGE COPY - Knowledge and Impact + News
----------------------------------------------------------------------------
Section headings, intros and empty-state messages for the three sections
this file set owns. The EN and AR strings are taken verbatim from
docs/SHARPER_Website_Draft_Section_EN_AR.docx (§5 News and Updates,
§6 Resources and Publications, §7 Knowledge Hub); the empty-state wording is
the only addition, because the draft has no copy for "the CMS is empty".
This is page prose, not UI chrome, which is why it lives beside the routes
instead of in ~/i18n/messages - the catalogue holds labels the whole site
shares. Everything a reader could otherwise mistake for content - counts,
figures, author names, dates - comes from Strapi, never from here: the draft
marks its own numbers "TBD" and says they must not be published unconfirmed.
========================================================================= */
import type { Locale } from "~/i18n";
export interface SectionCopy {
/** H1 of the section. */
readonly heading: string;
/** The standfirst under the H1. */
readonly intro: string;
/** Shown when the section has no entries at all yet. */
readonly emptyTitle: string;
readonly emptyBody: string;
/** `<meta name="description">` for the listing. */
readonly metaDescription: string;
}
type Localised<T> = Readonly<Record<Locale, T>>;
/* ---------------------------------------------------------------------------
§6 RESOURCES AND PUBLICATIONS
------------------------------------------------------------------------ */
export const RESOURCES_COPY: Localised<SectionCopy> = {
en: {
heading: "Reports, Research, and Publications",
intro:
"Findings, articles, and official documents produced through SHARP-ER’s research and dialogue work.",
emptyTitle: "No publications have been added yet.",
emptyBody:
"Reports, research and official documents will appear here as they are published.",
metaDescription:
"Findings, articles and official documents produced through SHARP-ER’s research and dialogue work. Filter by topic, date, author and partner organisation.",
},
ar: {
heading: "تقارير وأبحاث ومنشورات",
intro:
"نتائج ومقالات ووثائق رسمية صادرة عن أعمال البحث والحوار في مشروع SHARP-ER.",
emptyTitle: "لم تُضف أي منشورات بعد.",
emptyBody: "ستظهر هنا التقارير والأبحاث والوثائق الرسمية فور نشرها.",
metaDescription:
"نتائج ومقالات ووثائق رسمية صادرة عن أعمال البحث والحوار في مشروع SHARP-ER، مع إمكانية التصفية حسب الموضوع والتاريخ والكاتب والمنظمة الشريكة.",
},
};
/* ---------------------------------------------------------------------------
§7 KNOWLEDGE HUB
------------------------------------------------------------------------ */
export const KNOWLEDGE_COPY: Localised<SectionCopy> = {
en: {
heading: "Learn From the SHARP Network",
intro:
"Training materials, dialogue guides, and lessons from civil society organisations putting SHARP-ER’s approach into practice.",
emptyTitle: "The knowledge hub is being prepared.",
emptyBody:
"Training modules, dialogue guides, case studies and FAQs will appear here as they are published.",
metaDescription:
"Training modules and toolkits, Civil Society Law Session guides, case studies and plain-language FAQs from across the SHARP network.",
},
ar: {
heading: "تعلّم من شبكة SHARP",
intro:
"مواد تدريبية، وأدلة حوار، ودروس مستفادة من منظمات المجتمع المدني التي تطبّق نهج SHARP-ER على أرض الواقع.",
emptyTitle: "يجري تجهيز مركز المعرفة.",
emptyBody:
"ستظهر هنا الوحدات التدريبية وأدلة الحوار ودراسات الحالة والأسئلة الشائعة فور نشرها.",
metaDescription:
"وحدات تدريبية وأدلة عملية، وأدلة جلسات قانون المجتمع المدني، ودراسات حالة، وأسئلة شائعة بلغة مبسّطة من شبكة SHARP.",
},
};
/**
* The five kinds of material the hub carries - the values are the
* `knowledge-item.itemType` enumeration in the CMS, verbatim.
*
* The LABELS live here rather than in ~/i18n because the CMS enumeration is
* the source of truth and it keeps training modules and toolkits apart, so
* each of the five needs its own distinct label - a filter cannot offer two
* options reading the same. The wording is the draft's own.
*/
export const KNOWLEDGE_TYPE_LABELS: Localised<Readonly<Record<string, string>>> = {
en: {
"training-module": "Training modules",
toolkit: "Toolkits",
"law-session-guide": "Civil Society Law Session guides",
"case-study": "Case studies",
faq: "FAQs",
},
ar: {
"training-module": "وحدات تدريبية",
toolkit: "أدلة عملية",
"law-session-guide": "أدلة جلسات قانون المجتمع المدني",
"case-study": "دراسات حالة",
faq: "الأسئلة الشائعة",
},
};
/** The one-line description that sits under each type. */
export const KNOWLEDGE_TYPE_BLURBS: Localised<Readonly<Record<string, string>>> = {
en: {
"training-module": "From peer-to-peer learning and dialogue-facilitation training.",
toolkit: "Practical materials civil society organisations can put to work directly.",
"law-session-guide": "From joint sessions with the Commission on Civil Society (CCS).",
"case-study": "From CSO sub-projects across the network.",
faq: "Plain-language answers on rule of law, governance, and civic participation basics.",
},
ar: {
"training-module": "من التعلّم بين الأقران وتدريب تيسير الحوار.",
toolkit: "مواد عملية يمكن لمنظمات المجتمع المدني استخدامها مباشرة.",
"law-session-guide": "من الجلسات المشتركة مع لجنة المجتمع المدني (CCS).",
"case-study": "من المشاريع الفرعية لمنظمات المجتمع المدني عبر الشبكة.",
faq: "إجابات بلغة مبسّطة حول أساسيات سيادة القانون والحوكمة والمشاركة المدنية.",
},
};
/* ---------------------------------------------------------------------------
§5 NEWS AND UPDATES
------------------------------------------------------------------------ */
export interface NewsCopy extends SectionCopy {
/** Description of the "SHARP-ER News" tab. */
readonly projectBlurb: string;
/** Description of the "From the Network" tab. */
readonly networkBlurb: string;
}
export const NEWS_COPY: Localised<NewsCopy> = {
en: {
heading: "News from SHARP-ER and the Network",
intro:
"Milestones from the project team, and updates from the civil society organisations we work with.",
projectBlurb:
"Official updates on milestones, campaign launches, publications, and events.",
networkBlurb:
"Updates submitted by CSOs granted as part of the consortium, each card crediting the organisation.",
emptyTitle: "There are no updates to show yet.",
emptyBody:
"News from the project team and from the SHARP network will appear here.",
metaDescription:
"Milestones from the SHARP-ER project team, and updates from the civil society organisations we work with across Libya.",
},
ar: {
heading: "أخبار من SHARP-ER ومن الشبكة",
intro:
"محطات رئيسية من فريق المشروع، ومستجدات من منظمات المجتمع المدني التي نعمل معها.",
projectBlurb:
"تحديثات رسمية حول المحطات الرئيسية، وإطلاق الحملات، والمنشورات، والفعاليات.",
networkBlurb:
"مستجدات تنشرها منظمات المجتمع المدني الشريكة ضمن الائتلاف، وتُنسب كل بطاقة إلى المنظمة الناشرة.",
emptyTitle: "لا توجد مستجدات لعرضها بعد.",
emptyBody: "ستظهر هنا أخبار فريق المشروع ومستجدات شبكة SHARP.",
metaDescription:
"محطات رئيسية من فريق مشروع SHARP-ER، ومستجدات من منظمات المجتمع المدني التي نعمل معها في مختلف أنحاء ليبيا.",
},
};
/* ---------------------------------------------------------------------------
DETAIL PAGES
------------------------------------------------------------------------ */
export interface DetailCopy {
/** "Back to all publications" - the link out of a detail page. */
readonly backToListing: string;
/** Heading of the metadata block. */
readonly aboutThis: string;
readonly published: string;
readonly author: string;
readonly partner: string;
readonly topics: string;
readonly openExternal: string;
}
export const DETAIL_COPY: Localised<DetailCopy> = {
en: {
backToListing: "Back to all publications",
aboutThis: "About this publication",
published: "Published",
author: "Author",
partner: "Partner organisation",
topics: "Topics",
openExternal: "Open the document",
},
ar: {
backToListing: "العودة إلى جميع المنشورات",
aboutThis: "عن هذا المنشور",
published: "تاريخ النشر",
author: "الكاتب",
partner: "المنظمة الشريكة",
topics: "المواضيع",
openExternal: "فتح الوثيقة",
},
};
export const KNOWLEDGE_DETAIL_COPY: Localised<Pick<DetailCopy, "backToListing" | "aboutThis">> =
{
en: {
backToListing: "Back to the knowledge hub",
aboutThis: "About this material",
},
ar: {
backToListing: "العودة إلى مركز المعرفة",
aboutThis: "عن هذه المادة",
},
};
export const NEWS_DETAIL_COPY: Localised<Pick<DetailCopy, "backToListing" | "aboutThis">> = {
en: {
backToListing: "Back to all news",
aboutThis: "About this update",
},
ar: {
backToListing: "العودة إلى جميع الأخبار",
aboutThis: "عن هذا المستجد",
},
};