'star', 'title' => 'New', 'desc' => '']); ?>>
```
#### `editableListItemAttrs(string $page, string $listKey, int $index): string`
Return HTML attributes for a single editable list item.
```php
>
```
#### `editableListItems(string $page, string $listKey): array`
Get list items from JSON content. Returns an indexed array sorted by numeric key. Filters out hidden items for visitors.
```php
$items = editableListItems('en_home', 'features.items');
foreach ($items as $i => $item) {
echo editableText('en_home', "features.items.$i.title", 'Feature');
}
```
#### `editableTextList(string $page, string $listKey, array $defaults = ['content' => '']): string`
Render a list of editable HTML paragraphs with add/remove/reorder/hide controls. Each paragraph is an `editableHtml()` field with inline editing and the floating toolbar. Uses the standard list system internally.
```php
echo editableTextList('en_about', 'intro.paragraphs');
```
JSON structure:
```json
{
"intro": {
"paragraphs": {
"0": { "content": "First paragraph..." },
"1": { "content": "Second paragraph with formatting..." }
}
}
}
```
### Field Visibility
#### `isFieldHidden(array $data, string $fieldKey): bool`
Check if a field is marked hidden via a `{fieldKey}__hidden` sibling key set to `true`. All editable field functions check this automatically.
### Render Components
#### `renderAllSections(string $page, bool $staggerCards = false): string`
Render all sections from a page's JSON `sections` array. Automatically wraps consecutive `card` blocks in a `.cards-grid` div. When admin is logged in, each section gets editable overlay controls.
```php
echo renderAllSections('en_about', true); // true = stagger animation on card grids
```
#### `renderFeatureGrid(string $page): string`
Render a grid of feature cards with icons. Reads `features.items` from JSON.
- **Item fields**: `icon` (icon ID), `title`, `desc`
- **Available icons**: database, edit, backup, image, globe, link, shield, feather, star, sparkles, zap, terminal, upload, server, folder, wand
```php
echo renderFeatureGrid('en_home');
```
#### `renderPricingTable(string $page): string`
Render pricing plan cards. Reads `pricing.plans` from JSON.
- **Item fields**: `name`, `price`, `period`, `desc`, `features` (newline-separated string), `highlight` (bool), `badge`, `cta` ({text, href})
- Also reads `pricing.vat_note` for an optional footnote.
```php
echo renderPricingTable('en_pricing');
```
#### `renderFaqAccordion(string $page): string`
Render a collapsible FAQ. Reads `faq.entries` from JSON.
- **Item fields**: `question`, `answer`
- Requires `js/faq-accordion.js` (auto-loaded by footer.php if present).
```php
echo renderFaqAccordion('en_faq');
```
#### `renderTeamGrid(string $page): string`
Render a team/contributors grid. Reads `team.members` from JSON.
- **Item fields**: `name`, `role`, `bio`, `image` ({src, alt})
```php
echo renderTeamGrid('en_about');
```
#### `renderGallery(string $page): string`
Render an image gallery with lightbox. Reads `gallery.images` from JSON.
- **Item fields**: `src`, `alt`, `caption`
- Includes a lightbox overlay in the output.
```php
echo renderGallery('en_gallery');
```
#### `renderTimeline(string $page): string`
Render a timeline/changelog. Reads `timeline.entries` from JSON.
- **Item fields**: `date`, `version`, `title`, `desc`, `status` (released/upcoming)
```php
echo renderTimeline('en_roadmap');
```
#### `renderStats(string $page): string`
Render key figures/statistics. Reads `stats.items` from JSON.
- **Item fields**: `value`, `label`, `desc`
```php
echo renderStats('en_home');
```
#### `renderTestimonials(string $page): string`
Render testimonial quote cards. Reads `testimonials.items` from JSON.
- **Item fields**: `quote`, `author`, `role`
```php
echo renderTestimonials('en_home');
```
#### `renderComparisonTable(string $page): string`
Render a feature comparison table. Reads `comparison.rows` from JSON. Columns are defined in `getComparisonColumns()`.
- **Row fields**: `label`, plus one value per column ID (use `"yes"`, `"no"`, or free text)
```php
echo renderComparisonTable('en_showcase');
```
#### `renderNewsList(int $limit = 0, string $lang = 'en'): string`
Render a grid of news post cards. Reads from `content/news/*.json`. Posts are sorted by date descending.
```php
echo renderNewsList(0, 'en'); // All posts in English
echo renderNewsList(3, 'en'); // Latest 3 posts
```
## Custom Layout Pages vs Standard Pages
**Standard pages** only need a JSON file with a `sections` array. The router serves them via `includes/page.php` which calls `renderAllSections()`. No PHP template needed. To add per-page logic (external styles/scripts, page classes) without modifying core files, create `includes/site-page-hook.php` — it runs after `$slug`, `$lang`, `$data`, `$basePath` are set but before rendering.
**Custom layout pages** have a PHP template file at `{lang}/page-slug.php` with full control over HTML structure. They use `editableText()`, `editableHtml()`, `editableImage()`, `editableLink()`, and render components to keep content editable via the admin system. Templates can set `$pageExternalStyles` (array of CSS URLs) and `$pageExternalScripts` (array of JS URLs) to load external resources per page.
**When to use which:**
- Standard: simple content pages (about, legal notices, basic articles)
- Custom: landing pages, homepages, pages with specific layouts, pages using render components
## CLI Tools
### `cli/make.php` — Page Scaffolding
Generates page boilerplate with a single command:
```bash
php cli/make.php --slug=about --lang=en --title="About Us" # Standard (JSON only)
php cli/make.php --slug=services --lang=de --type=custom --title="Dienste" # Custom (PHP + JSON)
```
Options: `--slug` (required), `--lang`, `--type` (standard/custom), `--title`, `--description`, `--hide-nav`, `--dry-run`, `--force`.
### `cli/convert.php` — HTML to Raiju Converter
Converts a static HTML page into an editable PHP template + JSON content file + extracted CSS. See `cli/README.md` for full options.
## Inline Editor System
When an admin is logged in (`$_SESSION['admin_logged_in'] === true`), the editable field functions add `data-*` attributes that the inline editor JavaScript uses to enable on-page editing.
### How It Works
1. `includes/footer.php` checks `isAdminLoggedIn()` and loads `css/inline-editor.css` + `js/inline-editor.js`
2. The editor uses an explicit **edit mode** toggle ("Edit page" button in admin bar)
3. `body.edit-mode-active` CSS class gates all editing outlines and overlays
4. Changes are collected in memory (`EditorConfig.contentData`) and only saved when the user clicks "Save"
5. Structural operations (add/delete sections, reorder list items) trigger an immediate save + page reload
### Data Attributes
- `data-page` + `data-field` on `.editable-field` elements (text, HTML)
- `data-editable-link` + `data-page` + `data-field` on links
- `data-editable-image` + `data-page` + `data-field` on images
- `data-editable-list` + `data-list-page` + `data-list-key` + `data-list-defaults` on list containers
- `data-list-page` + `data-list-key` + `data-list-index` on list items
- `data-hidden="true"` on any element that is hidden from visitors but visible to admins
## Navigation System
### Auto-Discovery
Pages with JSON content files are automatically added to navigation by `header.php`. The system scans `content/pages/{lang}_*.json` and appends any page not already listed in `$NAV_ITEMS`. System partials (`home`, `footer`, `sidebar`, `header`) are excluded automatically.
To control which menus a page appears in, set `"nav": ["header", "footer"]` in its JSON file. Use `"nav": []` to hide from all menus. Default (no field): `["header"]`. The title for auto-discovered pages is read from the JSON `title` field, with a fallback to the titlecased slug.
### `includes/nav-config.php`
Optional — only needed for explicit ordering, custom labels, or the language switcher's page mapping.
Two main data structures:
**`$PAGE_MAPPING`** - Maps page slugs to URL paths per language. Used by the language switcher.
```php
$PAGE_MAPPING = [
'home' => ['en' => '.', 'de' => 'de/'],
'about' => ['en' => 'about', 'de' => 'de/ueber-uns'],
];
```
**`$NAV_ITEMS`** - Navigation items per language. Each item has `href`, `label`, and `page` (slug for active state highlighting). Pages listed here appear first; auto-discovered pages are appended after.
```php
$NAV_ITEMS = [
'en' => [
['href' => '.', 'label' => 'Home', 'page' => 'home'],
['href' => 'about', 'label' => 'About', 'page' => 'about'],
],
];
```
### Language Switching
`header.php` automatically generates language switch links from `$PAGE_MAPPING`. If the current page exists in the target language (via JSON file), it links there. Otherwise it falls back to the home page of that language.
The primary language (`SITE_LANG_DEFAULT`) serves pages at the root (`/about`). Secondary languages use a prefix (`/de/ueber-uns`).
## Admin API Endpoints
All endpoints are in `admin/api.php`. Authenticated via PHP sessions with CSRF token protection.
### Page Management
| Action | Method | Description |
|---|---|---|
| `list-pages` | GET | List all page JSON files |
| `create-page` | POST | Create a new page (params: `page`, `title`) |
| `copy-page` | POST | Copy page to another language |
| `delete-page` | POST | Move page to trash |
| `duplicate-page` | POST | Duplicate a page |
### Trash
| Action | Method | Description |
|---|---|---|
| `list-trash` | GET | List trashed pages |
| `restore-page` | POST | Restore page from trash |
| `delete-trash` | POST | Permanently delete trashed page |
| `empty-trash` | POST | Permanently delete all trashed pages |
### Content
| Action | Method | Description |
|---|---|---|
| `load` | GET | Load page JSON content (param: `page`) |
| `save` | POST | Save page JSON content (param: `page`, `content`) |
### Backups
| Action | Method | Description |
|---|---|---|
| `backups` | GET | List backups for a page |
| `restore` | POST | Restore a backup |
| `delete-backup` | POST | Delete a specific backup |
| `preview-backup` | GET | Preview backup content |
| `create-site-backup` | POST | Create a manual full-site ZIP backup and issue a download token |
| `download-site-backup` | GET | Stream a site backup ZIP using a one-time token |
| `restore-site-backup` | POST | Restore a full-site ZIP in `full` or `content` mode |
| `backup-status` | GET | Return scheduled backup status, retention, storage, and remote target metadata |
| `backup-list` | GET | List stored full-site ZIP backups |
| `backup-update-settings` | POST | Save scheduled backup retention, storage limit, and remote target config |
| `backup-delete` | POST | Delete a stored full-site ZIP backup |
| `backup-prepare-download` | POST | Issue a one-time token for a stored ZIP |
| `backup-upload-remote` | POST | Upload an existing ZIP to enabled remote targets |
| `backup-test-remote` | POST | Test one configured remote target with a small upload |
| `backup-dropbox-oauth-start` / `backup-dropbox-oauth-callback` | GET | Dropbox OAuth PKCE connect flow |
| `backup-google-oauth-start` / `backup-google-oauth-callback` | GET | Google Drive OAuth PKCE connect flow |
| `backup-onedrive-oauth-start` / `backup-onedrive-oauth-callback` | GET | Microsoft OneDrive OAuth PKCE connect flow |
Full-site backups are created by `includes/backup-helper.php` and can be run from cron via `cli/backup.php --action=run`. Scheduled backups use tiered retention (`daily`, `weekly`, `monthly`, `yearly`) plus an optional storage cap. Backup files are named `{site-domain}-backup-YYYY-MM-DD_HHMMSS-{tier}.zip`. Remote targets are stored under `backup.remote_targets` in `content/settings.json`; uploads are placed in a per-site subfolder below the configured remote path, secrets are masked in the dashboard, and secrets are scrubbed from `content/settings.json` when that file is written into a backup ZIP.
### Events
| Action | Method | Description |
|---|---|---|
| `load-events` | GET | Load all events |
| `load-event` | GET | Load single event |
| `save-event` | POST | Create or update event |
| `delete-event` | POST | Delete event |
| `toggle-event-visibility` | POST | Show/hide event |
### News/Blog
| Action | Method | Description |
|---|---|---|
| `load-news` | GET | List all news posts |
| `save-news` | POST | Create or update news post |
| `toggle-news-status` | POST | Show/hide news post |
| `delete-news` | POST | Delete news post |
### Media
| Action | Method | Description |
|---|---|---|
| `list-images` | GET | List uploaded images |
| `upload-image` | POST | Upload image file |
| `delete-image` | POST | Delete image file |
| `list-audio` | GET | List uploaded audio files |
| `upload-audio` | POST | Upload audio file |
| `delete-audio` | POST | Delete audio file |
### Mail & Settings
| Action | Method | Description |
|---|---|---|
| `load-mails` | GET | Load form submissions and available form metadata |
| `mark-mail-read` | POST | Mark single mail as read |
| `update-mail-flags` | POST | Update mail read/starred flags |
| `mark-all-mails-read` | POST | Mark all mails as read |
| `delete-mail` | POST | Delete a mail |
| `delete-read-mails` | POST | Delete all read mails |
| `unread-mail-count` | GET | Get count of unread mails |
| `change-password` | POST | Change admin password |
| `load-settings` | GET | Load site settings |
| `save-settings` | POST | Save site settings |
### Forms
| Action | Method | Description |
|---|---|---|
| `list-forms` | GET | List `content/forms/*.json` definitions |
| `load-form` | GET | Load one normalized form definition |
| `save-form` | POST | Save one form definition from the Forms settings panel |
Public form endpoints live in `api/`:
| Endpoint | Method | Description |
|---|---|---|
| `api/form.php?form={id}` | GET | Lazy-render a JSON form when `content/forms/{id}.json` exists, otherwise fall back to whitelisted legacy forms |
| `api/form-submit.php` | POST | Validate, notify, and store JSON form submissions |
| `api/contact.php` | POST | Legacy contact form endpoint; still stores compatible `formId` / `formLabel` metadata |
## Theming
### CSS Custom Properties
All design tokens are defined as CSS custom properties in `css/style.css`:
```css
:root {
/* Colors — neutral blue primary */
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-primary-dark-muted: #1e40af;
--color-primary-light: #60a5fa;
--color-primary-really-dark: #172554;
--color-primary-btn: radial-gradient(ellipse at 50% 0%, #3b82f6 0%, #2563eb 70%);
--color-primary-btn-hover: radial-gradient(ellipse at 50% 0%, #60a5fa 0%, #2563eb 70%);
--color-secondary: #60a5fa;
--color-text: #171717;
--color-text-secondary: #525252;
--color-background: #ffffff;
--color-background-dark: #eff6ff;
--color-background-darker: #dbeafe;
--color-background-section: #f5f5f4;
--color-background-card: #ffffff;
--color-white: #ffffff;
--color-black: #0a0a0a;
--color-border: rgba(0, 0, 0, 0.08);
/* Dark theme palette */
--color-bg-dark: #0c0c0c;
--color-bg-dark-elevated: #141414;
--color-bg-dark-card: #1c1c1c;
--color-bg-light: #fafaf9;
--color-text-on-dark: #e5e5e5;
--color-text-on-dark-muted: #a3a3a3;
--color-border-dark: rgba(255, 255, 255, 0.06);
--color-border-light: rgba(0, 0, 0, 0.06);
/* Accent — brand gradients */
--gradient-brand: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
--gradient-brand-text: linear-gradient(90deg, #60a5fa 0%, #3b82f6 50%, #60a5fa 100%);
/* Glass effect */
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.06);
/* Typography — system fonts */
--font-display: system-ui, -apple-system, sans-serif;
--font-body: system-ui, -apple-system, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
/* Spacing */
--spacing-xs: 0.5rem;
--spacing-sm: 1rem;
--spacing-md: 2rem;
--spacing-lg: 4rem;
--spacing-xl: 6rem;
/* Transitions */
--transition-fast: 0.2s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
/* Layout */
--header-height: 80px;
--header-bg: rgba(255, 255, 255, 0.95);
--container-max: 1400px;
--container-narrow: 800px;
}
```
### Dark/Light Theme
The theme toggle sets `data-theme="dark"` or `data-theme="light"` on ``. Theme preference is stored in `localStorage` under key `site-theme`. A blocking `