Files
IllusionandClaude Opus 5 aedef6daeb Rebuild the site to the Claude Design drafts
Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the
project's own design system: tier-2 tokens, the published type roles, logical
properties throughout, and both locales.

Surfaces
- The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts
  do; the body previously painted the band colour, so every band was a step
  too light.
- A card is drawn one step off whatever it stands on (#1D1D1B on the ground,
  #141413 inside a band). Components paint --surface-card and a band re-points
  it for its subtree, so a card can never come out the same colour as its band.

Shell
- Nav: translucent ground with a blur, chevrons on group triggers, an accent
  rule under the current section, the drafts' compact dropdown card, and an
  EN | AR toggle in place of the globe menu.
- Footer: the drafts' four-column grid, one-line brand text, Facebook as a
  bordered button, the EU lockup, the implemented-by row and the funding
  disclaimer.

Pages
- Home: hero with counting figures, launch band, three areas of change,
  newsroom, FAQ accordion and the consortium band.
- The nine interior pages rebuilt and then audited section by section against
  their drafts.
- Applications: the three-step application form now posts real submissions to
  the Strapi `application` type, validated server-side and working without
  JavaScript.

Motion
- Every animation in the drafts, as classes in _page-kit/motion.css: staggered
  entrances, scroll-driven reveals, the drifting washes (each page carries its
  own geometry), the sheen, the counting figures and the accordion. The
  drafts' coloured hover glow is deliberately not shipped.

CMS
- New `faq` collection type behind the home page's accordion.
- `role` added to the consortium member component so an editor can supply the
  chip and the deliverable separately.

Content
- No invented figure, date, name or commitment ships: the five-working-day
  reply, the placeholder email and Facebook handle, the sample news, events,
  publications and FAQs, the invented application rules and the TBD counters
  are all withheld pending real content. Figures that survived (3 consortium
  partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec
  2027) are verified against docs/ and the bundled draft copy.

Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20
page/locale URLs render 200 through the built SSR handler, including with the
CMS unreachable; no raw hex or physical-direction utility in components or
routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:32:56 +02:00

510 lines
16 KiB
TypeScript

/* ============================================================================
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;
}
}
/**
* How many entries match a filter, without pulling any of them back.
*
* Used by the knowledge hub, where each type card carries the number of items
* actually published under it. The figure is the CMS's own count - the
* redesign draft's "12 modules · EN / AR" was sample text and is not shipped -
* and an unreachable collection counts as nothing rather than taking the page
* down.
*/
export async function fetchCount(
resource: string,
locale: string,
filters: Record<string, unknown>,
): Promise<number> {
try {
const result = await getMany<{ readonly id?: number }>(resource, {
locale,
filters,
fields: ["id"],
pagination: { page: 1, pageSize: 1, withCount: true },
});
return result.pagination?.total ?? result.data.length;
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return 0;
}
}