initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# ============================================================================
# Build context for BOTH images is the repository root (see docker-compose.yml).
# Everything below is either reinstalled/rebuilt inside the image or must never
# enter one.
# ============================================================================
# Dependencies and build output - recreated by pnpm install / pnpm build.
**/node_modules
apps/web/build
apps/web/.react-router
apps/cms/dist
apps/cms/build
apps/cms/.strapi
apps/cms/.tmp
apps/cms/.cache
# Secrets. The containers receive their environment from docker compose.
.env
.env.*
!.env.example
# Licensed fonts are mounted or copied in at deploy time, never baked into a
# published image. See the FONTS section of README.md.
apps/web/public/fonts/*.woff2
apps/web/public/fonts/*.woff
apps/web/public/fonts/*.ttf
apps/web/public/fonts/*.otf
# Locally uploaded media lives in the cms-uploads volume, not in the image.
apps/cms/public/uploads/*
!apps/cms/public/uploads/.gitkeep
# Nothing here changes the built artefact; excluding it keeps the layer cache warm.
.git
.github
.vscode
.idea
docs
**/*.log
**/.DS_Store
**/coverage
**/*.tsbuildinfo
+35
View File
@@ -0,0 +1,35 @@
# https://editorconfig.org
# One formatting contract for every editor working on SHARP-ER.
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 100
# Markdown keeps trailing whitespace: two spaces are a hard line break.
[*.md]
trim_trailing_whitespace = false
max_line_length = off
# Makefiles are tab-indented by definition.
[Makefile]
indent_style = tab
[*.{yml,yaml}]
indent_size = 2
[*.{json,jsonc}]
indent_size = 2
[*.sh]
indent_size = 2
# Lockfiles and generated files are written by tooling - leave them alone.
[{pnpm-lock.yaml,package-lock.json}]
insert_final_newline = unset
trim_trailing_whitespace = unset
+114
View File
@@ -0,0 +1,114 @@
# ============================================================================
# SHARP-ER - root environment template
# ----------------------------------------------------------------------------
# Copy once, then fill in: cp .env.example .env
# Generate the CMS secrets: ./scripts/generate-secrets.sh --write .env
#
# WHO READS THIS FILE
# * docker compose - every ${VARIABLE} in docker-compose.yml is read from
# the .env file sitting next to it (this one).
# * apps/web - the React Router server reads STRAPI_URL / STRAPI_TOKEN
# / SITE_URL from its process environment. In manual mode
# nothing loads this file for you, so export it first:
# set -a; . ./.env; set +a
# The defaults below are also the code defaults, so a
# plain `pnpm dev` works without exporting anything.
# * apps/cms - Strapi does NOT read this file. In manual mode it reads
# apps/cms/.env (cp apps/cms/.env.example apps/cms/.env).
# Under Docker the values below are injected into the
# container instead, so apps/cms/.env is not used there.
#
# Never commit .env. Never paste a real secret back into this template.
# ============================================================================
# ---------------------------------------------------------------------------
# DATABASE
# Manual mode: your local PostgreSQL 16 instance (scripts/setup-db.sh uses
# these values). Docker mode: the postgres service is created with them.
# ---------------------------------------------------------------------------
DATABASE_NAME=sharper_cms
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
# Manual mode only - where the local server listens. Inside Docker the CMS
# always reaches the database as postgres:5432 on the compose network.
DATABASE_HOST=127.0.0.1
DATABASE_PORT=5432
# Docker mode only - host port the containerised database is published on.
# 5433 by default so it cannot collide with a PostgreSQL you already run on
# 5432. Connect with: psql -h 127.0.0.1 -p 5433 -U postgres sharper_cms
POSTGRES_PUBLISHED_PORT=5433
# ---------------------------------------------------------------------------
# CMS (Strapi 5) SECRETS
# Required. Every value must be a fresh random string - generate them with
# ./scripts/generate-secrets.sh --write .env
# or by hand with `openssl rand -base64 32`.
# APP_KEYS is a comma-separated list (at least two, so keys can be rotated).
# Rotating APP_KEYS logs everyone out; rotating ENCRYPTION_KEY makes values
# already encrypted in the database unreadable.
# ---------------------------------------------------------------------------
APP_KEYS=
API_TOKEN_SALT=
ADMIN_JWT_SECRET=
JWT_SECRET=
TRANSFER_TOKEN_SALT=
ENCRYPTION_KEY=
# ---------------------------------------------------------------------------
# PORTS
# Host ports the two applications are published on in Docker mode, and the
# ports they listen on in manual mode.
# ---------------------------------------------------------------------------
# React Router server (production build / Docker). `pnpm dev:web` uses 5173.
WEB_PORT=3000
# Strapi API + admin panel.
CMS_PORT=1337
# Docker mode only - the absolute URL the admin panel and Strapi's generated
# links use. Change it together with CMS_PORT, and set it to the public origin
# in production (e.g. https://cms.sharper.ly).
CMS_PUBLIC_URL=http://localhost:1337
# ---------------------------------------------------------------------------
# WEB APP
# ---------------------------------------------------------------------------
# Public origin of the site. Used for canonical URLs, hreflang alternates and
# JSON-LD, so it must match how visitors actually reach the site.
# Leave it unset in local development and the request's own host is used.
SITE_URL=http://localhost:3000
# Where the server-side loaders fetch content from, MANUAL MODE.
STRAPI_URL=http://localhost:1337
# Where they fetch from inside Docker, where "cms" is the compose service name.
# NOTE: this origin is also prefixed onto uploaded media URLs, which the
# browser has to be able to resolve too - see "Images from the CMS" in the
# Docker section of README.md.
DOCKER_STRAPI_URL=http://cms:1337
# Optional Strapi API token. Only needed to read draft or otherwise protected
# content; published content is public. Create it in the admin panel under
# Settings -> API Tokens, then paste the value here (it is shown once).
STRAPI_TOKEN=
# ---------------------------------------------------------------------------
# CMS RUNTIME
# ---------------------------------------------------------------------------
# Browser origins allowed to call the API, comma separated. localhost:5173 and
# 127.0.0.1:5173 are always allowed outside production and need not be listed.
CORS_ORIGINS=http://localhost:3000
# Log verbosity: fatal | error | warn | info | http | debug | trace
LOG_LEVEL=info
+81
View File
@@ -0,0 +1,81 @@
# ============================================================================
# SHARP-ER - repository ignore rules
# Anything listed here is either generated, machine-local, or licensed
# material that must not be redistributed through this repository.
# ============================================================================
# --- Dependencies ----------------------------------------------------------
node_modules/
.pnpm-store/
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/releases
# --- Build output ----------------------------------------------------------
# apps/web (React Router framework mode)
apps/web/build/
apps/web/.react-router/
# apps/cms (Strapi 5: dist/ is the compiled server, build/ the admin panel)
apps/cms/dist/
apps/cms/build/
apps/cms/.strapi/
apps/cms/.tmp/
apps/cms/.cache/
apps/cms/exports/
apps/cms/.strapi-updater.json
apps/cms/.strapi-cloud.json
apps/cms/license.txt
# Uploaded media lives in the database-backed media library, not in git.
apps/cms/public/uploads/*
!apps/cms/public/uploads/.gitkeep
# --- Environment -----------------------------------------------------------
# Real secrets never enter git; the *.example templates always do.
.env
.env.*
!.env.example
!.env.*.example
# --- Logs and diagnostics --------------------------------------------------
*.log
npm-debug.log*
yarn-error.log
pnpm-debug.log*
logs/
pids/
*.pid
coverage/
.tsbuildinfo
*.tsbuildinfo
.eslintcache
# --- Local databases / dumps ----------------------------------------------
*.sqlite
*.sqlite3
*.db
*.sql
!scripts/**/*.sql
# --- Editors and OS --------------------------------------------------------
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
.idea/
.vscode/*
!.vscode/extensions.json
!.vscode/settings.example.json
*.swp
*.swo
*~
.nbproject/
# --- Agent / tooling scratch ----------------------------------------------
.agents/**/tmp/
+3
View File
@@ -0,0 +1,3 @@
# Strapi and React Router both expect a flat-ish tree for their plugin resolution.
node-linker=hoisted
engine-strict=false
+1
View File
@@ -0,0 +1 @@
22.23.2
+130
View File
@@ -0,0 +1,130 @@
# CLAUDE.md — working brief for agent sessions
Read this before touching anything. Human setup instructions live in [README.md](README.md);
this file is the short version plus the rules that are easy to break.
## What the project is
**SHARP-ER** — *Shared Action on Rule of Law Progress – Exit Route*. EU-funded, nationwide
Libya, December 2025 – December 2027. Implemented by **Acted** (lead), **United Efforts
Consultancy (UEC)** and **Stichting Bureau Ensemble (SBE)**. Supports Libyan civil society,
local institutions and communities on justice, accountability and governance. Successor to
SHARP I and II (2019–2024).
Tone in all copy: professional, neutral, factual, inclusive, non-confrontational.
**Not** political, **not** advocacy, **not** campaign-style.
## Architecture in one screen
```
apps/web React Router 8 (framework mode, ssr: true) · React 19 · Vite 8 · Tailwind 4
SSR only reads the CMS from loaders. No client-side fetch on first paint.
/ 302 (Vary: Accept-Language) -> /en or /ar
/:lang locale layout, validates params.lang, provides { locale, dir }
/:lang/slug ten top-level sections + detail routes
Route ids: root · locale · home · about · whatWeDo · activities · events ·
applications · news · contact · resources · knowledgeHub (+ *Detail)
apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (en default, ar) · draft & publish
src/api/** content types (schema.json is the contract, commit it)
src/bootstrap/** runs on every boot: creates locales, grants Public permissions
(find/findOne on content; create-only on submission types)
config/ server · admin · database · middlewares (CORS) · plugins
docs/ Source material: ToR, approved EN/AR draft copy, brand guideline. READ ONLY.
```
Data flow: browser → React Router SSR loader → Strapi REST (`~/lib/strapi`, server only) →
PostgreSQL. `STRAPI_TOKEN` never reaches the browser (`~/lib/env.server.ts` is `.server`, and
the bundler enforces it). Every page falls back to bundled draft copy when the CMS is
unreachable — the site must never 500 because Strapi is down.
## Hard rules
1. **Design tokens are two-tier. Components use tier 2 only.**
Tier 1 (`app/styles/primitives.css`) holds raw values; tier 2 (`semantic.css`,
`typography.css`) holds meanings. Never a raw hex, never a tier-1 primitive, never a raw
`font-size` / `line-height` — use the type-role utilities (`display-sm`, `heading-h2`,
`body-md`, `label-md`, …).
Check: `grep -rn "#[0-9a-fA-F]\{6\}\|--color-green\|--color-neutral\|--color-red" apps/web/app/components apps/web/app/routes`
→ must return nothing.
2. **Logical properties only. Never `left` / `right`.**
`ps-` `pe-` `ms-` `me-` `start-` `end-`, `margin-inline-start`, `text-align: start`.
The site ships RTL Arabic; physical directions silently break it.
Check: `grep -rnE "\b(pl|pr|ml|mr|left|right)-[0-9]" apps/web/app` → nothing meaningful.
3. **Surfaces, not a theme.** `:root` *is* the dark surface; `data-surface="light"` flips a
subtree and repaints itself. Do not add a global theme toggle or a `dark:` variant.
4. **Figma is READ ONLY.** Only `get_design_context`, `get_screenshot`, `get_metadata`,
`get_variable_defs`. Never call anything that writes (`use_figma`, `generate_figma_design`,
`create_new_file`, `upload_assets`, shaders, plugins, …). Before the first
`get_design_context`, read `skill://figma/figma-design-to-code/SKILL.md` and pass
`skillNames: "resource:figma-design-to-code"`. File key `yvKHp3Ha2rQmv9kt9tCC7j`.
5. **Never invent statistics, dates, names or quotes.** If a number is not in `docs/` or in
the CMS, it does not go on the page. Leave the slot empty or omit the component.
6. **`docs/` is read-only source material.** Never edit, move or "clean up" those files.
7. **Both locales, always.** New UI text goes into `app/i18n/messages/en.json` *and*
`ar.json` with identical keys (the `Messages` type enforces it). A component that hard-codes
an English string is a bug — that is why `Card.Content`'s `actionLabel` is required.
8. **EU visibility is contractual.** "Funded by the European Union" badge top-right, project
logo top-left, the three partner logos always shown together, no standalone partner
branding. Do not restyle or omit them.
9. **No long-running servers or installs without being asked.** Do not run `pnpm install`,
`pnpm dev`, `strapi develop` or `docker compose up` speculatively. If you need a new
dependency, report it instead of installing it.
10. **No `?lang=` parameter, ever.** The locale is a URL prefix on both languages.
## Where things live
| You need | It is in |
| --- | --- |
| Tokens, surfaces, type roles | `apps/web/app/styles/` (+ its own `README.md`) |
| UI components | `apps/web/app/components/ui/{Button,Input,Card,Navigation,Footer,LanguageSwitcher}` |
| Page modules | `apps/web/app/routes/*.tsx`; shared kits in `routes/_page-kit/`, `routes/_shared/`, `routes/_events-opportunities/` |
| Route config, slugs | `apps/web/app/routes.ts`, `apps/web/app/lib/route-slugs.ts` |
| i18n | `apps/web/app/i18n/` (`config.ts`, `paths.ts`, `t.ts`, `format.ts`, `messages/*.json`) |
| Strapi client, SEO, env | `apps/web/app/lib/{strapi.ts,seo.ts,env.server.ts,route-handle.ts,site-navigation.ts}` |
| HTML shell, canonical, hreflang | `apps/web/app/root.tsx` |
| Content types | `apps/cms/src/api/<name>/content-types/<name>/schema.json` |
| Boot-time locale + permission setup | `apps/cms/src/bootstrap/` |
| CORS, upload rules, i18n locales | `apps/cms/config/{middlewares.ts,plugins.ts}` |
## Conventions that bite
- **`meta` reads `loaderData`, not `data`.** In React Router 8, `matches[0].data` is
`undefined`; use `readRootSeo(matches)` from `~/routes/_shared/listing`.
- **Do not pass `alternates: true` to `buildMeta`.** `root.tsx` emits the canonical and the
`hreflang` alternates for every route; a leaf that repeats them duplicates the tags. Use
`handle.canonicalPath` to override the canonical.
- **`app/routes.ts` existence-checks section modules** and Vite does not re-evaluate it when a
file appears — restart the dev server after adding a route module.
- **Strapi 5 addresses entries by `documentId`**, not by a numeric `id`, and localisation is a
`locale` query parameter on the same entry.
- **Submission content types are write-only for the public** (`create`, never `find`). They
hold personal data. The field is `language`, not `locale` — `locale` is reserved by the i18n
plugin.
- **`.server.ts` files never reach the browser.** Anything touching `STRAPI_TOKEN`, the
database or `process.env` belongs in one.
- **Licensed fonts are not in the repository** (`.gitignore` excludes `public/fonts/*.woff2`).
Expect fallback typefaces locally; never commit a font file.
## Verifying a change
```bash
pnpm typecheck # react-router typegen + tsc (web; the CMS is checked by its build)
pnpm --filter web build # the SSR build must succeed
bash -n scripts/<name>.sh # any shell script you touched
docker compose config # any docker-compose.yml change (needs the docker CLI)
```
Then, in a browser or with `curl`, on **both** locales:
```bash
curl -sI http://localhost:5173/ # 302 + Vary: Accept-Language
curl -s http://localhost:5173/en | head -c 200
curl -s http://localhost:5173/ar | head -c 200 # lang="ar" dir="rtl" in the first bytes
```
Checklist before calling a UI change done: renders in **both** locales; Arabic mirrors
correctly; no raw hex or tier-1 token; no physical-direction utility; keyboard focus is
visible; text comes from the message catalogue or the CMS, not from a literal in the
component.
+906
View File
@@ -0,0 +1,906 @@
# SHARP-ER website
Bilingual (English / Arabic) website for **SHARP-ER** — *Shared Action on Rule of Law
Progress – Exit Route* — a European Union funded programme running nationwide in Libya
from December 2025 to December 2027.
SHARP-ER supports Libyan civil society organisations, local institutions and communities
working on justice, accountability and governance. It is implemented by a consortium of
**Acted** (lead), **United Efforts Consultancy (UEC)** and **Stichting Bureau Ensemble (SBE)**,
and continues the work of SHARP I and SHARP II (2019–2024).
The site publishes project information, events and registrations, calls and applications,
news from the project and its partner network, and a knowledge hub of resources and
publications — all of it in English and Arabic, with the Arabic side laid out right to left.
> EU visibility is a contractual requirement: the *Funded by the European Union* emblem is
> present on every page, and the three implementing partners are always shown together.
---
## Contents
- [The stack, and why](#the-stack-and-why)
- [Repository layout](#repository-layout)
- [Choosing a setup path](#choosing-a-setup-path)
- [Path A — manual setup](#path-a--manual-setup)
- [Path B — Docker](#path-b--docker)
- [Available scripts](#available-scripts)
- [Design tokens](#design-tokens)
- [Bilingual EN / AR](#bilingual-en--ar)
- [Adding a content type, end to end](#adding-a-content-type-end-to-end)
- [Fonts (licensed, not in this repository)](#fonts-licensed-not-in-this-repository)
- [Deployment and operations](#deployment-and-operations)
- [Troubleshooting](#troubleshooting)
- [Source documents](#source-documents)
---
## The stack, and why
| Layer | Choice | Why |
| --- | --- | --- |
| Front end | **React Router 8**, framework mode, `ssr: true` | The terms of reference require an SEO-friendly structure. Server-side rendering means every page — including the Arabic one — is delivered as complete HTML with the correct `<html lang>` and `<html dir>` in the first byte, plus canonical and `hreflang` tags a crawler can read without executing JavaScript. |
| UI | **React 19**, **TypeScript**, **Vite 8** | The framework's own toolchain. Typed loaders and route modules, so a content shape change is a compile error rather than a blank page. |
| Styling | **Tailwind CSS 4** over a **two-tier design-token** layer | Tier 1 holds raw values, tier 2 holds meanings; components only ever spend tier‑2 tokens. Re-branding is a one-line edit in one file. See [Design tokens](#design-tokens). |
| CMS | **Strapi 5.53** (TypeScript) | Self-hosted and handed over to UEC at the end of the assignment, as the ToR requires. Its i18n plugin models EN/AR as first-class locales of the same entry, and draft & publish gives editors a review step. |
| Database | **PostgreSQL 16** | Strapi's recommended production database; proper UTF‑8 and JSON support for bilingual content. |
| Runtime | **Node 22**, **pnpm 9** workspace | One lockfile, two applications, one `pnpm install`. |
The web application never talks to the database. It reads the Strapi REST API from its
**loaders on the server**, so the API token never reaches the browser, and every page falls
back to bundled draft copy if the CMS is unreachable — the site stays up when Strapi is down.
---
## Repository layout
```
sharp-er/
├── apps/
│ ├── web/ React Router 8 front end (SSR)
│ │ ├── app/
│ │ │ ├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, nav, footer
│ │ │ ├── routes.ts Route config: "/" redirect, ":lang" layout, the ten sections
│ │ │ ├── routes/ One module per page (+ shared _page-kit / _shared helpers)
│ │ │ ├── components/ui/ Button, Input, Card, Navigation, Footer, LanguageSwitcher
│ │ │ ├── styles/ Design tokens — primitives.css, semantic.css, typography.css
│ │ │ ├── i18n/ Locale config, message catalogues (en.json / ar.json), formatters
│ │ │ └── lib/ strapi.ts (API client), seo.ts, env.server.ts, route-slugs.ts
│ │ ├── public/
│ │ │ ├── assets/ Logos, EU emblem, partner logos, icons
│ │ │ └── fonts/ Licensed woff2 files — NOT committed, see FONTS below
│ │ └── Dockerfile
│ │
│ └── cms/ Strapi 5 CMS
│ ├── config/ server, admin, database, middlewares (CORS), plugins (i18n, upload)
│ ├── src/
│ │ ├── api/ One folder per content type (schema, controller, routes, service)
│ │ ├── components/ Reusable field groups (hero, cta, seo, blocks/*)
│ │ └── bootstrap/ Boot-time reconciliation: locales + public permissions
│ ├── .env.example Every Strapi variable, documented
│ └── Dockerfile
│
├── docs/ SOURCE MATERIAL — read only, never edited
├── scripts/ setup-db.sh, generate-secrets.sh, check-prereqs.sh
├── docker-compose.yml postgres + cms + web
├── .env.example Root template: docker compose and the web server read this
├── CLAUDE.md Working brief for AI agent sessions
└── pnpm-workspace.yaml
```
---
## Choosing a setup path
Both paths give you the same three processes. Pick one; you do not need both.
| | **Path A — manual** | **Path B — Docker** |
| --- | --- | --- |
| You install | Node 22, pnpm 9, PostgreSQL 16 | Docker Engine + Compose v2 |
| Best for | day-to-day development (hot reload, fast rebuilds) | trying the project out, review builds, servers |
| Web | `http://localhost:5173` (dev server) | `http://localhost:3000` (production build) |
| CMS | `http://localhost:1337/admin` | `http://localhost:1337/admin` |
| Database | your local PostgreSQL, port 5432 | container, published on port **5433** |
| Time to first run | ~5 minutes | ~10 minutes (image build) |
Check your machine before you start:
```bash
./scripts/check-prereqs.sh # both paths
./scripts/check-prereqs.sh manual # or just one
./scripts/check-prereqs.sh docker
```
---
## Path A — manual setup
### 1. Prerequisites
| Tool | Version | Notes |
| --- | --- | --- |
| **Node.js** | **>= 22.22.0** | React Router 8 will not run on anything older — this is a hard floor, not a recommendation. `.nvmrc` pins **22.23.2**, the version this repository is developed on. Strapi 5 accepts Node 20 – 26, so 22 satisfies both. |
| **pnpm** | **9.x** (repo pins `pnpm@9.12.3`) | npm and yarn will not work: the workspace, the lockfile and the hoisted `node_modules` layout in `.npmrc` are all pnpm's. |
| **PostgreSQL** | **16** (server + client tools) | Client tools give you `psql`, `createdb` and `pg_isready`. |
| **Git** | any recent version | |
```bash
# Node — with nvm, the pinned version is one command
nvm install "$(cat .nvmrc)" && nvm use
# pnpm — corepack ships with Node
corepack enable && corepack prepare pnpm@9.12.3 --activate
# PostgreSQL 16 on Debian / Ubuntu
sudo apt install postgresql-16 postgresql-client-16
sudo systemctl enable --now postgresql
# macOS with Homebrew
brew install node@22 pnpm postgresql@16 && brew services start postgresql@16
```
Verify: `node -v` → `v22.22.0` or newer, `pnpm -v` → `9.x`, `psql --version` → `16.x`.
### 2. Install dependencies
```bash
git clone <repository-url> sharp-er
cd sharp-er
pnpm install # installs BOTH apps from the single root lockfile
```
Always run `pnpm install` from the repository root. `.npmrc` sets `node-linker=hoisted`
because Strapi's plugin resolution expects a flat `node_modules`; installing inside
`apps/cms` or `apps/web` on its own produces a tree neither application can boot from.
### 3. Create the database
The database is called `sharper_cms` and is owned by the role Strapi connects as
(`postgres` by default).
```bash
./scripts/setup-db.sh
```
The script is idempotent — it creates the database only if it is missing, never drops
anything, and finishes by proving it can connect and that the role may create tables.
It reads its defaults from `.env` and `apps/cms/.env`, and takes
`--name`, `--user`, `--password`, `--host`, `--port` overrides (`--help` for the details).
Doing it by hand is two commands:
```bash
createdb -h 127.0.0.1 -U postgres sharper_cms
psql -h 127.0.0.1 -U postgres -d sharper_cms -c "SELECT current_database(), version();"
```
<details>
<summary>If <code>psql</code> asks for a password you do not have (fresh Ubuntu install)</summary>
A newly installed PostgreSQL has a `postgres` role with no password set, reachable only
over the local socket. Set one, then use it as `DATABASE_PASSWORD` in `apps/cms/.env`:
```bash
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
```
For a shared or production machine, create a dedicated role instead:
```bash
sudo -u postgres psql -c "CREATE ROLE sharper WITH LOGIN PASSWORD 'a-strong-password' CREATEDB;"
./scripts/setup-db.sh --user sharper --password 'a-strong-password'
```
</details>
### 4. Environment files
Two files, with different jobs:
| File | Read by | Required in manual mode |
| --- | --- | --- |
| `apps/cms/.env` | Strapi (it loads its own `.env`) | **yes** |
| `.env` (root) | `docker compose`, and optionally the web server | no — the code defaults already point at `localhost` |
```bash
# CMS: copy the fully documented template, then fill in the six secrets
cp apps/cms/.env.example apps/cms/.env
./scripts/generate-secrets.sh --write apps/cms/.env
# Set the database credentials you used in step 3
# DATABASE_CLIENT=postgres
# DATABASE_HOST=127.0.0.1
# DATABASE_PORT=5432
# DATABASE_NAME=sharper_cms
# DATABASE_USERNAME=postgres
# DATABASE_PASSWORD=...
```
`scripts/generate-secrets.sh` fills `APP_KEYS`, `API_TOKEN_SALT`, `ADMIN_JWT_SECRET`,
`JWT_SECRET`, `TRANSFER_TOKEN_SALT` and `ENCRYPTION_KEY` with 32 random bytes each. It never
overwrites a value that is already set unless you pass `--force`, so it is safe to re-run.
Without `--write` it just prints a block you can paste. By hand:
`openssl rand -base64 32`, once per key (`APP_KEYS` takes at least two, comma separated).
The web application reads `STRAPI_URL`, `STRAPI_TOKEN` and `SITE_URL` from its **process
environment**. Nothing loads the root `.env` for you in manual mode, and you do not need it:
`STRAPI_URL` already defaults to `http://localhost:1337`, and with `SITE_URL` unset the
canonical URL is derived from the incoming request. To override them anyway:
```bash
cp .env.example .env
set -a; . ./.env; set +a # export it into the current shell
pnpm dev
```
### 5. Run both applications
```bash
pnpm dev # web + cms together, output interleaved and prefixed
```
or, in two terminals:
```bash
pnpm dev:cms # Strapi -> http://localhost:1337
pnpm dev:web # React Router -> http://localhost:5173
```
The first `pnpm dev:cms` takes a minute: Strapi creates its tables, then the boot-time
reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on
every boot; it
- creates the `en` (default) and `ar` locales if they are missing, and
- grants the **Public** role `find` / `findOne` on the content types the site reads, and
`create` — and only `create` — on the three submission types (event registrations,
applications, contact messages), so nobody can read other people's personal data back out.
You never have to click through the Users & Permissions screens after adding a content type;
restart the CMS and the grant is there.
### 6. Create the first admin user
Strapi has no seeded administrator. On first run, open
**http://localhost:1337/admin**
and complete the registration form — the first account created becomes the super admin.
Or do it from the command line:
```bash
pnpm --filter cms exec strapi admin:create-user \
--firstname="Site" --lastname="Admin" \
--email="admin@example.org" --password="SomeStrongPassw0rd"
```
Passwords must be at least 8 characters and contain a number and an uppercase letter.
### 7. Confirm it works
| URL | Expected |
| --- | --- |
| `http://localhost:5173/` | 302 to `/en` or `/ar` depending on your `Accept-Language` |
| `http://localhost:5173/en` | English home page, `<html lang="en" dir="ltr">` |
| `http://localhost:5173/ar` | Arabic home page, `<html lang="ar" dir="rtl">`, mirrored layout |
| `http://localhost:1337/admin` | Strapi admin panel |
| `http://localhost:1337/api/home-page?locale=en` | JSON (or `404` until an entry is published) |
| `http://localhost:1337/_health` | `204 No Content` |
```bash
# The one check worth automating: is the SSR HTML right in the first byte?
curl -s http://localhost:5173/ar | head -c 200
```
Then run the type checker once, so you know the baseline is clean:
```bash
pnpm typecheck
```
---
## Path B — Docker
Three containers: **postgres** (database), **cms** (Strapi), **web** (React Router).
Nothing else is installed on your machine — no Node, no pnpm, no PostgreSQL.
### 1. Prerequisites
- **Docker Engine 24+** (or Docker Desktop) with the **Compose v2** plugin.
Check with `docker compose version`; the standalone `docker-compose` binary is *not*
supported — this file uses `depends_on.condition`, which is Compose v2 syntax.
- Roughly 3 GB of disk for the images and 2 GB of RAM while they build.
### 2. Configure
```bash
cp .env.example .env
./scripts/generate-secrets.sh --write .env
```
`docker compose` reads the `.env` file sitting next to `docker-compose.yml` and substitutes
every `${VARIABLE}` in it. The six Strapi secrets are declared as `${VAR:?...}`, so a missing
value stops the stack straight away with a readable message instead of a Strapi crash loop.
In Docker mode `apps/cms/.env` is **not used** — the container receives its environment from
`docker-compose.yml`. The root `.env` is the only file you edit.
### 3. Start
```bash
docker compose up -d --build # first run: builds both images, ~5-10 minutes
docker compose ps # STATUS column shows (healthy) when each is up
```
Start-up is ordered by health, not by luck:
```
postgres healthy -> cms healthy -> web
pg_isready GET /_health GET /en
```
`cms` waits for a database that is actually accepting connections (`pg_isready`), and `web`
waits for a CMS that answers `/_health`, so the very first page render already has content.
The health checks themselves are declared in `apps/cms/Dockerfile` and `apps/web/Dockerfile`,
which means `docker run` and any orchestrator inherit them too.
Give the CMS up to 90 seconds on its first boot: it creates its tables, then runs the
locale and permission reconciliation described in Path A, step 5.
```bash
docker compose logs -f # everything, following
docker compose logs -f cms # one service
docker compose logs --tail=50 web
```
### 4. Create the first admin user
Open **http://localhost:1337/admin** and register — the first account is the super admin.
From the command line:
```bash
docker compose exec cms pnpm exec strapi admin:create-user \
--firstname="Site" --lastname="Admin" \
--email="admin@example.org" --password="SomeStrongPassw0rd"
```
### 5. Confirm it works
| URL | Expected |
| --- | --- |
| `http://localhost:3000/` | 302 to `/en` or `/ar` |
| `http://localhost:3000/en` and `/ar` | English and Arabic home pages |
| `http://localhost:1337/admin` | Strapi admin panel |
| `psql -h 127.0.0.1 -p 5433 -U postgres sharper_cms` | a database prompt |
### What the compose file sets up
| Service | Image | Host port | Persists in | Health check |
| --- | --- | --- | --- | --- |
| `postgres` | `postgres:16-alpine` | **5433** → 5432 | volume `sharper_postgres-data` | `pg_isready` |
| `cms` | built from `apps/cms/Dockerfile` | 1337 | volume `sharper_cms-uploads` (Media Library) | `GET /_health` |
| `web` | built from `apps/web/Dockerfile` | 3000 | nothing — stateless | `GET /en` |
The database is published on **5433** on purpose, so it cannot collide with a PostgreSQL
already listening on your host's 5432. Inside the compose network the CMS still reaches it as
`postgres:5432`. Change the published port with `POSTGRES_PUBLISHED_PORT` in `.env`.
Both images build from the **repository root** as their context, because the pnpm lockfile
lives there. They are not size-optimised: the CMS image keeps its dev dependencies, because
Strapi's CLI resolves its TypeScript toolchain when it starts.
### Images from the CMS
Uploaded media is served by Strapi, and the web server turns a relative `/uploads/x.jpg`
into an absolute URL by prefixing `STRAPI_URL`. Inside Docker that value is `http://cms:1337`,
a hostname Docker resolves but your browser does not — so pictures uploaded through the Media
Library will not load until it can. One line in your hosts file fixes it, permanently:
```bash
echo "127.0.0.1 cms" | sudo tee -a /etc/hosts # Windows: C:\Windows\System32\drivers\etc\hosts
```
`cms` then resolves to `127.0.0.1`, where compose publishes the CMS on port 1337, so the one
URL works from the browser and from inside the compose network alike. Everything else —
pages, text, layout, and the site's own images in `apps/web/public/assets/` — works without
this step.
In production this problem disappears: the CMS has a real hostname
(`https://cms.sharper.ly`), and `STRAPI_URL` is set to it.
### Everyday commands
```bash
docker compose ps # status and health
docker compose restart cms # restart one service
docker compose exec cms sh # shell inside the CMS container
docker compose exec postgres psql -U postgres sharper_cms
docker compose up -d --build web # rebuild after changing app code
docker compose build --no-cache cms # rebuild ignoring the layer cache
```
Source code is **not** bind-mounted: the images contain a production build, so a code change
needs a rebuild. For day-to-day development use Path A, or run only the database in Docker
(`docker compose up -d postgres`) and the two applications with `pnpm dev`.
### Stopping and tearing down
| Command | Containers | Database + uploads |
| --- | --- | --- |
| `docker compose stop` | stopped, kept | kept |
| `docker compose down` | removed | **kept** (named volumes survive) |
| `docker compose down -v` | removed | **deleted — irreversible** |
| `docker compose down --rmi local` | removed, images too | kept |
`down -v` is how you start from an empty database — every entry, every uploaded file and
the admin account are gone. Take a dump first if you are unsure:
```bash
# Back up
docker compose exec -T postgres pg_dump -U postgres sharper_cms > backup-$(date +%F).sql
# Restore into a fresh stack
docker compose exec -T postgres psql -U postgres sharper_cms < backup-2026-01-31.sql
# Uploaded media
docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
tar czf /backup/uploads-$(date +%F).tar.gz -C /data .
```
---
## Available scripts
**Repository root** (`package.json`) — these fan out across the workspace:
| Command | Does |
| --- | --- |
| `pnpm dev` | runs `web` and `cms` in parallel, streaming both logs |
| `pnpm dev:web` | React Router dev server with HMR → `http://localhost:5173` |
| `pnpm dev:cms` | `strapi develop` (auto-reloads on schema changes) → `http://localhost:1337` |
| `pnpm build` | builds the CMS, then the web app (that order: the web build has no CMS dependency, but a broken CMS build should fail first) |
| `pnpm start` | runs both production builds |
| `pnpm typecheck` | the web app's `react-router typegen && tsc` (the CMS has no `typecheck` script — its TypeScript is checked by `strapi build`) |
**`apps/web`** — run with `pnpm --filter web <script>`:
| Command | Does |
| --- | --- |
| `dev` | `react-router dev` — SSR dev server with HMR |
| `build` | `react-router build` → `build/client` (static assets) + `build/server` (SSR bundle) |
| `start` | `react-router-serve ./build/server/index.js` — the production server, port 3000 |
| `typecheck` | regenerates `+types/*` route types, then `tsc` |
**`apps/cms`** — run with `pnpm --filter cms <script>`:
| Command | Does |
| --- | --- |
| `develop` / `dev` | `strapi develop` — the only mode in which the Content-Type Builder can write schemas |
| `build` | builds the admin panel and compiles the TypeScript server into `dist/` |
| `start` | `strapi start` — production mode; the Content-Type Builder is disabled |
| `console` | a REPL with `strapi` in scope, for one-off data fixes |
| `strapi` | the CLI itself, e.g. `pnpm --filter cms exec strapi admin:create-user` |
| `upgrade` / `upgrade:dry` | `@strapi/upgrade` codemods for a minor/major version bump |
**`scripts/`** — repository helpers, all read-only unless you pass `--write`:
| Script | Does |
| --- | --- |
| `./scripts/check-prereqs.sh [all\|manual\|docker]` | verifies Node, pnpm, PostgreSQL, Docker and the env files; exits non-zero if something required is missing |
| `./scripts/setup-db.sh` | idempotent `createdb` + connection/permission verification (Path A, step 3) |
| `./scripts/generate-secrets.sh [--write FILE] [--force]` | fresh Strapi secrets; fills only empty/placeholder keys unless forced |
---
## Design tokens
Two tiers, so re-branding is a one-line edit in one file.
```
app/app.css entry: tailwindcss, then the three files below
app/styles/primitives.css TIER 1 raw values, no meaning (@theme)
app/styles/semantic.css TIER 2 roles that alias tier 1 (per surface)
app/styles/typography.css TIER 2 type roles, @font-face, EN/AR font switch
```
**The one rule: components may reference only tier-2 tokens.** Never a raw hex, never a
tier-1 primitive.
```tsx
<h2 className="heading-h2 text-[var(--text-primary)]" /> // yes
<h2 className="text-[#f8f9fa] text-[28px]" /> // no — hex and raw type
<h2 className="text-[var(--color-neutral-50)]" /> // no — tier-1 primitive
```
Because every component spends meanings rather than values, moving the brand green is four
lines in `primitives.css` (`--color-green-600` and its hover / active / disabled steps) and
no component changes at all.
The home page is **dark by default**, while the navigation and footer also have light
variants, so the palette is modelled as **surfaces, not a global theme**: `:root` *is* the
dark surface, and any element can flip its own subtree with `data-surface="light"`, which
re-resolves every tier-2 token and paints itself.
Brand colours are White `#ffffff`, Dark Grey `#1D1D1B`, Dark Turquoise `#008764` and
Red `#DD322F`, taken from the communication guideline in `docs/`.
Full reference — every token family, the Tailwind bridge, the surface model, the type roles
and the one deliberate deviation from Figma: **[`apps/web/app/styles/README.md`](apps/web/app/styles/README.md)**.
---
## Bilingual EN / AR
**URLs carry an explicit locale prefix on both languages.** There is no `?lang=` parameter
anywhere in the product.
```
/ 302, Vary: Accept-Language -> /en or /ar
/en, /en/about, ... English, ltr
/ar, /ar/about, ... Arabic, rtl
/EN/about 301 -> /en/about
```
The slug itself is always the Latin one (`/ar/knowledge-hub`, not a transliterated path), so
a URL survives a language switch and analytics can aggregate the two.
`<html lang>` and `<html dir>` are correct **in the first SSR byte** — they are attributes of
the server-rendered document, not something JavaScript sets afterwards. `root.tsx` also emits
a canonical link and `hreflang` alternates for `en`, `ar` and `x-default` on every page.
### Where the words live
| Content | Lives in | Edited by |
| --- | --- | --- |
| UI strings (navigation, buttons, filters, form labels, error messages) | `apps/web/app/i18n/messages/en.json` and `ar.json` — identical key sets, enforced by the `Messages` type | developers |
| Page and article content | Strapi, as an `en` and an `ar` version of the same entry | editors |
| Draft fallback copy | `apps/web/app/routes/_page-kit/copy.ts`, `_shared/page-copy.ts` — the approved prose used when the CMS is empty or unreachable | developers |
Read a string with the `useTranslation()` hook in a component, or `translate(locale, key)` in
a loader; keys are dotted paths (`t("pagination.page", { page: 2, pageCount: 7 })`) and are
typechecked against `en.json`, so a missing translation is a build error.
### Right-to-left
RTL is not a stylesheet; it is a consequence of using **CSS logical properties everywhere**.
Write `margin-inline-start`, `padding-inline`, `inset-inline-start`, `text-align: start`,
`border-inline-start` — and in Tailwind the logical utilities `ps-` `pe-` `ms-` `me-`
`start-` `end-`, never `pl-` `pr-` `ml-` `mr-` `left-` `right-`. The Arabic layout then
mirrors for free. `rtl:` and `ltr:` variants exist for the rare case that genuinely cannot be
expressed logically — an asymmetric icon, a background position.
Dates and numbers go through `~/i18n` (`formatDate`, `formatDateRange`, `formatNumber`,
`formatFileSize`), which uses `en-GB` and `ar-LY` with Western digits, matching the approved
Arabic copy.
### Adding a third locale
The types are exhaustive `Record<Locale, …>` maps, so after step 1 the compiler lists every
place that still has to be filled in.
1. `apps/web/app/components/ui/LanguageSwitcher/locale-path.ts` — add the code to `LOCALES`,
and an entry to `DIRECTION_BY_LOCALE`, `LOCALE_ENDONYM` and `LOCALE_SHORT_LABEL`.
2. `apps/web/app/i18n/config.ts` — add it to `INTL_LOCALE` and `OG_LOCALE`.
3. `apps/web/app/i18n/messages/<code>.json` — copy `en.json`, translate it, and register it in
`getMessages()`.
4. `apps/cms/config/plugins.ts` — add `{ code, name }` to the `locales` array and restart the
CMS; `src/bootstrap/locales.ts` creates it in the database on the next boot.
5. Nothing else: routing (`/:lang`), Accept-Language negotiation, the navigation, the footer
link columns and the `hreflang` alternates all derive from `SUPPORTED_LOCALES` and
`ROUTE_SLUGS`.
Note that the language switcher's UI is built for exactly two languages (it renders *the
other* one); a third locale needs it turned into a menu.
---
## Adding a content type, end to end
Worked example: a **Stories** collection, listed at `/en/stories` and `/ar/stories`.
### 1. Model it in Strapi
Run the CMS in development mode (`pnpm dev:cms`) — the Content-Type Builder only writes
schemas in `strapi develop`. Create the collection type, then in **Advanced settings** enable
**Internationalization** and **Draft & publish**.
Strapi writes the result to `apps/cms/src/api/story/` (`content-types/story/schema.json`,
plus a controller, routes and a service). **Commit that folder** — it is the schema, not a
local artefact. You can equally write the JSON by hand and restart.
Field conventions used across this project: `title` (string, required), `slug`
(uid from title, required), `summary` (text), `body` (blocks or rich text — both render),
`cover` (single media), `publicationDate` (date — the field everything sorts and filters on),
`topics` (relation to the shared `topics` taxonomy, so a tag means the same thing everywhere).
### 2. Restart the CMS, then check the API
`apps/cms/src/bootstrap/public-permissions.ts` grants the Public role `find` and `findOne`
on every `api::*` content type at boot, so there is nothing to click:
```bash
curl "http://localhost:1337/api/stories?locale=en&populate=cover"
```
A collection holding personal data is the exception — add its API name to
`PUBLIC_SUBMISSION_CONTENT_TYPES` in `apps/cms/src/bootstrap/constants.ts` and it gets
`create` only, never `find`.
### 3. Give it a URL
For a new top-level section, add the slug to `apps/web/app/lib/route-slugs.ts`
(`ROUTE_SLUGS`, and the right group in `ROUTE_SECTION_GROUPS`) and a label under
`nav.sections` in **both** `app/i18n/messages/en.json` and `ar.json`. The navigation, the
footer sitemap and `sectionPath()` all derive from those two, so nothing else needs editing.
Then register the module in `apps/web/app/routes.ts`. Section modules are picked up by their
conventional path (`SECTION_MODULES`); a detail route is one explicit line:
```ts
route("stories/:slug", "routes/stories.$slug.tsx", { id: "storyDetail" }),
```
Vite does not re-evaluate `routes.ts` when a new file appears — **restart `pnpm dev:web`**
after adding a route module.
### 4. Write the route module
`apps/web/app/routes/stories.tsx`. Fetch on the server, in the loader; the Strapi client is
server-only, and the API token never reaches the browser.
```tsx
import { buildMeta } from "~/lib/seo";
import { getMany } from "~/lib/strapi";
import { readRootSeo, requireLocale } from "~/routes/_shared/listing";
import type { LoaderFunctionArgs, MetaArgs } from "react-router";
interface Story {
documentId: string;
title: string;
slug: string;
summary?: string;
publicationDate?: string;
}
export async function loader({ params }: LoaderFunctionArgs) {
const locale = requireLocale(params.lang); // 404s on anything but en | ar
const { data } = await getMany<Story>("stories", {
locale,
sort: "publicationDate:desc",
populate: { cover: { fields: ["url", "alternativeText"] } },
pagination: { page: 1, pageSize: 12 },
});
return { locale, stories: data };
}
export function meta({ matches }: MetaArgs) {
const root = readRootSeo(matches); // { locale, pathname, siteUrl }
return buildMeta({ ...root, title: "Stories", description: "…" });
}
export default function Stories({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
return <main>{/* Card.Content, PageShell, … from the shared kit */}</main>;
}
```
For a detail page use `requireEntity(await getBySlug<Story>("stories", params.slug, { locale }))`
— `requireEntity` throws the 404 the root ErrorBoundary renders, localised.
Do **not** pass `alternates: true` to `buildMeta`: `root.tsx` already emits the canonical link
and the `hreflang` alternates for every route, and a leaf `meta` that repeats them duplicates
the tags. Use `handle.canonicalPath` to override the canonical instead.
Render with the existing kit rather than new markup: `~/components/ui` (Button, Input, Card,
…) and `app/routes/_shared/listing-ui` / `_page-kit/sections` (PageShell, PageHeader,
FilterBar, Pagination, EmptyState, RichText).
### 5. Verify
```bash
pnpm typecheck
```
Then open `/en/stories` and `/ar/stories`, confirm the Arabic page mirrors, and confirm an
unknown slug renders the localised 404 rather than a 500. If the CMS is stopped, the page
must still render its fallback copy.
---
## Fonts (licensed, not in this repository)
The brand faces are **Panton** for English and **Montserrat Arabic** for Arabic.
> **Note on the guideline.** The communication guideline PDF names the Arabic face
> "Montaser Arabic". That is a typo in the guideline — the correct face is **Montserrat
> Arabic**, confirmed with the client on 2026-09-11. Both files are supplied.
Both are **commercial typefaces licensed to the project, not to this repository** —
`.gitignore` excludes every `woff2` under `apps/web/public/fonts/`, and `.dockerignore` keeps
them out of built images. Do not commit them, and do not publish them to a CDN that serves
them outside the project's licence.
Drop the files into **`apps/web/public/fonts/`**, with exactly these names — matching is
case-sensitive in production:
| File | Family | Weight | Status |
| --- | --- | --- | --- |
| `Panton-Regular.woff2` | Panton | 400 | supplied |
| `Panton-Bold.woff2` | Panton | 700 | supplied |
| `Panton-SemiBold.woff2` | Panton | 500 | **missing** |
| `Montserrat-Arabic-Regular.woff2` | Montserrat Arabic | 400 | supplied |
| `Montserrat-Arabic-Medium.woff2` | Montserrat Arabic | 500–600 | supplied |
| `Montserrat-Arabic-Bold.woff2` | Montserrat Arabic | 700 | supplied |
Because a fresh clone has none of them, the stacks in `app/styles/primitives.css` degrade
politely and the site is fully usable without any of them:
```css
--font-en: "Panton", "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-ar: "Montserrat Arabic", "IBM Plex Sans Arabic", "Noto Sans Arabic", ui-sans-serif, system-ui, sans-serif;
```
The brand family is always **first** in the stack, so the moment a licensed file is dropped
in, it takes over — no code change, no rebuild of anything but the page. One current gap:
- **Panton SemiBold.** `label-md` asks for weight 500; with no SemiBold face declared, CSS
font matching resolves it to Panton Regular. When the cut arrives, add one `@font-face` for
`"Panton"` at `font-weight: 500 600` in `app/styles/typography.css`. Do not declare that
face before the file exists — a face that fails to load drops weight-500 text out of the
family altogether.
Under Docker, `docker-compose.yml` mounts `./apps/web/public/fonts` into the running web
container read-only, so a local container uses your licensed files without ever baking them
into an image. On a server, copy them into the deployed `build/client/fonts/` directory (or
mount them the same way) as part of the release, outside version control.
---
## Deployment and operations
### Building and running a release
```bash
pnpm install --frozen-lockfile
pnpm build # cms (admin panel + dist/) then web (build/client + build/server)
pnpm --filter cms start # strapi start :1337
pnpm --filter web start # react-router-serve :3000
```
Both are ordinary long-running Node processes. Run them under systemd, PM2 or the
containers in `docker-compose.yml`, behind a reverse proxy (nginx, Caddy) that terminates
TLS and forwards to 3000 and 1337.
### Production environment
| Variable | Where | Value |
| --- | --- | --- |
| `NODE_ENV` | both | `production` |
| `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it |
| `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server |
| `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public |
| `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links |
| `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted |
| `CORS_ORIGINS` | cms | `https://sharper.ly,https://www.sharper.ly` — an empty list blocks every cross-origin browser call, which is the safe default |
| `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
| `APP_KEYS` and the other five secrets | cms | **different values from staging**, generated with `./scripts/generate-secrets.sh` |
Uploaded media is written to `apps/cms/public/uploads` by the default provider, so give the
CMS a persistent volume — or switch to S3, which `apps/cms/config/plugins.ts` documents step
by step (remember to add the bucket host to `img-src` / `media-src` in
`config/middlewares.ts`).
### Requirements from the terms of reference
The ToR (`docs/UEC_SHARPER_Website_TOR V3.docx`, sections 3.5 and 3.6) commits the service
provider to an operating standard, not only to a delivery. In summary:
| Requirement | How it is met, and what still has to be set up |
| --- | --- |
| **Secure hosting with SSL** | Terminate TLS at the proxy (Let's Encrypt or the host's certificate), redirect HTTP to HTTPS, and enable HSTS. Strapi must run with `IS_PROXIED=true` behind it. |
| **Regular backups** | Nightly `pg_dump` of `sharper_cms` **plus** the uploads directory — the database alone does not contain the media files. Keep them off the application server and test a restore before go-live. The commands are in [Stopping and tearing down](#stopping-and-tearing-down). |
| **Security checks and updates** | Watch Strapi and Node releases; `pnpm --filter cms upgrade:dry` previews a Strapi upgrade's codemods. Rotate `STRAPI_TOKEN` and the admin accounts at handover. |
| **Analytics (e.g. Google Analytics)** | **Not yet integrated.** The tag belongs in `apps/web/app/root.tsx`, in the document `<head>`, and must respect the site's privacy notice — collect no personal data through it and document what is collected. |
| **Monthly viewership report** | Comes from the analytics property once it exists: sessions, users, top pages, EN vs AR split (the locale is in the URL prefix, so `/en/*` and `/ar/*` aggregate cleanly), referrers, devices. |
| **Errors addressed within one day of being reported** | Keep a monitored inbox or tracker, and an uptime check on `https://sharper.ly/en` and `https://cms.sharper.ly/_health`. Both applications log to stdout; capture it with the process manager. |
| **Bi-weekly content support** | Editors work in the Strapi admin panel; no deployment is needed for content. Draft & publish gives a review step before anything goes live. |
| **Fast loading, mobile responsive, basic accessibility** | SSR HTML, no client-side data fetching on first paint, `font-display: swap`, responsive layouts and logical properties throughout. Re-check with Lighthouse on both `/en` and `/ar` before each release. |
| **SEO-friendly structure** | Server-rendered HTML, one canonical per page, `hreflang` for `en` / `ar` / `x-default`, JSON-LD (Organization, WebSite, Article, Event, BreadcrumbList), and `noindex` on filtered listing permutations. A `sitemap.xml` and `robots.txt` are **not yet built** — add them before launch. |
| **CMS handover to UEC** | The schema lives in `apps/cms/src/api/**` in version control, the admin panel is self-hosted, and `.env.example` documents every variable. Hand over: the repository, the server access, the database credentials and a super-admin account. |
---
## Troubleshooting
**`Unsupported engine` / the dev server exits immediately after `pnpm dev:web`.**
React Router 8 requires **Node >= 22.22.0** — this was the first thing to bite while
bootstrapping the project. Check with `node -v`; `nvm install "$(cat .nvmrc)" && nvm use`
fixes it. `pnpm install` warns but does not stop you, because `.npmrc` sets
`engine-strict=false` so that Strapi's narrower range does not block the workspace.
**Strapi exits with `Cannot find module 'pg'` or `knex: Required dependency pg not found`.**
`DATABASE_CLIENT=postgres` needs the `pg` driver. It is already a dependency of `apps/cms`,
so this means the install is incomplete or was run in the wrong place: run `pnpm install`
from the **repository root**, never inside `apps/cms`.
**`pnpm install` inside `apps/web` or `apps/cms` produces an app that will not boot.**
Same cause. `.npmrc` sets `node-linker=hoisted` for the whole workspace; a nested install
builds a different tree from the one Strapi's plugin resolution expects.
**`password authentication failed for user "postgres"`.**
A fresh PostgreSQL install has no password for that role. See the collapsed note in
[Path A, step 3](#3-create-the-database).
**`Missing app.keys` on CMS start-up.** The six secrets are not set. Run
`./scripts/generate-secrets.sh --write apps/cms/.env` (manual) or
`./scripts/generate-secrets.sh --write .env` (Docker).
**Port already in use.** 5432 usually means a PostgreSQL is already running — in Docker mode
the container is published on 5433 for exactly that reason (`POSTGRES_PUBLISHED_PORT`). For
1337, 3000 and 5173: `ss -ltnp | grep -E '1337|3000|5173'`.
**`WARN The field "pnpm.onlyBuiltDependencies" was found in apps/cms/package.json`.**
Harmless. pnpm only honours that field at the workspace root, where it is already declared.
**A new page 404s even though the route module exists.** `app/routes.ts` existence-checks the
section modules while Vite builds the route config, and Vite does not re-evaluate it when a
file appears. Restart `pnpm dev:web`.
**`Property 'data' does not exist` in a route's `meta`, or an Arabic page renders English
metadata.** In React Router 8 a meta match exposes `loaderData`, not `data`;
`matches[0].data` is `undefined` at runtime and a type error at compile time. Use
`readRootSeo(matches)` from `~/routes/_shared/listing`.
**Type errors about `./+types/...`.** Those files are generated. Run `pnpm typecheck`
(which runs `react-router typegen` first) or `pnpm --filter web exec react-router typegen`.
**The browser reports a CORS error calling the API.** Add the origin to `CORS_ORIGINS` in the
CMS environment and restart it. `http://localhost:5173` and `http://127.0.0.1:5173` are
always allowed outside production.
**Images uploaded in the CMS do not load under Docker.** Expected until `cms` resolves in
your browser — see [Images from the CMS](#images-from-the-cms).
**`docker compose` complains about `depends_on` / `condition`.** You are on the old
`docker-compose` v1 binary. Install the Compose v2 plugin and use `docker compose` (a space,
not a hyphen).
**The site renders in a different typeface than the design.** Expected on a fresh clone: the
licensed fonts are not in the repository. See [Fonts](#fonts-licensed-not-in-this-repository).
**Everything looks broken after a schema change in Strapi.** `strapi develop` writes schema
changes to `apps/cms/src/api/**` and migrates the database; if the two disagree, stop the
CMS, check `git status` in that folder, and restart. As a last resort in **development
only**, drop and recreate the database (`dropdb sharper_cms && ./scripts/setup-db.sh`, or
`docker compose down -v`) — this destroys all content.
---
## Source documents
`docs/` holds the material this site is built from. It is **read-only source material** —
never edit it, and never invent a figure that is not in it.
| File | What it is |
| --- | --- |
| `UEC_SHARPER_Website_TOR V3.docx` | Terms of reference: scope, functional and technical requirements, deliverables, timeline |
| `SHARPER_Website_Draft_Section_EN_AR.docx` | Approved draft copy for every section, English and Arabic |
| `SHARPER Home Page .docx` | Home page copy |
| `SHARP-ER_Website Recommendations.docx` | Structure and content recommendations |
| `Sharp Updated communication guidline_ (2).pdf` | Brand: colours, typefaces, EU visibility and partner-logo rules, tone of voice |
Tone, everywhere: professional, neutral, factual, inclusive and non-confrontational. The site
is not political, not advocacy and not campaign-style.
---
## Licence and credits
Funded by the European Union. Implemented by Acted, United Efforts Consultancy and
Stichting Bureau Ensemble. The views expressed on the site are those of the implementing
consortium and do not necessarily reflect those of the European Union.
Content, branding and the licensed typefaces belong to the project and its partners; they are
not covered by any open-source licence granted over this source code.
+156
View File
@@ -0,0 +1,156 @@
# ---------------------------------------------------------------------------
# SHARP-ER CMS (Strapi 5) - environment template
#
# Copy to .env and fill in real values: cp .env.example .env
# Generate every secret/salt/key with: openssl rand -base64 32
# Never commit .env, and never copy real secrets back into this file.
# ---------------------------------------------------------------------------
# Node environment: development | production | test. Drives the CORS defaults
# in config/middlewares.ts (localhost is only allowed outside production).
NODE_ENV=development
# --- Server (config/server.ts) ---------------------------------------------
# Address Strapi binds to. 0.0.0.0 listens on every interface.
HOST=0.0.0.0
# Port the API and admin panel are served on.
PORT=1337
# Public URL of the API, used to build absolute media/admin URLs.
# Leave commented out in local development; set it behind a proxy or a custom
# domain, e.g. PUBLIC_URL=https://cms.sharper.ly
# PUBLIC_URL=
# true when Strapi sits behind nginx / a load balancer, so X-Forwarded-* headers
# are trusted for the client IP and protocol.
IS_PROXIED=false
# Log verbosity: fatal | error | warn | info | http | debug | trace
LOG_LEVEL=info
# Enable the Strapi cron runner (config/server.ts -> cron.enabled).
CRON_ENABLED=false
# Include relations in webhook payloads. Off by default: heavier payloads.
WEBHOOKS_POPULATE_RELATIONS=false
# --- Secrets (config/server.ts, config/admin.ts, config/plugins.ts) ---------
# All of these are per-environment. Rotating APP_KEYS invalidates sessions;
# rotating ENCRYPTION_KEY makes already-encrypted values unreadable.
# Comma-separated session signing keys (at least two, so keys can be rotated).
APP_KEYS="replace-with-random-key-1,replace-with-random-key-2"
# Salt used to hash API tokens.
API_TOKEN_SALT=replace-with-random-salt
# Signing secret for admin-panel sessions.
ADMIN_JWT_SECRET=replace-with-random-secret
# Signing secret for Content API (users-permissions) tokens.
JWT_SECRET=replace-with-random-secret
# Salt used to hash data-transfer tokens (strapi transfer / import / export).
TRANSFER_TOKEN_SALT=replace-with-random-salt
# Key used to encrypt secrets stored in the database (e.g. provider settings).
ENCRYPTION_KEY=replace-with-random-key
# --- Admin panel (config/admin.ts) -----------------------------------------
# Path (or absolute URL) the admin panel is served from.
ADMIN_URL=/admin
# Serve the admin panel from this process. false when it is hosted separately.
SERVE_ADMIN_PANEL=true
# Open a browser window on `strapi develop`. Keep false for headless runs.
ADMIN_AUTO_OPEN=false
# Admin panel UI flags: NPS survey, EE promotion, documentation links.
FLAG_NPS=true
FLAG_PROMOTE_EE=true
FLAG_DOC_LINKS=true
# --- CORS (config/middlewares.ts) ------------------------------------------
# Comma-separated list of browser origins allowed to call the API.
# In development http://localhost:5173 and http://127.0.0.1:5173 (the React
# Router dev server) are always allowed and need not be listed here.
# Example for production: https://sharper.ly,https://www.sharper.ly
CORS_ORIGINS=
# --- Database (config/database.ts) -----------------------------------------
# postgres | mysql | sqlite. This project runs on PostgreSQL 16.
DATABASE_CLIENT=postgres
# Full connection string. When set it takes precedence over the parts below,
# so leave it commented out to use DATABASE_HOST/PORT/NAME/USERNAME/PASSWORD.
# Example: postgres://user:password@host:5432/database
# DATABASE_URL=
# Database host.
DATABASE_HOST=127.0.0.1
# Database port (5432 for PostgreSQL, 3306 for MySQL).
DATABASE_PORT=5432
# Database name.
DATABASE_NAME=sharper_cms
# Database user.
DATABASE_USERNAME=postgres
# Database password.
DATABASE_PASSWORD=replace-with-your-database-password
# PostgreSQL schema Strapi writes its tables into.
DATABASE_SCHEMA=public
# Enable TLS for the database connection. Usually true on managed databases.
DATABASE_SSL=false
# Optional TLS material, only read when DATABASE_SSL=true.
# DATABASE_SSL_KEY=
# DATABASE_SSL_CERT=
# DATABASE_SSL_CA=
# DATABASE_SSL_CAPATH=
# DATABASE_SSL_CIPHER=
# Set to false only for a self-signed database certificate.
# DATABASE_SSL_REJECT_UNAUTHORIZED=true
# Connection pool bounds.
# DATABASE_POOL_MIN=2
# DATABASE_POOL_MAX=10
# Milliseconds to wait for a free connection before failing.
# DATABASE_CONNECTION_TIMEOUT=60000
# SQLite file, relative to apps/cms. Only used when DATABASE_CLIENT=sqlite.
# DATABASE_FILENAME=.tmp/data.db
# --- Authentication rate limit (config/plugins.ts) --------------------------
# Window in milliseconds for the users-permissions auth rate limit.
AUTH_RATE_LIMIT_INTERVAL=60000
# Maximum auth requests (login, forgot password, ...) per window per IP.
AUTH_RATE_LIMIT_MAX=5
# --- Public submission rate limit (src/middlewares/rate-limit.ts) -----------
# The three public POST endpoints (contact-submission, event-registration,
# application) are capped per client IP. The limiter is in-process, so with
# several Strapi instances the effective cap is this many PER INSTANCE - put a
# shared limiter in front of a multi-instance deployment.
# Window in milliseconds.
SUBMISSION_RATE_LIMIT_INTERVAL=60000
# Maximum submissions per window per IP, per endpoint.
SUBMISSION_RATE_LIMIT_MAX=5
# Set to false to switch the limiter off (load tests only - never in production).
# SUBMISSION_RATE_LIMIT_ENABLED=false
# --- Public account flows (src/bootstrap/auth-permissions.ts) ---------------
# This site has no end-user accounts, so every users-permissions auth endpoint
# is revoked from the Public role on each boot - `register` above all, which
# Strapi grants by default and which would let anyone create an API account.
# List here, comma-separated, only the actions that are DELIBERATELY public,
# e.g. PUBLIC_AUTH_ACTIONS=callback,forgotPassword. Empty means "none", which
# is the intended state.
# PUBLIC_AUTH_ACTIONS=
# --- Media / uploads (config/plugins.ts) ------------------------------------
# Maximum size of a single uploaded file, in bytes. Default 50 MB.
UPLOAD_MAX_FILE_SIZE=52428800
# Cache-Control max-age (ms) for files served by the local upload provider.
UPLOAD_LOCAL_MAX_AGE=300000
# Production only - switching the upload provider to S3. Uncomment these after
# adding @strapi/provider-upload-aws-s3 and editing the upload block in
# config/plugins.ts (the switch is documented there step by step).
# AWS_ACCESS_KEY_ID=
# AWS_ACCESS_SECRET=
# AWS_REGION=
# AWS_BUCKET=
# AWS_ACL=private
# AWS_SIGNED_URL_EXPIRES=900
# S3_ROOT_PATH=
# CDN_URL=
+131
View File
@@ -0,0 +1,131 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
*.sqlite3
############################
# Misc.
############################
*#
ssl
.idea
nbproject
public/uploads/*
!public/uploads/.gitkeep
.tsbuildinfo
.eslintcache
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
node_modules
.node_history
############################
# Package managers
############################
.yarn/*
!.yarn/cache
!.yarn/unplugged
!.yarn/patches
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*
yarn-error.log
############################
# Tests
############################
coverage
############################
# Strapi
############################
.env
license.txt
exports
.strapi
dist
build
.strapi-updater.json
.strapi-cloud.json
+86
View File
@@ -0,0 +1,86 @@
# syntax=docker/dockerfile:1
###############################################################################
# SHARP-ER CMS - Strapi 5 on Node 22
#
# The build context is the REPOSITORY ROOT, not apps/cms: this is a pnpm
# workspace, so pnpm-lock.yaml, pnpm-workspace.yaml and .npmrc all live one
# level up and pnpm refuses to do a frozen install without them.
#
# docker build -f apps/cms/Dockerfile -t sharper-cms .
# docker compose build cms # does exactly that
#
# Debian slim rather than Alpine: Strapi's image pipeline (sharp) ships glibc
# prebuilt binaries, so this avoids a source build of libvips.
###############################################################################
# --- base: the toolchain both stages share ----------------------------------
FROM node:22-bookworm-slim AS base
ENV PNPM_VERSION=9.12.3
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global "pnpm@${PNPM_VERSION}"
WORKDIR /repo
# --- build: install dependencies and compile the admin panel ----------------
FROM base AS build
# Manifests first, so the (slow) install layer is cached until a dependency
# actually changes. Both workspace manifests are copied even though only the
# CMS is installed: pnpm compares the lockfile against every project in the
# workspace and calls the lockfile stale if one is missing.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY apps/cms/package.json ./apps/cms/
COPY apps/web/package.json ./apps/web/
# `--filter cms...` = the CMS and anything in the workspace it depends on.
# devDependencies are needed here: `strapi build` compiles TypeScript.
RUN pnpm install --frozen-lockfile --filter cms...
COPY apps/cms ./apps/cms
# `strapi build` loads config/*.ts, which reads these. They are throwaway
# build-time values that never reach the runtime stage below - the real ones
# are injected by docker compose at start-up.
ENV NODE_ENV=production \
APP_KEYS=build-only-key-1,build-only-key-2 \
API_TOKEN_SALT=build-only \
ADMIN_JWT_SECRET=build-only \
JWT_SECRET=build-only \
TRANSFER_TOKEN_SALT=build-only \
ENCRYPTION_KEY=build-only \
DATABASE_CLIENT=sqlite
RUN pnpm --filter cms build
# --- runtime ----------------------------------------------------------------
FROM base AS runtime
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=1337
# node_modules is carried over rather than reinstalled with --prod: the Strapi
# CLI still resolves its TypeScript toolchain at start-up, so pruning
# devDependencies breaks `strapi start` on a TypeScript project.
COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/package.json /repo/pnpm-workspace.yaml /repo/.npmrc ./
COPY --from=build /repo/apps/cms ./apps/cms
# The Media Library writes here. Ownership is set before the named volume is
# mounted, so the volume inherits it on first start.
RUN mkdir -p /repo/apps/cms/public/uploads \
&& chown -R node:node /repo/apps/cms
USER node
WORKDIR /repo/apps/cms
EXPOSE 1337
# Strapi answers /_health with 204 once the HTTP server is listening. Node 22
# has fetch built in, so the image needs no curl or wget.
HEALTHCHECK --interval=15s --timeout=5s --start-period=90s --retries=10 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||1337)+'/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# `strapi start` serves the API and the pre-built admin panel from dist/.
CMD ["pnpm", "start"]
+61
View File
@@ -0,0 +1,61 @@
# 🚀 Getting started with Strapi
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/dev-docs/cli) (CLI) which lets you scaffold and manage your project in seconds.
### `develop`
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-develop)
```
npm run develop
# or
yarn develop
```
### `start`
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-start)
```
npm run start
# or
yarn start
```
### `build`
Build your admin panel. [Learn more](https://docs.strapi.io/dev-docs/cli#strapi-build)
```
npm run build
# or
yarn build
```
## ⚙️ Deployment
Strapi gives you many possible deployment options for your project including [Strapi Cloud](https://cloud.strapi.io). Browse the [deployment section of the documentation](https://docs.strapi.io/dev-docs/deployment) to find the best solution for your use case.
```
yarn strapi deploy
```
## 📚 Learn more
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
- [Strapi blog](https://strapi.io/blog) - Official Strapi blog containing articles made by the Strapi team and the community.
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
## ✨ Community
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
---
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>
+34
View File
@@ -0,0 +1,34 @@
import type { Core } from '@strapi/strapi';
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Admin => ({
auth: {
secret: env('ADMIN_JWT_SECRET')!,
},
apiToken: {
salt: env('API_TOKEN_SALT')!,
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT')!,
},
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY')!,
},
/**
* Path the admin panel is served from, relative to `server.url`.
* Set ADMIN_URL to an absolute URL only when the panel is hosted separately
* from the API (in which case also set SERVE_ADMIN_PANEL=false).
*/
url: env('ADMIN_URL', '/admin'),
serveAdminPanel: env.bool('SERVE_ADMIN_PANEL', true),
// Do not pop a browser open on `strapi develop` - agents and CI run headless.
autoOpen: env.bool('ADMIN_AUTO_OPEN', false),
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
docLinks: env.bool('FLAG_DOC_LINKS', true),
},
});
export default config;
+16
View File
@@ -0,0 +1,16 @@
import type { Core } from '@strapi/strapi';
const config: Core.Config.Api = {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
strictParams: true,
},
documents: {
strictParams: true,
strictRelations: true,
},
};
export default config;
+72
View File
@@ -0,0 +1,72 @@
import path from 'path';
import type { Core } from '@strapi/strapi';
import { isDatabaseClientKind } from '@strapi/database';
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Database => {
const client = env('DATABASE_CLIENT', 'sqlite');
if (!isDatabaseClientKind(client)) {
throw new Error(
`Unsupported DATABASE_CLIENT: ${client}. Use "postgres", "mysql", or "sqlite".`
);
}
const connections: Record<Core.Config.Database.ClientKind, Core.Config.Database['connection']> = {
mysql: {
client: 'mysql',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 3306),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
postgres: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) && {
key: env('DATABASE_SSL_KEY', undefined),
cert: env('DATABASE_SSL_CERT', undefined),
ca: env('DATABASE_SSL_CA', undefined),
capath: env('DATABASE_SSL_CAPATH', undefined),
cipher: env('DATABASE_SSL_CIPHER', undefined),
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
schema: env('DATABASE_SCHEMA', 'public'),
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
sqlite: {
client: 'sqlite',
connection: {
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
},
useNullAsDefault: true,
},
};
return {
connection: {
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
};
};
export default config;
+63
View File
@@ -0,0 +1,63 @@
import type { Core } from '@strapi/strapi';
/**
* Origins that are always allowed outside production: the React Router dev
* server (Vite) on both hostnames it is commonly reached on.
*/
const DEV_ORIGINS = [
'http://localhost:5173',
'http://127.0.0.1:5173',
];
/**
* The default Strapi middleware stack, in order, with CORS configured for the
* SHARP-ER web app.
*
* Order matters: `strapi::errors` must wrap everything after it, `strapi::cors`
* must run before routing so pre-flight requests are answered, and
* `strapi::public` stays last so it only handles what nothing else claimed.
*/
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Middlewares => {
const isProduction = env('NODE_ENV', 'development') === 'production';
/**
* Production origins come from the environment, e.g.
* CORS_ORIGINS=https://sharper.ly,https://www.sharper.ly
* Outside production the local dev server is allowed as well.
*/
const configuredOrigins = env.array('CORS_ORIGINS', []) as string[];
const origin = Array.from(
new Set([...(isProduction ? [] : DEV_ORIGINS), ...configuredOrigins].filter(Boolean))
);
return [
'strapi::logger',
'strapi::errors',
// If uploads move to S3/CDN, add the bucket host to img-src and media-src
// here - see the S3 notes in config/plugins.ts.
'strapi::security',
{
name: 'strapi::cors',
config: {
// An empty list blocks every cross-origin browser request, which is the
// safe default if CORS_ORIGINS was forgotten in production.
origin,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
// The web app sends the locale it wants and reads pagination metadata.
expose: ['Content-Range', 'Content-Length'],
credentials: true,
maxAge: 31536000,
keepHeadersOnError: false,
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
};
export default config;
+143
View File
@@ -0,0 +1,143 @@
import type { Core } from '@strapi/strapi';
/**
* Media types the Media Library accepts.
*
* The site publishes reports, guidance notes and event material, so office
* documents and PDFs are allowed next to the usual image/video/audio types.
*/
const allowedMediaTypes = [
'image/*',
'video/*',
'audio/*',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.*',
'text/plain',
'text/csv',
];
/**
* Types that are never accepted, even if they slip past `allowedMediaTypes`.
* SVG is denied on purpose: it can carry script and is served from the same
* origin as the API.
*/
const deniedTypes = [
'image/svg+xml',
'application/vnd.microsoft.portable-executable',
'application/x-msdownload',
'application/x-msdos-program',
'application/x-executable',
'application/x-dosexec',
'application/x-sh',
'text/x-shellscript',
'application/x-mach-binary',
];
/**
* The locales the site ships with.
*
* `en` is the default (LTR) and `ar` is the right-to-left translation. The web
* app mirrors these codes in its URL prefixes (`/en/...`, `/ar/...`), so the
* codes here are part of the public contract - do not rename them.
*
* The i18n plugin itself does not read this key; it is our own declaration and
* is read back at boot by `src/bootstrap/locales.ts`, which creates any missing
* locale and pins the default one. Adding a locale is therefore a one-line edit
* here plus a restart.
*/
const locales = [
{ code: 'en', name: 'English (en)', isDefault: true },
{ code: 'ar', name: 'Arabic (ar)' },
];
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
/**
* Internationalisation - core plugin, listed explicitly so the two supported
* locales live in version control rather than only in the database.
*/
i18n: {
enabled: true,
config: {
defaultLocale: 'en',
locales,
},
},
'users-permissions': {
config: {
jwtSecret: env('JWT_SECRET'),
/**
* "refresh" uses the SessionManager (short-lived access token + refresh
* token) instead of the legacy long-lived plugin JWT.
*/
jwtManagement: 'refresh',
sessions: {
// Refresh token is stored in a cookie that JavaScript cannot read.
httpOnly: true,
},
/**
* Rate limit on the auth endpoints (login, forgot-password, ...).
* Tightened from the Strapi default of 10 requests per minute.
*/
ratelimit: {
interval: env.int('AUTH_RATE_LIMIT_INTERVAL', 60_000),
max: env.int('AUTH_RATE_LIMIT_MAX', 5),
},
},
},
/**
* Uploads stay on the default `local` provider: files land in
* `apps/cms/public/uploads` and are served by `strapi::public`.
*
* TO SWITCH TO S3 IN PRODUCTION
* -----------------------------
* 1. Add the provider package to apps/cms/package.json:
* "@strapi/provider-upload-aws-s3": "5.53.0"
* 2. Replace the `provider` / `providerOptions` keys below with:
*
* provider: 'aws-s3',
* providerOptions: {
* baseUrl: env('CDN_URL'), // optional CloudFront/CDN origin
* rootPath: env('S3_ROOT_PATH'), // optional key prefix
* s3Options: {
* credentials: {
* accessKeyId: env('AWS_ACCESS_KEY_ID'),
* secretAccessKey: env('AWS_ACCESS_SECRET'),
* },
* region: env('AWS_REGION'),
* params: {
* ACL: env('AWS_ACL', 'private'),
* signedUrlExpires: env.int('AWS_SIGNED_URL_EXPIRES', 15 * 60),
* Bucket: env('AWS_BUCKET'),
* },
* },
* },
* actionOptions: { upload: {}, uploadStream: {}, delete: {} },
*
* 3. Add the bucket/CDN host to the Content Security Policy in
* `config/middlewares.ts` (`img-src` and `media-src`), otherwise the admin
* panel shows broken thumbnails.
* 4. Set the AWS_* / CDN_URL variables listed in `.env.example`.
*/
upload: {
config: {
provider: 'local',
providerOptions: {
// Cache-Control max-age (seconds) sent for locally served uploads.
localServer: {
maxage: env.int('UPLOAD_LOCAL_MAX_AGE', 300_000),
},
},
// Hard ceiling per file, in bytes. Defaults to 50 MB.
sizeLimit: env.int('UPLOAD_MAX_FILE_SIZE', 50 * 1024 * 1024),
security: {
allowedTypes: allowedMediaTypes,
deniedTypes,
},
},
},
});
export default config;
+30
View File
@@ -0,0 +1,30 @@
import type { Core } from '@strapi/strapi';
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
/**
* Public URL of the API. Required behind a reverse proxy / custom domain so
* that generated links (media URLs, admin redirects) are absolute and https.
* Leave unset in local development.
*/
url: env('PUBLIC_URL'),
// Trust X-Forwarded-* headers when Strapi runs behind nginx / a load balancer.
proxy: env.bool('IS_PROXIED', false),
app: {
keys: env.array('APP_KEYS')!,
},
logger: {
config: {
level: env('LOG_LEVEL', 'info'),
},
},
cron: {
enabled: env.bool('CRON_ENABLED', false),
},
webhooks: {
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
},
});
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

+43
View File
@@ -0,0 +1,43 @@
{
"name": "cms",
"version": "0.1.0",
"private": true,
"description": "A Strapi application",
"scripts": {
"build": "strapi build",
"console": "strapi console",
"deploy": "strapi deploy",
"dev": "strapi develop",
"develop": "strapi develop",
"start": "strapi start",
"strapi": "strapi",
"typecheck": "tsc --noEmit",
"upgrade": "npx @strapi/upgrade latest",
"upgrade:dry": "npx @strapi/upgrade latest --dry"
},
"dependencies": {
"@strapi/database": "5.53.0",
"@strapi/plugin-cloud": "5.53.0",
"@strapi/plugin-users-permissions": "5.53.0",
"@strapi/strapi": "5.53.0",
"pg": "^8.23.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.30.3",
"styled-components": "^6.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
},
"engines": {
"node": ">=20.0.0 <=26.x.x",
"npm": ">=6.0.0"
},
"strapi": {
"uuid": "e6ef164d-b72a-4161-a38e-7a9acda47ace",
"installId": "17a7dd9f8aabfc1974cafe13966c2ef4914ce9a64c816f389522108bc4721cdd"
}
}
+3
View File
@@ -0,0 +1,3 @@
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
# User-Agent: *
# Disallow: /
View File
+37
View File
@@ -0,0 +1,37 @@
import type { StrapiApp } from '@strapi/strapi/admin';
export default {
config: {
locales: [
// 'ar',
// 'fr',
// 'cs',
// 'de',
// 'da',
// 'es',
// 'he',
// 'id',
// 'it',
// 'ja',
// 'ko',
// 'ms',
// 'nl',
// 'no',
// 'pl',
// 'pt-BR',
// 'pt',
// 'ru',
// 'sk',
// 'sv',
// 'th',
// 'tr',
// 'uk',
// 'vi',
// 'zh-Hans',
// 'zh',
],
},
bootstrap(app: StrapiApp) {
console.log(app);
},
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["../plugins/**/admin/src/**/*", "./"],
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { mergeConfig, type UserConfig } from 'vite';
export default (config: UserConfig) => {
// Important: always return the modified config
return mergeConfig(config, {
resolve: {
alias: {
'@': '/src',
},
},
});
};
View File
@@ -0,0 +1,113 @@
{
"kind": "singleType",
"collectionName": "about_pages",
"info": {
"singularName": "about-page",
"pluralName": "about-pages",
"displayName": "About Page",
"description": "About and Strategy: who SHARP-ER is, the consortium and the SHARP legacy."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"hero": {
"type": "component",
"repeatable": false,
"component": "shared.hero",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"body": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"blocks": {
"type": "dynamiczone",
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
"blocks.accordion",
"blocks.video-embed",
"blocks.partner-logos"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"consortium": {
"type": "component",
"repeatable": true,
"component": "page.consortium-member",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"pillars": {
"type": "component",
"repeatable": true,
"component": "shared.titled-text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"legacyHeadline": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"legacyBody": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"legacyLink": {
"type": "component",
"repeatable": false,
"component": "shared.link",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* about-page controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::about-page.about-page');
@@ -0,0 +1,7 @@
/**
* about-page router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::about-page.about-page');
@@ -0,0 +1,7 @@
/**
* about-page service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::about-page.about-page');
@@ -0,0 +1,159 @@
{
"kind": "collectionType",
"collectionName": "activities",
"info": {
"singularName": "activity",
"pluralName": "activities",
"displayName": "Activity",
"description": "Activities and initiatives delivered under the SHARP-ER workstreams."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 250,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"summary": {
"type": "text",
"maxLength": 500,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"body": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"blocks": {
"type": "dynamiczone",
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
"blocks.accordion",
"blocks.video-embed",
"blocks.partner-logos"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"activityStream": {
"type": "enumeration",
"enum": [
"sharp-network",
"cso-subprojects-p2r",
"suq-dialogues",
"human-security",
"research"
],
"required": true,
"default": "sharp-network",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"implementingPartners": {
"type": "relation",
"relation": "oneToMany",
"target": "api::partner.partner"
},
"regions": {
"type": "relation",
"relation": "oneToMany",
"target": "api::region.region"
},
"topics": {
"type": "relation",
"relation": "oneToMany",
"target": "api::topic.topic"
},
"activityStatus": {
"type": "enumeration",
"enum": [
"planned",
"ongoing",
"completed"
],
"required": true,
"default": "ongoing",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"coverImage": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"gallery": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* activity controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::activity.activity');
@@ -0,0 +1,7 @@
/**
* activity router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::activity.activity');
@@ -0,0 +1,7 @@
/**
* activity service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::activity.activity');
@@ -0,0 +1,24 @@
/**
* application lifecycles
*
* `submittedAt` is never required in the schema, because the public API sets it
* in the sanitize-submission middleware rather than trusting the caller. This
* backfills it for entries created any other way (the admin panel, a seed
* script) so the column is never null.
*/
interface CreateEvent {
params: {
data: Record<string, unknown>;
};
}
export default {
beforeCreate(event: CreateEvent) {
const { data } = event.params;
if (!data.submittedAt) {
data.submittedAt = new Date().toISOString();
}
},
};
@@ -0,0 +1,70 @@
{
"kind": "collectionType",
"collectionName": "applications",
"info": {
"singularName": "application",
"pluralName": "applications",
"displayName": "Application",
"description": "A submitted application against an opportunity. Not translated; managed by staff."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"opportunity": {
"type": "relation",
"relation": "oneToOne",
"target": "api::opportunity.opportunity"
},
"applicantName": {
"type": "string",
"required": true,
"maxLength": 200
},
"email": {
"type": "email",
"required": true
},
"phone": {
"type": "string"
},
"organisation": {
"type": "string",
"maxLength": 200
},
"organisationType": {
"type": "string",
"maxLength": 200
},
"answers": {
"type": "json"
},
"attachments": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images",
"files"
]
},
"applicationStatus": {
"type": "enumeration",
"enum": [
"submitted",
"under-review",
"shortlisted",
"accepted",
"rejected"
],
"required": true,
"default": "submitted"
},
"submittedAt": {
"type": "datetime"
},
"acknowledgementSentAt": {
"type": "datetime"
}
}
}
@@ -0,0 +1,7 @@
/**
* application controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::application.application');
@@ -0,0 +1,56 @@
/**
* application sanitize-submission middleware
*
* The public role may create applications, so the request body is
* attacker-controlled. Only the fields an applicant is allowed to supply are
* copied out of it; the review state and the timestamps are set here, on the
* server, and anything else the caller sent is dropped.
*
* `attachments` is deliberately NOT on the whitelist. It is a media relation,
* so its value is a list of ids of files that are ALREADY in the Media
* Library - accepting it from an unauthenticated caller lets anyone bind any
* uploaded file, including staff-only documents, onto an application row that
* reviewers then open. The Public role cannot upload either, so there is no
* legitimate id a public applicant could send. Attachments are added by staff
* in the admin panel; a public upload flow would need its own authenticated
* endpoint that returns ids scoped to the submission being created.
*/
import type { Core } from '@strapi/strapi';
const CLIENT_FIELDS = [
'opportunity',
'applicantName',
'email',
'phone',
'organisation',
'organisationType',
'answers',
] as const;
const sanitizeSubmission: Core.MiddlewareFactory = () => async (ctx, next) => {
const request = ctx.request as typeof ctx.request & { body?: unknown };
const body = (request.body ?? {}) as { data?: unknown };
const input = (typeof body.data === 'object' && body.data !== null ? body.data : {}) as Record<
string,
unknown
>;
const data: Record<string, unknown> = {};
for (const field of CLIENT_FIELDS) {
if (field in input) {
data[field] = input[field];
}
}
data.applicationStatus = 'submitted';
data.submittedAt = new Date().toISOString();
data.acknowledgementSentAt = null;
request.body = { data };
await next();
};
export default sanitizeSubmission;
@@ -0,0 +1,31 @@
/**
* application router
*
* `create` is the one action the Public role holds here, so the POST route
* carries two middlewares and the ORDER matters:
*
* 1. `global::rate-limit` caps how often one IP may post, before any
* work is done on the body.
* 2. `api::application.sanitize-submission`
* drops every field that is not on the client
* whitelist and sets the server-owned state.
*
* See `src/middlewares/rate-limit.ts` for the limiter's scope and its
* single-instance caveat.
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::application.application', {
config: {
create: {
middlewares: [
{
name: 'global::rate-limit',
config: { name: 'application-create', interval: 60_000, max: 5 },
},
'api::application.sanitize-submission',
],
},
},
});
@@ -0,0 +1,7 @@
/**
* application service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::application.application');
@@ -0,0 +1,84 @@
{
"kind": "singleType",
"collectionName": "contact_pages",
"info": {
"singularName": "contact-page",
"pluralName": "contact-pages",
"displayName": "Contact Page",
"description": "Contact details for SHARP-ER and each consortium member."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"hero": {
"type": "component",
"repeatable": false,
"component": "shared.hero",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"intro": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"generalEmail": {
"type": "email",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"websiteLabel": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"socialLinks": {
"type": "component",
"repeatable": true,
"component": "shared.link",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"consortiumContacts": {
"type": "component",
"repeatable": true,
"component": "shared.address",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* contact-page controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::contact-page.contact-page');
@@ -0,0 +1,7 @@
/**
* contact-page router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::contact-page.contact-page');
@@ -0,0 +1,7 @@
/**
* contact-page service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::contact-page.contact-page');
@@ -0,0 +1,24 @@
/**
* contact-submission lifecycles
*
* `submittedAt` is never required in the schema, because the public API sets it
* in the sanitize-submission middleware rather than trusting the caller. This
* backfills it for entries created any other way (the admin panel, a seed
* script) so the column is never null.
*/
interface CreateEvent {
params: {
data: Record<string, unknown>;
};
}
export default {
beforeCreate(event: CreateEvent) {
const { data } = event.params;
if (!data.submittedAt) {
data.submittedAt = new Date().toISOString();
}
},
};
@@ -0,0 +1,50 @@
{
"kind": "collectionType",
"collectionName": "contact_submissions",
"info": {
"singularName": "contact-submission",
"pluralName": "contact-submissions",
"displayName": "Contact Submission",
"description": "A message sent through the contact form. Not translated; managed by staff."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": {
"type": "string",
"required": true,
"maxLength": 200
},
"organisation": {
"type": "string",
"maxLength": 200
},
"email": {
"type": "email",
"required": true
},
"subject": {
"type": "string",
"required": true,
"maxLength": 250
},
"message": {
"type": "text",
"required": true
},
"submissionStatus": {
"type": "enumeration",
"enum": [
"new",
"in-progress",
"resolved"
],
"required": true,
"default": "new"
},
"submittedAt": {
"type": "datetime"
}
}
}
@@ -0,0 +1,7 @@
/**
* contact-submission controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::contact-submission.contact-submission');
@@ -0,0 +1,38 @@
/**
* contact-submission sanitize-submission middleware
*
* The public role may create contact submissions, so the request body is
* attacker-controlled. Only the fields a visitor is allowed to supply are
* copied out of it; the triage state and the timestamp are set here, on the
* server, and anything else the caller sent is dropped.
*/
import type { Core } from '@strapi/strapi';
const CLIENT_FIELDS = ['name', 'organisation', 'email', 'subject', 'message'] as const;
const sanitizeSubmission: Core.MiddlewareFactory = () => async (ctx, next) => {
const request = ctx.request as typeof ctx.request & { body?: unknown };
const body = (request.body ?? {}) as { data?: unknown };
const input = (typeof body.data === 'object' && body.data !== null ? body.data : {}) as Record<
string,
unknown
>;
const data: Record<string, unknown> = {};
for (const field of CLIENT_FIELDS) {
if (field in input) {
data[field] = input[field];
}
}
data.submissionStatus = 'new';
data.submittedAt = new Date().toISOString();
request.body = { data };
await next();
};
export default sanitizeSubmission;
@@ -0,0 +1,31 @@
/**
* contact-submission router
*
* `create` is the one action the Public role holds here, so the POST route
* carries two middlewares and the ORDER matters:
*
* 1. `global::rate-limit` caps how often one IP may post, before any
* work is done on the body.
* 2. `api::contact-submission.sanitize-submission`
* drops every field that is not on the client
* whitelist and sets the server-owned state.
*
* See `src/middlewares/rate-limit.ts` for the limiter's scope and its
* single-instance caveat.
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::contact-submission.contact-submission', {
config: {
create: {
middlewares: [
{
name: 'global::rate-limit',
config: { name: 'contact-submission-create', interval: 60_000, max: 5 },
},
'api::contact-submission.sanitize-submission',
],
},
},
});
@@ -0,0 +1,7 @@
/**
* contact-submission service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::contact-submission.contact-submission');
@@ -0,0 +1,24 @@
/**
* event-registration lifecycles
*
* `submittedAt` is never required in the schema, because the public API sets it
* in the sanitize-submission middleware rather than trusting the caller. This
* backfills it for entries created any other way (the admin panel, a seed
* script) so the column is never null.
*/
interface CreateEvent {
params: {
data: Record<string, unknown>;
};
}
export default {
beforeCreate(event: CreateEvent) {
const { data } = event.params;
if (!data.submittedAt) {
data.submittedAt = new Date().toISOString();
}
},
};
@@ -0,0 +1,64 @@
{
"kind": "collectionType",
"collectionName": "event_registrations",
"info": {
"singularName": "event-registration",
"pluralName": "event-registrations",
"displayName": "Event Registration",
"description": "A submitted registration for an event. Not translated; managed by staff."
},
"options": {
"draftAndPublish": false
},
"attributes": {
"event": {
"type": "relation",
"relation": "oneToOne",
"target": "api::event.event"
},
"fullName": {
"type": "string",
"required": true,
"maxLength": 200
},
"email": {
"type": "email",
"required": true
},
"phone": {
"type": "string"
},
"organisation": {
"type": "string",
"maxLength": 200
},
"role": {
"type": "string",
"maxLength": 200
},
"dietaryOrAccess": {
"type": "text"
},
"registrationState": {
"type": "enumeration",
"enum": [
"confirmed",
"waitlisted",
"cancelled",
"attended"
],
"required": true,
"default": "confirmed"
},
"consentGdpr": {
"type": "boolean",
"required": true
},
"submittedAt": {
"type": "datetime"
},
"reminderSentAt": {
"type": "datetime"
}
}
}
@@ -0,0 +1,7 @@
/**
* event-registration controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::event-registration.event-registration');
@@ -0,0 +1,180 @@
/**
* event-registration sanitize-submission middleware
*
* The public role may create event registrations, so the request body is
* attacker-controlled. Only the fields a registrant is allowed to supply are
* copied out of it; the workflow state and the timestamps are set here, on the
* server, and anything else the caller sent is dropped.
*
* REGISTRATION STATE IS DERIVED, NEVER SENT
* `registrationState` used to be hard-set to `confirmed`, which meant a FULL
* event silently confirmed everyone and staff had to unpick it by hand. It is
* now worked out here, from the event itself:
*
* capacity reached (or the editors marked the event `full`)
* + a waitlist -> `waitlisted`
* + no waitlist -> the request is REFUSED with a 400
* otherwise -> `confirmed`
*
* This is the only place that decision can safely be made. Putting it in the
* browser would let anyone confirm a place on a sold-out event with one
* `curl`, and the count it depends on - how many places are already taken - is
* not something a client can be trusted to report.
*
* The count is read and the row is written without a transaction, so two
* registrations arriving in the same instant can both see the last free place.
* Overbooking by the number of concurrent requests is the accepted trade-off
* for a public form; staff see the overflow in the admin panel.
*/
import type { Core } from '@strapi/strapi';
import { errors } from '@strapi/utils';
const CLIENT_FIELDS = [
'event',
'fullName',
'email',
'phone',
'organisation',
'role',
'dietaryOrAccess',
'consentGdpr',
] as const;
const EVENT_UID = 'api::event.event';
const REGISTRATION_UID = 'api::event-registration.event-registration';
/** The subset of the event that decides whether there is a place left. */
type EventCapacity = {
documentId: string;
capacity: number | null;
waitlistEnabled: boolean | null;
registrationStatus: string | null;
};
/**
* The `event` value a client may send is a documentId (what the REST API uses
* for relations), and older clients may send a numeric id. Both are accepted;
* anything else is treated as "no event given".
*/
const readEventReference = (value: unknown): { documentId?: string; id?: number } => {
if (typeof value === 'string' && value.length > 0) {
return { documentId: value };
}
if (typeof value === 'number' && Number.isInteger(value)) {
return { id: value };
}
// `{ connect: [...] }` / `{ set: [...] }` and arrays are relation-write
// shapes a public form has no business sending, so they resolve to nothing
// and the registration is treated as having no known event.
return {};
};
/**
* `capacity`, `waitlistEnabled` and `registrationStatus` are not localised, so
* any row sharing the documentId carries the same values and the first match
* is enough.
*/
const findEvent = async (
strapi: Core.Strapi,
reference: { documentId?: string; id?: number }
): Promise<EventCapacity | null> => {
if (!reference.documentId && reference.id === undefined) {
return null;
}
const where = reference.documentId
? { documentId: reference.documentId }
: { id: reference.id };
return (await strapi.db.query(EVENT_UID).findOne({
where,
select: ['documentId', 'capacity', 'waitlistEnabled', 'registrationStatus'],
})) as EventCapacity | null;
};
/** How many places on this event are already taken. */
const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise<number> =>
strapi.db.query(REGISTRATION_UID).count({
where: {
event: { documentId },
registrationState: 'confirmed',
},
});
type RegistrationState = 'confirmed' | 'waitlisted';
/**
* Work out the state this registration is entitled to.
*
* Throws `ApplicationError` (HTTP 400) when the event has no place and no
* waitlist to offer - the honest answer to "may I register?" is no, and
* storing a row that says `confirmed` would be a lie to both the registrant
* and the staff reading the list.
*/
const resolveRegistrationState = async (
strapi: Core.Strapi,
eventValue: unknown
): Promise<RegistrationState> => {
const event = await findEvent(strapi, readEventReference(eventValue));
if (!event) {
// Either no event was given or the id does not resolve. There is no
// capacity to weigh, and nothing to confirm a place on.
return 'waitlisted';
}
if (event.registrationStatus === 'closed') {
throw new errors.ApplicationError('Registration for this event is closed.');
}
const capacity =
typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null;
const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId);
const isFull =
event.registrationStatus === 'full' || (capacity !== null && taken >= capacity);
if (!isFull) {
return 'confirmed';
}
const waitlistOpen = event.waitlistEnabled === true || event.registrationStatus === 'waitlist';
if (!waitlistOpen) {
throw new errors.ApplicationError('This event is full and has no waitlist.');
}
return 'waitlisted';
};
const sanitizeSubmission: Core.MiddlewareFactory =
(_config, { strapi }) =>
async (ctx, next) => {
const request = ctx.request as typeof ctx.request & { body?: unknown };
const body = (request.body ?? {}) as { data?: unknown };
const input = (typeof body.data === 'object' && body.data !== null ? body.data : {}) as Record<
string,
unknown
>;
const data: Record<string, unknown> = {};
for (const field of CLIENT_FIELDS) {
if (field in input) {
data[field] = input[field];
}
}
data.registrationState = await resolveRegistrationState(strapi, data.event);
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
request.body = { data };
await next();
};
export default sanitizeSubmission;
@@ -0,0 +1,31 @@
/**
* event-registration router
*
* `create` is the one action the Public role holds here, so the POST route
* carries two middlewares and the ORDER matters:
*
* 1. `global::rate-limit` caps how often one IP may post, before any
* work is done on the body.
* 2. `api::event-registration.sanitize-submission`
* drops every field that is not on the client
* whitelist and sets the server-owned state.
*
* See `src/middlewares/rate-limit.ts` for the limiter's scope and its
* single-instance caveat.
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::event-registration.event-registration', {
config: {
create: {
middlewares: [
{
name: 'global::rate-limit',
config: { name: 'event-registration-create', interval: 60_000, max: 5 },
},
'api::event-registration.sanitize-submission',
],
},
},
});
@@ -0,0 +1,7 @@
/**
* event-registration service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::event-registration.event-registration');
@@ -0,0 +1,239 @@
{
"kind": "collectionType",
"collectionName": "events",
"info": {
"singularName": "event",
"pluralName": "events",
"displayName": "Event",
"description": "Entries in the events calendar: Suq dialogues, trainings, law sessions, network meetings and launches."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 250,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"description": {
"type": "text",
"maxLength": 500,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"body": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"eventType": {
"type": "enumeration",
"enum": [
"suq-dialogue",
"training",
"law-session",
"network-meeting",
"launch",
"other"
],
"required": true,
"default": "other",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"startDateTime": {
"type": "datetime",
"required": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"endDateTime": {
"type": "datetime",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"timezone": {
"type": "string",
"default": "Africa/Tripoli",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"format": {
"type": "enumeration",
"enum": [
"in-person",
"online",
"hybrid"
],
"required": true,
"default": "in-person",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"venue": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"onlineUrl": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"region": {
"type": "relation",
"relation": "oneToOne",
"target": "api::region.region"
},
"topics": {
"type": "relation",
"relation": "oneToMany",
"target": "api::topic.topic"
},
"implementingPartners": {
"type": "relation",
"relation": "oneToMany",
"target": "api::partner.partner"
},
"registrationStatus": {
"type": "enumeration",
"enum": [
"open",
"closed",
"full",
"waitlist",
"not-required"
],
"required": true,
"default": "not-required",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"capacity": {
"type": "integer",
"min": 0,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"waitlistEnabled": {
"type": "boolean",
"default": false,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"registrationDeadline": {
"type": "datetime",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"coverImage": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"gallery": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"recapUrl": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"relatedResources": {
"type": "relation",
"relation": "oneToMany",
"target": "api::resource.resource"
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* event controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::event.event');
+7
View File
@@ -0,0 +1,7 @@
/**
* event router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::event.event');
+7
View File
@@ -0,0 +1,7 @@
/**
* event service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::event.event');
@@ -0,0 +1,92 @@
{
"kind": "singleType",
"collectionName": "home_pages",
"info": {
"singularName": "home-page",
"pluralName": "home-pages",
"displayName": "Home Page",
"description": "Content of the SHARP-ER home page."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"hero": {
"type": "component",
"repeatable": false,
"component": "shared.hero",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"stats": {
"type": "component",
"repeatable": true,
"component": "shared.stat",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"blocks": {
"type": "dynamiczone",
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
"blocks.accordion",
"blocks.video-embed",
"blocks.partner-logos"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"featuredNews": {
"type": "relation",
"relation": "oneToMany",
"target": "api::news-article.news-article"
},
"featuredEvents": {
"type": "relation",
"relation": "oneToMany",
"target": "api::event.event"
},
"partnersHeadline": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"partners": {
"type": "relation",
"relation": "oneToMany",
"target": "api::partner.partner"
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* home-page controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::home-page.home-page');
@@ -0,0 +1,7 @@
/**
* home-page router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::home-page.home-page');
@@ -0,0 +1,7 @@
/**
* home-page service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::home-page.home-page');
@@ -0,0 +1,166 @@
{
"kind": "collectionType",
"collectionName": "knowledge_items",
"info": {
"singularName": "knowledge-item",
"pluralName": "knowledge-items",
"displayName": "Knowledge Item",
"description": "Long-term Knowledge Hub repository: training modules, toolkits, law session guides, case studies and FAQs."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 250,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"summary": {
"type": "text",
"maxLength": 1000,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"body": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"blocks": {
"type": "dynamiczone",
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
"blocks.accordion",
"blocks.video-embed",
"blocks.partner-logos"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"itemType": {
"type": "enumeration",
"enum": [
"training-module",
"toolkit",
"law-session-guide",
"case-study",
"faq"
],
"required": true,
"default": "toolkit",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"attachments": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images",
"files"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"topics": {
"type": "relation",
"relation": "oneToMany",
"target": "api::topic.topic"
},
"relatedItems": {
"type": "relation",
"relation": "oneToMany",
"target": "api::knowledge-item.knowledge-item"
},
"relatedResources": {
"type": "relation",
"relation": "oneToMany",
"target": "api::resource.resource"
},
"keywords": {
"type": "json",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"sourceActivity": {
"type": "relation",
"relation": "oneToOne",
"target": "api::activity.activity"
},
"publicationDate": {
"type": "date",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"thumbnail": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* knowledge-item controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::knowledge-item.knowledge-item');
@@ -0,0 +1,7 @@
/**
* knowledge-item router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::knowledge-item.knowledge-item');
@@ -0,0 +1,7 @@
/**
* knowledge-item service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::knowledge-item.knowledge-item');
@@ -0,0 +1,159 @@
{
"kind": "collectionType",
"collectionName": "news_articles",
"info": {
"singularName": "news-article",
"pluralName": "news-articles",
"displayName": "News Article",
"description": "News and updates, split between SHARP-ER news and stories from the network."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 250,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"excerpt": {
"type": "text",
"maxLength": 500,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"body": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"blocks": {
"type": "dynamiczone",
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
"blocks.accordion",
"blocks.video-embed",
"blocks.partner-logos"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"category": {
"type": "enumeration",
"enum": [
"sharper-news",
"from-the-network"
],
"required": true,
"default": "sharper-news",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"authorOrganisation": {
"type": "relation",
"relation": "oneToOne",
"target": "api::partner.partner"
},
"publishDate": {
"type": "date",
"required": true,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"coverImage": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"gallery": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"topics": {
"type": "relation",
"relation": "oneToMany",
"target": "api::topic.topic"
},
"regions": {
"type": "relation",
"relation": "oneToMany",
"target": "api::region.region"
},
"featured": {
"type": "boolean",
"default": false,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* news-article controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::news-article.news-article');
@@ -0,0 +1,7 @@
/**
* news-article router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::news-article.news-article');
@@ -0,0 +1,7 @@
/**
* news-article service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::news-article.news-article');
@@ -0,0 +1,176 @@
{
"kind": "collectionType",
"collectionName": "opportunities",
"info": {
"singularName": "opportunity",
"pluralName": "opportunities",
"displayName": "Opportunity",
"description": "Calls for expression of interest, network membership, trainings and grants open for application."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 250,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"summary": {
"type": "text",
"maxLength": 500,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"body": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"opportunityType": {
"type": "enumeration",
"enum": [
"call-eoi",
"network-membership",
"training",
"grant",
"other"
],
"required": true,
"default": "call-eoi",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"opportunityStatus": {
"type": "enumeration",
"enum": [
"upcoming",
"open",
"closed"
],
"required": true,
"default": "upcoming",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"openDate": {
"type": "date",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"deadline": {
"type": "datetime",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"eligibility": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"howToApply": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"externalApplicationUrl": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"attachments": {
"type": "media",
"multiple": true,
"required": false,
"allowedTypes": [
"images",
"files"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"topics": {
"type": "relation",
"relation": "oneToMany",
"target": "api::topic.topic"
},
"implementingPartners": {
"type": "relation",
"relation": "oneToMany",
"target": "api::partner.partner"
},
"coverImage": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* opportunity controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::opportunity.opportunity');
@@ -0,0 +1,7 @@
/**
* opportunity router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::opportunity.opportunity');
@@ -0,0 +1,7 @@
/**
* opportunity service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::opportunity.opportunity');
@@ -0,0 +1,106 @@
{
"kind": "collectionType",
"collectionName": "partners",
"info": {
"singularName": "partner",
"pluralName": "partners",
"displayName": "Partner",
"description": "Consortium members, CSO partners and institutions SHARP-ER works with."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"name": {
"type": "string",
"required": true,
"maxLength": 200,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "name",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"logo": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"logoDark": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"description": {
"type": "text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"website": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"partnerRole": {
"type": "enumeration",
"enum": [
"consortium-lead",
"consortium-partner",
"cso-partner",
"institution"
],
"required": true,
"default": "cso-partner",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"country": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* partner controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::partner.partner');
@@ -0,0 +1,7 @@
/**
* partner router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::partner.partner');
@@ -0,0 +1,7 @@
/**
* partner service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::partner.partner');
@@ -0,0 +1,40 @@
{
"kind": "collectionType",
"collectionName": "regions",
"info": {
"singularName": "region",
"pluralName": "regions",
"displayName": "Region",
"description": "Libyan region or city used to filter events and activities geographically."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"name": {
"type": "string",
"required": true,
"maxLength": 120,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "name",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* region controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::region.region');
+7
View File
@@ -0,0 +1,7 @@
/**
* region router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::region.region');
@@ -0,0 +1,7 @@
/**
* region service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::region.region');
@@ -0,0 +1,214 @@
{
"kind": "collectionType",
"collectionName": "resources",
"info": {
"singularName": "resource",
"pluralName": "resources",
"displayName": "Resource",
"description": "Publications: perception research, policy articles, project reports and visibility factsheets."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 250,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "title",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"description": {
"type": "text",
"maxLength": 1000,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"resourceType": {
"type": "enumeration",
"enum": [
"research-perception",
"policy-article",
"project-report",
"visibility-factsheet"
],
"required": true,
"default": "project-report",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"file": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"files",
"images",
"videos",
"audios"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"externalUrl": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"fileSizeLabel": {
"type": "string",
"maxLength": 40,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"pageCount": {
"type": "integer",
"min": 0,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"resourceLanguage": {
"type": "enumeration",
"enum": [
"en",
"ar",
"bilingual"
],
"required": true,
"default": "en",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"publicationDate": {
"type": "date",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"authors": {
"type": "string",
"maxLength": 500,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"partnerOrganisations": {
"type": "relation",
"relation": "oneToMany",
"target": "api::partner.partner"
},
"topics": {
"type": "relation",
"relation": "oneToMany",
"target": "api::topic.topic"
},
"doi": {
"type": "string",
"maxLength": 200,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"handle": {
"type": "string",
"maxLength": 200,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"version": {
"type": "string",
"default": "1.0",
"maxLength": 20,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"supersedes": {
"type": "relation",
"relation": "oneToOne",
"target": "api::resource.resource"
},
"thumbnail": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"featured": {
"type": "boolean",
"default": false,
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* resource controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::resource.resource');
@@ -0,0 +1,7 @@
/**
* resource router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::resource.resource');
@@ -0,0 +1,7 @@
/**
* resource service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::resource.resource');
@@ -0,0 +1,151 @@
{
"kind": "singleType",
"collectionName": "site_settings",
"info": {
"singularName": "site-settings",
"pluralName": "site-settings-list",
"displayName": "Site Settings",
"description": "Global, per-locale site chrome: navigation, footer, EU visibility statement and default SEO."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"siteName": {
"type": "string",
"required": true,
"default": "SHARP-ER",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"tagline": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"logo": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"logoDark": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"primaryNav": {
"type": "component",
"repeatable": true,
"component": "navigation.nav-group",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"footerColumns": {
"type": "component",
"repeatable": true,
"component": "navigation.footer-column",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"socialLinks": {
"type": "component",
"repeatable": true,
"component": "shared.link",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"euFundingStatement": {
"type": "text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"euBadge": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"euBadgeDark": {
"type": "media",
"multiple": false,
"required": false,
"allowedTypes": [
"images"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"implementedByLabel": {
"type": "string",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"consortiumLogos": {
"type": "relation",
"relation": "oneToMany",
"target": "api::partner.partner"
},
"defaultSeo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* site-settings controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::site-settings.site-settings');
@@ -0,0 +1,7 @@
/**
* site-settings router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::site-settings.site-settings');
@@ -0,0 +1,7 @@
/**
* site-settings service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::site-settings.site-settings');
@@ -0,0 +1,48 @@
{
"kind": "collectionType",
"collectionName": "topics",
"info": {
"singularName": "topic",
"pluralName": "topics",
"displayName": "Topic",
"description": "Thematic tag used to filter activities, events, news, resources and knowledge items."
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"name": {
"type": "string",
"required": true,
"maxLength": 120,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"slug": {
"type": "uid",
"targetField": "name",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"description": {
"type": "text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* topic controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::topic.topic');
+7
View File
@@ -0,0 +1,7 @@
/**
* topic router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::topic.topic');
+7
View File
@@ -0,0 +1,7 @@
/**
* topic service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::topic.topic');
@@ -0,0 +1,95 @@
{
"kind": "singleType",
"collectionName": "what_we_do_pages",
"info": {
"singularName": "what-we-do-page",
"pluralName": "what-we-do-pages",
"displayName": "What We Do Page",
"description": "Workstreams, principles and guiding values of SHARP-ER."
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"hero": {
"type": "component",
"repeatable": false,
"component": "shared.hero",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"intro": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"workstreams": {
"type": "component",
"repeatable": true,
"component": "page.workstream",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"principles": {
"type": "component",
"repeatable": true,
"component": "shared.titled-text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"guidingValues": {
"type": "json",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"blocks": {
"type": "dynamiczone",
"components": [
"blocks.rich-text",
"blocks.image-gallery",
"blocks.stats-grid",
"blocks.cta-banner",
"blocks.card-grid",
"blocks.quote",
"blocks.accordion",
"blocks.video-embed",
"blocks.partner-logos"
],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"seo": {
"type": "component",
"repeatable": false,
"component": "shared.seo",
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* what-we-do-page controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::what-we-do-page.what-we-do-page');
@@ -0,0 +1,7 @@
/**
* what-we-do-page router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::what-we-do-page.what-we-do-page');

Some files were not shown because too many files have changed in this diff Show More