initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: "عن هذا المستجد",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user