initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Strapi and React Router both expect a flat-ish tree for their plugin resolution.
|
||||||
|
node-linker=hoisted
|
||||||
|
engine-strict=false
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
|
||||||
|
# User-Agent: *
|
||||||
|
# Disallow: /
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* event router
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { factories } from '@strapi/strapi';
|
||||||
|
|
||||||
|
export default factories.createCoreRouter('api::event.event');
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* topic router
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { factories } from '@strapi/strapi';
|
||||||
|
|
||||||
|
export default factories.createCoreRouter('api::topic.topic');
|
||||||
@@ -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
Reference in New Issue
Block a user