Raiju 1.0.0

This commit is contained in:
touche 2026-10-25 06:05:42 +03:00
commit 44dadf1900
343 changed files with 139432 additions and 0 deletions

10
.gitattributes vendored Normal file
View File

@ -0,0 +1,10 @@
# Excluded from release archives (git archive / GitHub ZIP download)
screenshots/ export-ignore
reference/ export-ignore
raiju-backup/ export-ignore
.claude/ export-ignore
node_modules/ export-ignore
package.json export-ignore
package-lock.json export-ignore
screenshot.mjs export-ignore
screenshot-section.mjs export-ignore

80
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,80 @@
name: Raiju CI
on:
pull_request:
push:
branches: [main, 'codex/**']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
php:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-24.04
timeout-minutes: 12
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.4', '8.5']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: curl, dom, fileinfo, gd, mbstring, zip
coverage: none
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: python3 tests/run-smoke.py
apache:
name: Apache routing / PHP-FPM
runs-on: ubuntu-24.04
timeout-minutes: 12
steps:
- uses: actions/checkout@v4
- name: Install Apache and PHP-FPM
run: |
sudo apt-get update
sudo apt-get install -y apache2 php8.3-cli php8.3-fpm php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip
- name: Check real .htaccess routing in an isolated installation
env:
HTTPD: /usr/sbin/apache2
PHP_FPM: /usr/sbin/php-fpm8.3
HTTPD_MODULE_DIR: /usr/lib/apache2/modules
run: python3 tests/routing-smoke.py --apache
- name: Check the security self-check behind an nginx-like static-file proxy
env:
HTTPD: /usr/sbin/apache2
PHP_FPM: /usr/sbin/php-fpm8.3
HTTPD_MODULE_DIR: /usr/lib/apache2/modules
run: python3 tests/security-check-smoke.py --apache
chromium:
name: Chromium
runs-on: ubuntu-24.04
timeout-minutes: 12
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
extensions: curl, dom, fileinfo, gd, mbstring, zip
coverage: none
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci --prefix tests
- run: tests/node_modules/.bin/playwright install --with-deps chromium
- run: python3 tests/browser-check.py
- run: python3 tests/browser-check.py responsive-check.js
- run: python3 tests/browser-check.py revision-check.js
- run: python3 tests/agent-browser-check.py
- run: python3 tests/page-list-browser-check.py
- uses: actions/upload-artifact@v4
if: always()
with:
name: browser-evidence
path: /tmp/raiju-browser-evidence-*
retention-days: 7

109
.gitignore vendored Normal file
View File

@ -0,0 +1,109 @@
# Generated by setup wizard
admin/config.php
api/smtp-config.php
includes/nav-config.php
content/settings.json
# Adjacent product/support projects are separate repositories.
1studi.ru/
# Local working material / scratch (not part of the distributable core)
relaunch/
handoff.md
ADMIN-UI-SYSTEM-CONCEPT.md
AI-COPILOT-GOAL.md
.DS_Store
# Backup (local reference only)
raiju-backup/
raiju-alt/
# Runtime data
backups/*
!backups/.gitkeep
assets/images-trash/*
!assets/images-trash/.gitkeep
assets/audio-trash/*
!assets/audio-trash/.gitkeep
content/login_attempts.json
content/mails.json
content/users.json
content/redirects.json
content/form-tokens.json
content/form-rate-limit.json
content/security-check.json
content/security-check.json.lock
# Content & pages (generated by setup wizard or deploy script)
content/pages/*.json
content/news/*.json
content/events.json
content/comparison.json
content/faq.json
content/sidebar.json
# Language directories (generated by setup wizard or deploy script)
de/
en/
es/
fr/
it/
nl/
pt/
ja/
zh/
# CSS overlay (copied by deploy script)
css/website.css
# Fonts & font stylesheet (copied by deploy script)
css/fonts.css
assets/fonts/
# Development
screenshots/
reference/
node_modules/
__pycache__/
*.pyc
*.mjs
deploy.sh
auth-deploy.sh
package.json
package-lock.json
# OS files
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Claude Code local settings
.claude/settings.local.json
# Site-specific upgrade notes (kept locally, not part of Raiju Core)
RAIJU-UPGRADE-NOTES.md
# Reproducible browser tooling is development-only.
!tests/package.json
!tests/package-lock.json
content/analytics/
content/ai-requests.json
# AI agent runtime data (runs, change sets, staged images, snapshots, journal) and model findings
content/ai-agent/
content/ai-model-quirks.json
content/ai-model-quirks.json.lock
# Model lists of image services (fal.ai, Comet), refreshed daily
content/ai-image-models-cache.json
# Search engine check result
content/seo-check.json
content/seo-check.json.lock
# AI API keys (Provider, Brave, Pexels): never commit; the settings file next to it holds no keys
content/ai-secrets.json
content/ai-settings.json.lock
# Local working files (plans, drafts, notes) that never go into Git, are never deployed and are never served
not-for-git/

93
.htaccess Normal file
View File

@ -0,0 +1,93 @@
# Raiju CMS - .htaccess
# Apache mod_rewrite required
#
# Dynamic content routing lives in route.php. Apache security, aliases and
# file/directory precedence live here; see ROUTING.md before changing them.
# These core rules are language-agnostic with respect to SITE_LANG_DEFAULT.
RewriteEngine On
Options -MultiViews
# ============================================
# Security - Protect sensitive files
# ============================================
# Deny access to config files
<FilesMatch "(?i)^(config\.php|smtp-config\.php)$">
Order allow,deny
Deny from all
</FilesMatch>
# Deny access to JSON content files, backups, trash directories and the local working folder
RewriteRule ^(content|backups|tests|cli|not-for-git)/ - [F,L,NC]
RewriteRule (^|/)[^/]*-trash(/|$) - [F,L,NC]
# Never expose VCS, environment or local tooling files. Keep ACME available.
RewriteRule (^|/)\.(?!well-known(?:/|$)) - [F,L,NC]
# Route news detail URLs before Apache can resolve language-local
# listing templates such as de/news.php or es/news.php.
RewriteRule ^([a-z]{2}/)?news/[a-z0-9-]+/?$ /route.php [END,QSA]
# ============================================
# Upload limits (optional)
# ============================================
# PHP caps every upload with upload_max_filesize and post_max_size; the media
# library's image limit (Settings → Modules → Media library) cannot exceed them.
# On hosts that run PHP as an Apache module, uncomment to raise them here; on
# PHP-FPM hosts use .user.ini or the hosting panel instead (the system status
# in the dashboard shows the values in effect).
# php_value upload_max_filesize 20M
# php_value post_max_size 25M
# php_value memory_limit 256M
# ============================================
# Static files & directories
# ============================================
# Route-specific canonical URLs; mirror includes/routing-helper.php.
# /login is a compatibility entry point, never a second asset base directory.
RewriteRule ^login/?$ /admin/ [R=308,L]
# PHP endpoints win over same-named directories (e.g. dashboard.php alongside
# dashboard/scripts/). A new implementation directory must not break its URL.
RewriteCond %{DOCUMENT_ROOT}/$1.php -f
RewriteCond %{DOCUMENT_ROOT}/$1 -d
RewriteRule ^(.+?)/?$ /$1.php [R=308,L]
# Slash variants of existing PHP scripts use an explicit file URL.
RewriteCond %{DOCUMENT_ROOT}/$1.php -f
RewriteRule ^(.+)/$ /$1.php [R=308,L]
# Directory entry points need a slash for relative assets. 308 preserves POSTs.
RewriteCond %{REQUEST_FILENAME} -d
RewriteCond %{REQUEST_FILENAME}/index.php -f
RewriteRule ^(.*[^/])$ /$1/ [R=308,L]
# Serve existing files directly (CSS, JS, images, fonts, etc.)
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L]
# Serve directories that have an index.php (e.g. /admin/)
RewriteCond %{REQUEST_FILENAME} -d
RewriteCond %{REQUEST_FILENAME}/index.php -f
RewriteRule ^ - [L]
# Clean URLs: if slug.php exists as a file, serve it directly
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ /$1.php [END]
# ============================================
# Front Controller
# ============================================
# Everything else goes to route.php (language routing, news, JSON pages).
# Use an absolute target and END so requests below real language
# directories (/de/..., /es/...) cannot enter another per-directory
# rewrite round.
RewriteRule ^ /route.php [END,QSA]
# ============================================
# Error Pages
# ============================================
ErrorDocument 404 /404.php
ErrorDocument 410 /410.php

6
404.php Normal file
View File

@ -0,0 +1,6 @@
<?php
/**
* 404 Error Page — wrapper around the generic error renderer.
*/
$errorCode = 404;
include __DIR__ . '/error.php';

9
410.php Normal file
View File

@ -0,0 +1,9 @@
<?php
/**
* 410 Gone Error Page — wrapper around the generic error renderer.
*
* For permanently removed content where 301-redirecting to an unrelated
* page would be misleading and a default 404 understates the finality.
*/
$errorCode = 410;
include __DIR__ . '/error.php';

78
AGENTS.md Normal file
View File

@ -0,0 +1,78 @@
# Raiju CMS - Структура и Логика
## Архитектура
**Flat-file CMS без базы данных** - всё хранится в JSON файлах:
- `content/pages/*.json` - страницы с секциями контента
- `content/settings.json` - глобальные настройки
- `content/users.json` - пользователи (multi-user система)
- `content/events.json` - события/мероприятия
- `content/analytics.json` - локальная аналитика
## Структура директорий
- **admin/** - админ-панель и API эндпоинты
- **includes/** - хелперы (content-loader, footer, header, ai, backup)
- **api/** - публичные API (контакт, лайки, поиск)
- **cli/** - CLI инструменты для бэкапов и обслуживания
- **assets/** - медиа файлы (images, audio, video, documents)
- **css/** - стили (локальные, без CDN)
- **content/** - JSON контент
- **backups/** - ZIP бэкапы
## Модель контента
Каждая страница - JSON с массивом `sections[]`:
- heading, text, image, quote, list, card
- youtube, soundcloud (embed)
- divider, spacer (layout blocks)
Рендеринг через PHP хелперы из `includes/content-loader.php`.
## Пользователи
Multi-user система в `users.json`:
- `id`, `username`, `passwordHash`, `role` (admin/editor), `email`
- Аутентификация через `admin/users.php`
- Сессии с CSRF защитой в `includes/session-helper.php`
## Настройки
`settings.json` содержит:
- branding (логотип, название)
- theme (цвета, шрифты)
- backup (удалённые цели, токены)
- modules (AI, новости, события)
- privacy (аналитика, email obfuscation)
## Маршрутизация
`.htaccess` + `route.php` как front controller:
- Перенаправляет несуществующие файлы на route.php
- route.php загружает страницы по маппингу из `nav-config.php`
- Поддержка многоязычности (/en/, /de/ и т.д.)
## Интеграции
- **AI**: OpenAI, Anthropic, KIE.ai, Fal.ai, Comet для чата и генерации изображений
- **Бэкапы**: Dropbox, Google Drive, OneDrive, S3, FTP, WebDAV
- **Аналитика**: полностью локальная, без внешних запросов
- **Агент**: AI-агент для контент-операций с sandboxed безопасностью
## Безопасность
- CSRF токены для всех форм
- Password hash (bcrypt) для паролей
- Защита от SSRF в AI-агенте (raijuNetSafeFetch)
- Sanitization HTML для контента
- Dev login только с loopback
## Что важно для сайта:
- JSON - источник истины для контента
- Не редактировать core файлы (admin/, includes/, css/) при адаптации сайта
- Использовать `css/website.css` для кастомных стилей
- `$basePath` для путей к ассетам
- `$contentPage` для admin bar
Система хорошо продумана для локального self-hosted использования без внешних зависимостей.

1960
AI-AGENT-GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

24
CHANGELOG-fork.md Normal file
View File

@ -0,0 +1,24 @@
# Raiju Changelog
This changelog tracks changes made in this repository beyond the baseline.
## Baseline
- **Baseline commit:** 98067c9029f6ffd1c32a71e0df026bac59bdfc8c
- **Baseline date:** 2026-10-25
- **Version:** 1.0.0
- **Repository URL:** http://192.168.0.47:3000/touche/raiju
## Changes
### 2026-10-25 - Initial baseline
- Started from baseline commit 98067c90
- Migrated to Gitea instance at http://192.168.0.47:3000
- Started version numbering at 1.0.0; earlier tags were removed
- Configured git remote:
- origin: http://192.168.0.47:3000/touche/raiju.git
## Notes
- This repository is for internal development purposes
- Security patches should be applied promptly

38
CHANGELOG.md Normal file
View File

@ -0,0 +1,38 @@
# Changelog
All notable changes to this project are documented in this file. The project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Release dates use Europe/Vienna calendar dates.
## Unreleased
## [1.0.0] — 2026-10-25
### Added
- **Initial release.** First release, based on baseline commit `98067c90`.
- **Flat-file CMS core** — JSON content files, PHP page templates, inline editing
on the live page, multi-language content and block-based content with 11 block
types (text, heading, quote, list, image, card, youtube, soundcloud, audio,
divider, spacer).
- **Render components** — pricing tables, FAQ accordions, team and feature grids,
galleries, timelines, stats, testimonials, comparison tables, news listings and
breadcrumbs.
- **Admin dashboard** — pages, posts and events, media library with web versions
for large images, icon manager, settings and system status.
- **AI tools** — server-side AI gateway, editor Copilot, assistant and agent
modes, usage limits, audit logs and provider-specific API keys.
- **Backups** — per-page JSON history with restore, full-site ZIP backup and
restore, scheduled backups with retention, and remote targets (Dropbox,
Google Drive, OneDrive, SFTP/SCP, S3-compatible storage, WebDAV).
- **Forms and messages** — JSON-backed forms, spam-protected public forms, local
message storage and optional SMTP.
- **Access and SEO** — maintenance mode, private pages, customizable login
screens, per-page metadata, sitemap/robots and SEO health checks.
- **No build step** — plain PHP, HTML, CSS and vanilla JavaScript.
### Changed
- **Version numbering.** Releases are numbered from 1.0.0; earlier release
history is not part of it.

22
CLAUDE.md Normal file
View File

@ -0,0 +1,22 @@
# Raiju CMS - Claude Entry Point
This file exists for tools that automatically look for `CLAUDE.md`.
The canonical, tool-neutral agent guide is [`AI-AGENT-GUIDE.md`](AI-AGENT-GUIDE.md). Read it before making non-trivial changes. For task-oriented workflows, also see [`SKILLS.md`](SKILLS.md).
## Critical Rules
- Decide first whether you are doing **site adaptation** or **Raiju core development**.
- For site adaptation, avoid editing Raiju-Core files such as `admin/`, `api/`, `cli/`, most of `includes/`, core `css/`, and core `js/`; use content JSON, page templates, `css/website.css`, `css/page-*.css`, assets, and documented hooks instead.
- For existing-site migrations, preserve the source visual behavior exactly:
copy original local CSS/JS/assets first, keep class names and layout wrappers,
and inject editable helpers without redesigning, simplifying, or re-creating
the styling.
- For Raiju core development, core files may be edited when the user explicitly asks for admin UI, API, CLI, backup, routing, setup, editor, or shared behavior changes.
- JSON is the source of truth for editable content.
- Use `$basePath` for asset and link paths.
- Set `$contentPage` before including `includes/header.php`.
- Compare original and converted screenshots section by section before calling
a migration done; visual differences are defects unless the user requested a
redesign.
- `main` is protected on GitHub; push branches and merge through Pull Requests.

60
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,60 @@
# Contributing
Thank you for your interest in contributing to Raiju!
## How to Contribute
1. **Fork** the repository
2. Create a **feature branch** (`git checkout -b feature/my-feature`)
3. **Commit** your changes (`git commit -m 'Add my feature'`)
4. **Push** to the branch (`git push origin feature/my-feature`)
5. Open a **Pull Request**
## Code Standards
- PHP 8.1+ syntax; validate on a supported PHP release.
- Use consistent indentation (4 spaces)
- Follow existing naming conventions
- Add comments for complex logic
- Ensure security: validate inputs, sanitize outputs, use CSRF tokens
## Reporting Issues
- Use GitHub Issues
- Include steps to reproduce
- Include browser/PHP version if relevant
## Feature Requests
- Open an issue with the "enhancement" label
- Describe the use case
- Discuss before implementing large changes
## Releases
Follow [RELEASES.md](RELEASES.md) to keep `VERSION`, changelog dates, tested tags
and GitHub releases synchronized. Document compatibility changes in the upgrade
guide before publishing a release.
## Offline validation
Run `python3 tests/run-smoke.py` from the repository root. The runner needs PHP
(with mbstring, DOM, cURL and ZIP), Node.js and Python 3. It creates disposable
site copies, uses generated fixture accounts, and makes no paid AI calls or
external email deliveries. Live configuration and customer content are not copied.
The suite covers PHP/JavaScript syntax, translation JSON, permissions, sessions,
CSRF, safe HTML, routing, forms, backup/restore, concurrent file writes, and mocked
AI provider requests. Browser checks are separate from the offline runner.
Routing tests in this suite run on the PHP development server, not Apache.
For routing, login-destination or handler-directory changes, also complete the
[Apache routing checks](ROUTING.md#required-verification) and include that evidence
in the PR. The focused local regression command is `python3 tests/system-smoke.py`.
With Playwright and Chromium installed locally, run `python3 tests/browser-check.py`.
This also uses an isolated fixture and reports the location of its screenshots.
For shared JSON records use `includes/json-store.php`: `raijuJsonUpdate()` locks
the complete read/change/write transaction; `raijuJsonAtomicWrite()` alone only
prevents partially written files. Never remove a lock file after releasing it.

343
LICENSE Normal file
View File

@ -0,0 +1,343 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to the
creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others, if any, used by a
Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached the
notice in Exhibit A, the Executable Form of such Source Code Form, and
Modifications of such Source Code Form, in each case including portions
thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described in
Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of version
1.1 or earlier of the License, but not also under the terms of a
Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in a
separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible, whether at
the time of the initial grant or subsequently, any and all of the rights
conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to, deletion
from, or modification of the contents of Covered Software; or
(b) any new file in Source Code Form that contains any Covered Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method, process,
and apparatus claims, in any patent Licensable by such Contributor that
would be infringed, but for the grant of the License, by the making, using,
selling, offering for sale, having made, import, or transfer of either its
Contributions or its Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU Lesser
General Public License, Version 2.1, the GNU Affero General Public License,
Version 3.0, or any later versions of those licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" or "Your"
means an individual or a legal entity exercising rights under this License.
For legal entities, "You" includes any entity that controls, is controlled
by, or is under common control with You. For purposes of this definition,
"control" means (a) the power, direct or indirect, to cause the direction
or management of such entity, whether by contract or otherwise, or (b)
ownership of more than fifty percent (50%) of the outstanding shares or
beneficial ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive
license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available, modify,
display, perform, distribute, and otherwise exploit its Contributions,
either on an unmodified basis, with Modifications, or as part of a Larger
Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer for sale,
have made, import, and otherwise transfer either its Contributions or its
Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution become
effective for each Contribution on the date the Contributor first distributes
such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under this
License. No additional rights or licenses will be implied from the distribution
or licensing of Covered Software under this License. Notwithstanding Section
2.1(b) above, no patent license is granted by a Contributor:
(a) for any code that a Contributor has removed from Covered Software; or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of its
Contributions.
This License does not grant any rights in the trademarks, service marks, or
logos of any Contributor (except as may be necessary to comply with the notice
requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to distribute
the Covered Software under a subsequent version of this License (see Section
10.2) or under the terms of a Secondary License (if permitted under the terms of
Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its Contributions are
its original creation(s) or it has sufficient rights to grant the rights to its
Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under applicable
copyright doctrines of fair use, fair dealing, or other equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under the
terms of this License. You must inform recipients that the Source Code Form of
the Covered Software is governed by the terms of this License, and how they can
obtain a copy of this License. You may not attempt to alter or restrict the
recipients' rights in the Source Code Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code Form, as
described in Section 3.1, and You must inform recipients of the Executable
Form how they can obtain a copy of such Source Code Form by reasonable
means in a timely manner, at a charge no more than the cost of distribution
to the recipient; and
(b) You may distribute such Executable Form under the terms of this License, or
sublicense it under different terms, provided that the license for the
Executable Form does not attempt to limit or alter the recipients' rights in
the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice, provided
that You also comply with the requirements of this License for the Covered
Software. If the Larger Work is a combination of Covered Software with a work
governed by one or more Secondary Licenses, and the Covered Software is not
Incompatible With Secondary Licenses, this License permits You to additionally
distribute such Covered Software under the terms of such Secondary License(s),
so that the recipient of the Larger Work may, at their option, further
distribute the Covered Software under the terms of either this License or such
Secondary License(s).
3.4. Notices
You may not remove or alter the substance of any license notices (including
copyright notices, patent notices, disclaimers of warranty, or limitations of
liability) contained within the Source Code Form of the Covered Software, except
that You may alter any license notices to the extent required to remedy known
factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support, indemnity
or liability obligations to one or more recipients of Covered Software. However,
You may do so only on Your own behalf, and not on behalf of any Contributor. You
must make it absolutely clear that any such warranty, support, indemnity, or
liability obligation is offered by You alone, and You hereby agree to indemnify
every Contributor for any liability incurred by such Contributor as a result of
warranty, support, indemnity or liability terms You offer. You may include
additional disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this License with
respect to some or all of the Covered Software due to statute, judicial order,
or regulation then You must: (a) comply with the terms of this License to the
maximum extent possible; and (b) describe the limitations and the code they
affect. Such description must be placed in a text file included with all
distributions of the Covered Software under this License. Except to the extent
prohibited by statute or regulation, such description must be sufficiently
detailed for a recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically if You
fail to comply with any of its terms. However, if You become compliant, then the
rights granted under this License from a particular Contributor are reinstated
(a) provisionally, unless and until such Contributor explicitly and finally
terminates Your grants, and (b) on an ongoing basis, if such Contributor fails
to notify You of the non-compliance by some reasonable means prior to 60 days
after You have come back into compliance. Moreover, Your grants from a
particular Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the first
time You have received notice of non-compliance with this License from such
Contributor, and You become compliant prior to 30 days after Your receipt of the
notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions, counter-claims, and
cross-claims) alleging that a Contributor Version directly or indirectly
infringes any patent, then the rights granted to You by any and all Contributors
for the Covered Software under Section 2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
license agreements (excluding distributors and resellers) which have been
validly granted by You or Your distributors under this License prior to
termination shall survive termination.
6. Disclaimer of Warranty
-------------------------
Covered Software is provided under this License on an "as is" basis, without
warranty of any kind, either expressed, implied, or statutory, including,
without limitation, warranties that the Covered Software is free of defects,
merchantable, fit for a particular purpose or non-infringing. The entire risk as
to the quality and performance of the Covered Software is with You. Should any
Covered Software prove defective in any respect, You (not any Contributor)
assume the cost of any necessary servicing, repair, or correction. This
disclaimer of warranty constitutes an essential part of this License. No use of
any Covered Software is authorized under this License except under this
disclaimer.
7. Limitation of Liability
--------------------------
Under no circumstances and under no legal theory, whether tort (including
negligence), contract, or otherwise, shall any Contributor, or anyone who
distributes Covered Software as permitted above, be liable to You for any
direct, indirect, special, incidental, or consequential damages of any character
including, without limitation, damages for lost profits, loss of goodwill, work
stoppage, computer failure or malfunction, or any and all other commercial
damages or losses, even if such party shall have been informed of the
possibility of such damages. This limitation of liability shall not apply to
liability for death or personal injury resulting from such party's negligence to
the extent applicable law prohibits such limitation. Some jurisdictions do not
allow the exclusion or limitation of incidental or consequential damages, so this
exclusion and limitation may not apply to You.
8. Litigation
-------------
Any litigation relating to this License may be brought only in the courts of a
jurisdiction where the defendant maintains its principal place of business and
such litigation shall be governed by laws of that jurisdiction, without
reference to its conflict-of-law provisions. Nothing in this Section shall
prevent a party's ability to bring cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject matter
hereof. If any provision of this License is held to be unenforceable, such
provision shall be reformed only to the extent necessary to make it enforceable.
Any law or regulation which provides that the language of a contract shall be
construed against the drafter shall not be used to construe this License against
a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section 10.3,
no one other than the license steward has the right to modify or publish new
versions of this License. Each version will be given a distinguishing version
number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version of the
License under which You originally received the Covered Software, or under the
terms of any subsequent version published by the license steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to create a
new license for such software, you may create and use a modified version of this
License if you rename the license and remove any references to the name of the
license steward (except to note that such modified license differs from this
License).
10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses
If You choose to distribute Source Code Form that is Incompatible With Secondary
Licenses under the terms of this version of the License, the notice described in
Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public License,
v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain
one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular file, then
You may include the notice in a location (such as a LICENSE file in a relevant
directory) where a recipient would be likely to look for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as defined by
the Mozilla Public License, v. 2.0.

253
README.md Normal file
View File

@ -0,0 +1,253 @@
# Raiju
A flat-file CMS built on PHP with no database. Content lives in JSON files, pages are PHP templates, and an inline editor lets you edit everything directly on the page. Zero dependencies, zero build steps.
**Current release:** 1.0.0, tracked in [`VERSION`](VERSION). Examples and full
documentation at [1studi.ru](https://1studi.ru/).
**Upgrading an existing site:** read the [upgrade guide](UPGRADING.md) before
replacing files. Custom save API integrations require resource revisions.
Release metadata and historical date notes are documented in [RELEASES.md](RELEASES.md).
## Features
- **No database** -- all content stored as JSON files
- **Inline editing** -- click and edit text, images, and links on the live page
- **Multi-language** -- built-in language switching and per-language content files
- **Block-based content** -- 11 block types (text, heading, quote, list, image, card, youtube, soundcloud, audio, divider, spacer)
- **Render components** -- pricing tables, FAQ accordions, team grids, feature grids, galleries, timelines, stats, testimonials, comparison tables, news listings, breadcrumbs
- **Dark/light theme** -- toggle with localStorage persistence
- **Accessibility foundations** -- skip links, landmarks, reduced-motion support, labelled controls, and SEO health checks for missing image alt text
- **Access controls** -- maintenance mode with preview bypass and password-protected private pages
- **Customizable lock/login screens** -- logo/favicon, image layouts, overlays, and login box colors
- **SEO/AEO tools** -- per-page metadata, Open Graph image fallback, sitemap/robots, and health indicators
- **AI tools** -- server-side AI gateway plus frontend editor Copilot for safe chat guidance, field suggestions, content drafts, image generation/editing, usage limits, audit logs, and provider-specific API keys
- **Custom layouts** -- full PHP template control with editable field API
- **JSON-backed forms** -- manage multiple simple forms from JSON, render them in templates, and collect submissions in the dashboard inbox
- **Automatic backups** -- per-page JSON history with restore via admin panel
- **Site backup & restore** -- download full site as ZIP, restore from backup
- **Scheduled backups** -- server-cron or web-cron full-site ZIP backups with retention and storage limits
- **Remote backup targets** -- upload scheduled ZIPs to Dropbox, Google Drive, OneDrive, SFTP/SCP, S3-compatible storage, or WebDAV
- **Forms & messages** -- lazy-loaded, spam-protected public forms with local message storage, optional SMTP, and form-based inbox filtering
- **News/blog system** -- post management with listing and single-post views
- **Event management** -- calendar events with multi-language support
- **No build step** -- plain PHP, HTML, CSS, vanilla JS
## Quick Start
```bash
# 1. Clone the repository
git clone http://192.168.0.47:3000/touche/raiju.git
cd raiju
# 2. Start the development server
php -S localhost:3000 router.php
# 3. Open the setup wizard
# Visit http://localhost:3000/admin/setup.php
# Set your site name, language, and admin password
```
For large image uploads raise the CLI limits (the default `upload_max_filesize` is often 2M):
```bash
php -d upload_max_filesize=20M -d post_max_size=25M -S localhost:3000 router.php
```
That's it. Your site is running.
Before uploading or upgrading a site, follow
[Routing and deployment verification](ROUTING.md). Local PHP routing and Apache
can differ, especially when clean URLs share names with physical directories.
After the first upload, open **System status** in the dashboard: its security
check confirms that internal folders such as `content/` and `backups/` cannot
be downloaded. An nginx proxy in front of Apache can bypass `.htaccess`; see
[Hosting proxies and internal folders](ROUTING.md#hosting-proxies-and-internal-folders).
## Built for AI
Raiju ships with structured documentation designed for AI coding agents:
- **`AI-AGENT-GUIDE.md`** -- tool-neutral agent guide with block types, template API, and step-by-step instructions for creating pages.
- **`AGENTS.md` / `CLAUDE.md`** -- short entry points for tools that auto-load those filenames; both point to the shared guide.
- **`SKILLS.md`** -- task-oriented workflows for common agent jobs.
- **`architecture.md`** -- full technical reference covering JSON schemas, every PHP function signature, API endpoints, and the inline editor system.
An AI agent can create a new page, build a custom layout, add content blocks, and style components without asking you how things work. The documentation gives it everything it needs.
## Requirements
- PHP 8.1 is the language minimum; use a [currently supported PHP branch](https://www.php.net/supported-versions.php) in production (8.4 or 8.5 recommended).
- CI tests PHP 8.1, 8.4 and 8.5. The PHP 8.1 check verifies compatibility;
it does not extend PHP's upstream security support, which ended on 2025-12-31.
- PHP extensions: `mbstring`, `dom`, `fileinfo`, `session`; `zip` for backups, `curl` for provider integrations, and `gd` for image conversion.
- Apache with `mod_rewrite` (production) or PHP built-in server (development).
If nginx serves static files in front of Apache (HestiaCP, or Plesk with
"Serve static files directly by nginx"), block the internal folders in nginx
too; see [ROUTING.md](ROUTING.md#hosting-proxies-and-internal-folders).
- No Composer, no npm, no database
## Directory Structure
```
admin/ Admin panel, API, setup wizard
api/ Public endpoints (contact form, etc.)
assets/
images/ Uploaded images
audio/ Uploaded audio files
fonts/ Custom font files
cli/ Command-line tools (make.php, convert.php)
content/
pages/ Page content JSON files
forms/ Public form definitions
news/ Blog post JSON files
events.json Events
settings.json Site-wide settings
ai-settings.json AI provider settings, feature flags, and limits (no keys)
ai-secrets.json AI API keys (provider, Brave, Pexels); never commit or serve
ai-usage.json AI usage counters
css/
style.css Core styles + CSS custom properties (do not edit)
components.css Core render component styles (do not edit)
website.css Site-owned overrides (created by you, optional)
includes/
header.php HTML head + navigation
footer.php Footer + scripts
asset-helpers.php Core stylesheet loader (raijuCoreStyles)
content-loader.php Template API
forms.php JSON form loading, rendering, validation, notifications
block-types.php Block type definitions
block-renderers/ Per-block-type renderers
nav-config.php Navigation configuration
access-guard.php Maintenance mode and private page enforcement
ai/ AI provider gateway, Copilot context/actions, usage limits, audit, and generated images
menu-helpers.php Menu registry
page.php Front controller for JSON-only pages
js/ Client-side scripts
examples/ Example templates, content, css/website.css.template
router.php Development server router
```
## Creating a Page
### Option 1: JSON only (standard page)
Create `content/pages/en_about.json`:
```json
{
"page": "en_about",
"lang": "en",
"title": "About Us",
"description": "Learn more about us.",
"sections": [
{ "id": "s1", "type": "heading", "text": "About Us", "level": "h1" },
{ "id": "s2", "type": "text", "title": "", "content": "<p>Our story.</p>" }
]
}
```
Nested clean URLs are supported as well. Use a slash in the public page path
and a reserved double underscore in the flat JSON filename:
```text
content/pages/en_products__vitamin-d.json → /products/vitamin-d
content/pages/de_produkte__vitamin-d.json → /de/produkte/vitamin-d
```
When the language is the configured default, its language prefix is omitted.
The Dashboard and `php cli/make.php --slug=products/vitamin-d --lang=en`
create this mapping automatically.
The page is now live at `/about`. No PHP file needed.
### Option 2: Custom layout (PHP template)
Create `en/services.php`:
```php
<?php
$pageTitle = 'Services';
$pageDescription = 'What we offer.';
$currentLang = 'en';
$currentPage = 'services';
$contentPage = 'en_services';
$basePath = '../';
include '../includes/header.php';
include '../includes/content-loader.php';
$_p = $contentPage;
?>
<main class="main-content">
<div class="content-inner">
<h1><?php echo editableText($_p, 'hero.title', 'Our Services'); ?></h1>
<?php echo renderFeatureGrid($_p); ?>
<?php echo renderPricingTable($_p); ?>
</div>
</main>
<?php include '../includes/footer.php'; ?>
```
Create `content/pages/en_services.json` with the matching data structure.
See `examples/` for complete working examples.
## Accessibility Best Practices
Raiju provides a basic accessible foundation for public pages and admin tools: skip links, landmark-friendly templates, keyboard focus styles, reduced-motion handling, labelled controls, and SEO health checks for missing image alt text.
When building custom templates or site-specific components, follow these rules:
- Use one clear `<main>` area per page. Core templates expose `id="main-content"` for the skip link; custom templates should include the same id or let `includes/footer.php` add it to the first `<main>`.
- Keep heading order meaningful. Each public page should have one visible H1, then nested H2/H3 sections.
- Enter useful alt text for informative images. Leave alt empty only for purely decorative images.
- Use real buttons for actions and real links for navigation.
- Keep visible focus states intact; do not remove outlines without replacing them.
- Respect `prefers-reduced-motion` for page-specific animations.
- Label every form field and connect validation errors with nearby text.
- Test important pages with keyboard-only navigation and a screen reader before launch.
## Access Controls
Raiju includes frontend access controls without adding a database:
- **Maintenance mode** lives in **Dashboard -> Settings -> Access**. It can show a maintenance, back-soon, or launch-countdown page with custom title/text, optional unavailable-until time, and optional countdown.
- **Preview bypass** is configured with a URL parameter and secret key. Visiting a URL like `/?preview=secret-value` stores a session bypass so editors or clients can review the site while maintenance mode is active.
- **Private pages** are set per page in the Content Editor's **Access** card. Set visibility to `Private`, enter a password, and optionally customize the password page title/text.
- **Logged-in admins and editors bypass** maintenance mode and private page locks automatically.
- **Passwords and bypass secrets are hashed** in JSON. Do not store plaintext passwords directly in `content/pages/*.json` or `content/settings.json`.
## Theming
All design tokens are CSS custom properties defined in `css/style.css` (Raiju Core). To customize them for your site, **don't edit `style.css` directly** -- it gets overwritten on upgrades. Instead, copy the override template and edit your own file:
```bash
cp examples/css/website.css.template css/website.css
```
`includes/header.php` loads `css/website.css` automatically after Core, so any `:root` overrides you set there win:
```css
:root {
--color-primary: #2563eb;
--color-text: #171717;
--font-display: system-ui, -apple-system, sans-serif;
--spacing-md: 2rem;
}
```
Dark theme variables are included and toggled via `data-theme="dark"` on the `<html>` element.
To use custom fonts, place font files in `assets/fonts/` and define `@font-face` rules in `css/fonts.css`. `includes/header.php` loads `css/fonts.css` automatically when both the file and the `assets/fonts/` directory exist.
## License
This project is licensed under the Mozilla Public License 2.0. See
[LICENSE](LICENSE) for details.
Third-party components and their license terms are listed in
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

36
RELEASES.md Normal file
View File

@ -0,0 +1,36 @@
# Release policy
## Release metadata
`VERSION` is the runtime source of truth. Each published release must have:
- The same version in `VERSION`, the newest numbered `CHANGELOG.md` section and
the Git tag (`vX.Y.Z`).
- A changelog date matching its actual publication calendar date in Europe/Vienna.
- A tag pointing to the tested commit merged into `main`.
- Upgrade notes for API, runtime, storage or authentication changes.
The `Unreleased` section holds work awaiting the next release. A merge alone does
not publish a release. Under Semantic Versioning, incompatible documented API
changes require a major version, compatible features a minor version, and
compatible fixes a patch version. Do not rename or move existing published tags.
## Release checklist
1. Review changes since the previous release and determine the version from the
documented compatibility impact.
2. Update `VERSION`, move pending changelog entries into a dated version section,
leave a fresh `Unreleased` section and document upgrade steps.
3. Submit a release pull request and merge it through the protected `main` branch.
4. Verify the merge commit's CI and the version rendered by `raijuVersion()`.
Check that the release archive includes `VERSION` and the complete core.
For routing, login-destination or handler-directory changes, also record the
[Apache routing verification](ROUTING.md#required-verification) for that code.
5. Create an annotated `vX.Y.Z` tag on that exact merge commit.
6. Read back the tag target and the archive version.
## Release history
| Version | Date | Notes |
| --- | --- | --- |
| 1.0.0 | 2026-10-25 | Initial release, based on baseline commit `98067c90`. |

183
ROUTING.md Normal file
View File

@ -0,0 +1,183 @@
# Routing and deployment verification
Read this before changing URLs, redirects, `.htaccess`, `router.php`,
`route.php`, login destinations, or the directory layout of request handlers.
Apply it to site migrations and core upgrades as well as routing fixes.
## Why local success is insufficient
Production routing has multiple layers: the hosting proxy, Apache's rewrite
and directory handling, and Raiju's PHP front controller. The development
server executes `router.php` and does not interpret `.htaccess`. Passing local
PHP tests therefore does not prove that the uploaded installation routes
correctly. Verify affected routes under Apache and on the deployed host.
The concrete regression found in September 2026 was a filesystem collision:
`admin/dashboard.php` is the request handler, while `admin/dashboard/scripts/`
holds implementation fragments. Login redirected to `dashboard`. Apache
redirected `/admin/dashboard` to `/admin/dashboard/` with HTTP 301; that URL
then fell through to `route.php` and returned 404. `/admin/` continued to work
because it has an actual `index.php`. The PHP development server served the
slashless dashboard successfully and concealed the broken login flow.
The fix sends login directly to `dashboard.php` and redirects both clean URL
aliases to it. Do not change the destination back to `dashboard` without
reworking and testing the complete routing contract below.
Site-specific news/event detail routes can have a similar ordering problem
when physical listing templates compete with nested detail URLs. Preserve
the site's explicit detail rules when merging updated core rewrite rules.
## Routing contract
- Use explicit PHP URLs for internal admin endpoints that share their name
with implementation directories. New fragment directories must not silently
change existing public URLs. Check for collisions before adding them.
- Keep `/admin/` as a directory URL. The current dashboard destination is
`/admin/dashboard.php`; `/admin/dashboard` and `/admin/dashboard/` are
compatibility aliases that return HTTP 308 with query parameters preserved.
- `/admin` redirects to `/admin/`; `/login` and `/login/` are aliases for
`/admin/`. Login aliases must not be accepted as post-login return targets.
- Physical directories with an `index.php` get their missing slash. If a PHP
handler shares its name with a directory, both clean aliases resolve to the
explicit `.php` handler. A PHP handler with an extra trailing slash also
resolves to its explicit filename. These HTTP 308 redirects retain POST
method/body and query parameters. They do not change JSON-backed page slugs.
- `includes/routing-helper.php` implements the shared PHP fallback used by
`router.php` and `route.php`; `.htaccess` applies the equivalent rules before
Apache's generic file/directory handling. Update these together and include
the helper in upgrades.
- Canonicalization is route-specific. Do not add a blanket rule to append or
remove slashes across the site, or disable directory handling globally to
conceal one collision. A trailing slash changes how browsers resolve relative
assets, forms, API requests and login redirects.
- Preserve existing security rules before routing shortcuts. Put explicit
collision/detail rules before generic file/directory handling. Retain
`Options -MultiViews` and the existing front-controller rewrite termination;
changing either requires Apache verification.
- Keep Apache and `router.php` behavior aligned for paths they both serve.
`route.php` handles dynamic content; it should not interpret admin fragment
directory names as public page slugs.
- Consider cached permanent redirects. Do not introduce an inverse redirect
that loops with an older cached 301. The explicit `dashboard.php` target
avoids the previous `/admin/dashboard` to `/admin/dashboard/` redirect.
- Merge `.htaccess` deliberately during upgrades and include hidden files in
upload checks. Preserve customer security, redirects and news/event rules.
Uploading only PHP files can leave obsolete production routing in place.
- The bundled Apache substitutions use root-absolute paths. Do not claim
subdirectory hosting works based on a root installation test; verify rewrite
targets, cookies, assets and redirects under the actual deployment prefix.
## Hosting proxies and internal folders
`.htaccess` protects `content/`, `backups/`, `cli/`, `tests/` and the media
trash folders only for requests that reach Apache. Some hosting panels put
nginx in front of Apache and let it deliver static files such as `.json`,
`.txt` and `.zip` directly from disk. Those requests never reach Apache, so
`content/users.json` (password hashes), `content/settings.json` (SMTP password,
web cron token), `content/mails.json` (form submissions) and backup ZIPs become
public. This happened on a HestiaCP staging server in September 2026: a test
JSON file in `content/` returned HTTP 200 from nginx, while a PHP file in the
same folder returned 403 from Apache.
Block the folders in nginx as well. `^~` makes these prefix locations win over
the panel's regular-expression location for static files:
```nginx
location ^~ /content/ { deny all; }
location ^~ /backups/ { deny all; }
location ^~ /cli/ { deny all; }
location ^~ /tests/ { deny all; }
location ^~ /assets/images-trash/ { deny all; }
location ^~ /assets/audio-trash/ { deny all; }
location ^~ /assets/videos-trash/ { deny all; }
location ^~ /assets/documents-trash/ { deny all; }
```
- **HestiaCP:** save the lines as
`/home/<user>/conf/web/<domain>/nginx.ssl.conf_raiju` (for plain HTTP also
as `nginx.conf_raiju`); HestiaCP includes these files in the domain's
server block. Then run `nginx -t && systemctl reload nginx` as root.
- **Plesk:** the default "Smart static files processing" asks Apache first, so
`.htaccess` applies. With "Serve static files directly by nginx" switched on,
requests for the listed extensions never reach Apache. Keep the option
switched off, or add the lines under "Apache & nginx Settings" → "Additional
nginx directives".
- For an installation in a subdirectory, prefix each path with it.
Check this after the first upload and after hosting changes. Dashboard →
System status → Security requests short-lived probe files through the site's
own address and shows these rules for the current installation. A missing file
is no test: nginx passes unknown files to Apache, which answers 403. To check by
hand, request an existing file, for example
`curl -I https://example.com/content/settings.json`, and expect 403 or 404.
`python3 tests/security-check-smoke.py --apache` reproduces the proxy locally
in front of Apache and PHP-FPM, with and without these rules.
## Required verification
For routing or request-handler directory changes, run the focused regression:
```sh
python3 tests/system-smoke.py
python3 tests/routing-smoke.py
python3 tests/routing-smoke.py --apache
```
These suites use disposable accounts and content, never live credentials.
The default routing suite runs on the PHP development server; `--apache` runs
the same contract against Apache and PHP-FPM on temporary loopback ports using
the candidate `.htaccess`. It does not alter the machine's web-server services.
Set `HTTPD`, `PHP_FPM` and `HTTPD_MODULE_DIR` if their paths are not detected.
The separate Apache CI job runs this fixture too. Do not describe PHP-only
results as Apache coverage. Repeat the relevant checks on the deployed host
after an authorized upload; local Apache cannot verify hosting proxy rules.
| Check | Expected result |
| --- | --- |
| Guest opens `/admin/` | Login form, HTTP 200 |
| `/admin`, `/login`, `/login/` | HTTP 308 to `/admin/`; final login form and CSS load |
| Successful login through the form | Redirect reaches the real dashboard, HTTP 200; no intermediate 404 or loop |
| `/admin/dashboard`, `/admin/dashboard/` | HTTP 308 to `/admin/dashboard.php`; query string preserved |
| Dashboard request without a session | Redirect to login; no dashboard data exposed |
| Dashboard request with a valid session | Dashboard HTML, HTTP 200 |
| Actual admin CSS, JS, form/API URLs after navigation | Correct paths and expected content types; authenticated API still works |
| Form POST to directory URL without slash | HTTP 308; followed request retains method/body and query |
| Add fragment directory beside an existing PHP endpoint | Both clean aliases resolve to `.php`; HTML and relative CSS still work |
| Existing language homepage and nested page, with/without slash | Established site behavior, correct assets and links |
| Existing news/event listing and detail URLs in each supported language | Established site behavior; detail request reaches its intended handler |
| Unknown public and admin URLs | HTTP 404, not a successful empty page |
| Config, content and implementation-only resources | Existing access protections remain effective |
| Existing internal files through the public address, behind any hosting proxy (`/content/settings.json`, a backup ZIP) | HTTP 403 or 404; System status → Security reports no public folders |
Inspect the first response **and** the followed redirect chain. A final HTTP
200 alone may be a login or error page, so check the returned content. Use GET
requests, preserve session cookies during login tests, include a query string
such as `?tab=content&probe=a%2Fb`, and check both a fresh browser session and
previously used aliases. Verify relative assets from the final browser URL.
Record the tested code revision or file hashes, server/PHP versions, relevant
hosting prefix/proxy, request paths, statuses, Location headers and final page.
Keep test credentials and session cookies out of the evidence. If Apache or
post-upload checks have not run, state that explicitly as outstanding
verification rather than reporting a production fix as verified.
## Local verification — 2026-09-15
The candidate working tree passed all 19 smoke suites with PHP 8.5.4 and the
additional routing suite under Apache 2.4.67 + PHP-FPM 8.5.4 on macOS. The
installation was at the document root, on temporary loopback ports, without a
hosting proxy. The executable assertions in `tests/routing-smoke.py` record
the request paths, expected first-response statuses/Location values, followed
login/API content, CSS types, and POST payload retention. No live host or
subdirectory installation was tested. The new GitHub Apache job is configured;
a remote CI run has not been triggered from this local check.
Tested SHA-256 values:
```text
.htaccess 1e8a86df0fb6304f7c6cc0189ad2813e0e657c4a49ba4694750d8241a5840d42
includes/routing-helper.php c2d7aa3ff89fbf7ae89d06208e8c94801948d98ea1abfd0f008a7ef1e97b3fc0
router.php 831613fc9e35f2c252d7ebe52a56c8d01e2b8af427ae0c16470de766a6f64e6f
route.php 9709439626c7cbf7716154180f9e8244894a9580815c120fd6c9a799d3312033
```

580
SKILLS.md Normal file
View File

@ -0,0 +1,580 @@
# Raiju CMS — AI Agent Skills
Machine-readable instructions for common tasks. Each skill describes what to do, which files to touch, and how to verify the result.
**What Raiju does:** Raiju turns any HTML or PHP page into an editable website — no database required. You take an existing page (hand-crafted, designed in Figma, or vibe-coded with AI), replace hardcoded text, images, and links with PHP helper functions (`editableText()`, `editableImage()`, `editableLink()`), and get a fully functional CMS with inline editing. The helpers inject `data-*` attributes that the inline editor JavaScript discovers. Admins click to edit directly on the page. Content is stored as JSON files. Visitors see clean HTML. See `AI-AGENT-GUIDE.md` → "How Inline Editing Works" for the full attribute reference.
**Existing-site rule:** When converting an existing website, preserve the visual system first and only add editability around it. Keep the original CSS, JS, assets, fonts, class names, markup hierarchy, animation hooks, dimensions, spacing, colors, and responsive behavior unless the user explicitly requests a redesign. A visually different converted site is a migration bug, even when all fields are technically editable.
---
## Skill: make-page-editable
### Description
This is Raiju's primary use case. Take any existing HTML or PHP page — hand-built, designed in Figma and exported, or vibe-coded with AI — and turn it into a fully editable CMS page. Replace hardcoded text, images, and links with Raiju's PHP helpers. The result: admins can edit everything directly on the page, content is stored in JSON, and visitors see clean HTML. No database, no complex migration.
Before changing code, capture or inspect the source page section by section. After conversion, compare the Raiju page against the original at desktop and mobile widths. Header transparency, scroll states, gradients, accent lines, image masks, type weights, paddings, backgrounds, and animations must match unless intentionally changed.
### Automated Option
Run the converter tool to do this automatically:
```bash
php cli/convert.php my-page.html --slug=my-page --lang=en --dry-run
```
This parses the HTML, detects sections/headings/images/links/repeating patterns, and generates the PHP template + JSON file. Review the output with `--dry-run` first, then run without it to write files. See `cli/README.md` for all options.
The converter is a starter pass, not a license to rebuild the design. If the output drifts visually, repair the template and styles toward the source page instead of normalizing it to generic Raiju markup.
### Manual Steps (Prerequisites)
- Read `AI-AGENT-GUIDE.md` → "How Inline Editing Works" for the data-attribute system
- Read `AI-AGENT-GUIDE.md` → "Template API" for available editable functions
- Have the static HTML page ready
### Concept
Every piece of content that should be editable needs to be wrapped in an editable PHP function. The function reads from a JSON file and outputs `data-*` attributes that the inline editor recognizes:
| Static HTML | Editable replacement |
|---|---|
| `<h1>Welcome</h1>` | `<h1><?php echo editableText($_p, 'hero.title', 'Welcome'); ?></h1>` |
| `<p>Some text</p>` | `<p><?php echo editableText($_p, 'intro.text', 'Some text'); ?></p>` |
| `<div>Rich HTML</div>` | `<div><?php echo editableHtml($_p, 'about.content', '<p>Rich HTML</p>'); ?></div>` |
| `<a href="/x">Click</a>` | `<?php echo editableLink($_p, 'hero.cta', 'Click', '/x', 'btn'); ?>` |
| `<img src="x.jpg" alt="Y">` | `<?php echo editableImage($_p, 'hero.image', 'x.jpg', 'Y', 'img-class'); ?>` |
### Steps
1. **Add the PHP boilerplate** at the top of the page template:
```php
<?php
$pageTitle = 'Page Title';
$pageDescription = 'SEO description.';
$currentLang = 'en';
$currentPage = 'my-page';
$contentPage = 'en_my-page';
$basePath = '../';
include '../includes/header.php';
include '../includes/content-loader.php';
$_p = $contentPage;
?>
```
And the footer at the bottom:
```php
<?php include '../includes/footer.php'; ?>
```
2. **Replace every hardcoded text** with `editableText()` or `editableHtml()`:
- Use `editableText()` for single-line text (headings, labels, short paragraphs)
- Use `editableHtml()` for rich content with formatting (admin gets a floating toolbar with Bold, Italic, Link, Clean)
- Choose meaningful dot-notation keys: `section.field` (e.g. `hero.title`, `about.content`, `pricing.heading`)
3. **Replace every hardcoded link** with `editableLink()`:
```php
// Before: <a href="/contact" class="btn btn-primary">Contact Us</a>
// After:
<?php echo editableLink($_p, 'hero.cta', 'Contact Us', '/contact', 'btn btn-primary'); ?>
```
4. **Replace every content image** with `editableImage()`:
```php
// Before: <img src="assets/images/hero.jpg" alt="Hero image" class="hero__img">
// After:
<?php echo editableImage($_p, 'hero.image', 'assets/images/hero.jpg', 'Hero image', 'hero__img'); ?>
```
5. **For repeating content** (cards, features, team members), use the editable list system. For card-like items with multiple fields or image/link/icon fields, use `editableListGroupItemAttrs()` so editors get one structured modal for the whole item. For a single text-only item, keep `editableListItemAttrs()` and inline editing only:
```php
<?php $featureDefaults = ['title' => 'New', 'desc' => '', 'link' => ['text' => 'Learn more', 'href' => '#']]; ?>
<div class="cards" <?php echo editableListAttrs($_p, 'features.items', $featureDefaults); ?>>
<?php foreach (editableListItems($_p, 'features.items') as $i => $item): ?>
<div class="card" <?php echo editableListGroupItemAttrs($_p, 'features.items', $i, [], $featureDefaults, 'Feature'); ?>>
<h3><?php echo editableText($_p, "features.items.$i.title", 'Feature'); ?></h3>
<p><?php echo editableText($_p, "features.items.$i.desc", 'Description'); ?></p>
<?php echo editableLink($_p, "features.items.$i.link", 'Learn more', '#', 'card__link'); ?>
</div>
<?php endforeach; ?>
</div>
```
6. **For editable paragraph blocks** (multiple paragraphs that admins can add, delete, and reorder), use `editableTextList()`:
```php
<div class="about-text">
<?php echo editableTextList($_p, 'about.paragraphs'); ?>
</div>
```
Each paragraph gets inline editing with the floating toolbar. JSON structure:
```json
{ "about": { "paragraphs": { "0": { "content": "First paragraph..." }, "1": { "content": "Second..." } } } }
```
7. **Create the matching JSON file** `content/pages/{lang}_{slug}.json` with all the keys used in the template:
```json
{
"page": "en_my-page",
"lang": "en",
"title": "Page Title",
"hero": {
"title": "Welcome",
"subtitle": "Tagline",
"cta": { "text": "Contact Us", "href": "/contact" },
"image": { "src": "assets/images/hero.jpg", "alt": "Hero image" }
},
"features": {
"heading": "Features",
"items": {
"0": { "title": "Feature 1", "desc": "Description 1" },
"1": { "title": "Feature 2", "desc": "Description 2" }
}
}
}
```
Note: List items use numbered-object format (`"0": {...}`) not arrays.
8. **Register the page** in `includes/nav-config.php` (`$PAGE_MAPPING` and optionally `$NAV_ITEMS`).
### What NOT to make editable
- Navigation structure (handled by `nav-config.php`)
- CSS classes, layout structure, HTML skeleton
- Decorative images that are part of the design (use CSS `background-image` instead)
- JavaScript behavior or interactive elements
### Validation
- PHP template renders without errors
- When logged in: elements show `data-page` and `data-field` attributes in DOM inspector
- When logged in + edit mode active: clicking text makes it editable inline
- When logged in: clicking images opens image picker, clicking links opens link editor
- When logged out: page shows clean HTML with no `data-*` attributes
- Changes made in edit mode persist after "Speichern" (save) and page reload
---
## Skill: create-page
### Description
Create a new standard page with content editable via the inline editor. No PHP template needed.
### Prerequisites
- Read `AI-AGENT-GUIDE.md` for conventions
- Read `includes/nav-config.php` for existing pages and language config
### Steps
1. Create `content/pages/{lang}_{slug}.json`:
```json
{
"page": "{lang}_{slug}",
"lang": "{lang}",
"title": "Page Title",
"description": "SEO description.",
"sections": [
{ "id": "s1", "type": "heading", "text": "Page Title", "level": "h1" },
{ "id": "s2", "type": "text", "title": "", "content": "<p>Content here.</p>" }
]
}
```
2. Add entry to `$PAGE_MAPPING` in `includes/nav-config.php`:
```php
'{slug}' => '{lang}_{slug}',
```
3. (Optional) Add to `$NAV_ITEMS` if the page should appear in navigation.
### Validation
- `content/pages/{lang}_{slug}.json` exists and is valid JSON
- Page is accessible at `/{slug}` (primary lang) or `/{lang}/{slug}`
- Sections render correctly via `renderAllSections()`
---
## Skill: add-section
### Description
Add a new content section to an existing page.
### Prerequisites
- Read `AI-AGENT-GUIDE.md` → Block Types table for available types and fields
- Read the target page's JSON file
### Steps
1. Open `content/pages/{lang}_{slug}.json`
2. Append a new object to the `sections` array:
```json
{
"id": "s{next_number}",
"type": "{block_type}",
...fields per block type...
}
```
3. Ensure `id` is unique within the page
### Available Block Types
| Type | Required Fields |
|---|---|
| `text` | `content` (HTML) |
| `heading` | `text`, `level` (h1-h6) |
| `quote` | `text` |
| `list` | `title`, `style` (bullet/numbered), `content` (HTML) |
| `image` | `src`, `alt` |
| `card` | `title`, `content` |
| `youtube` | `videoId` |
| `soundcloud` | `trackId` |
| `audio` | `src` |
| `divider` | *(none)* |
| `spacer` | `height` (sm/md/lg/xl) |
### Validation
- JSON is valid after edit
- New section has a unique `id`
- Page renders without errors
---
## Skill: create-json-form
### Description
Create or adjust a simple public form using Raiju's JSON-backed form system.
Use this when a generated/vibe-coded site needs a form that editors can later
rename, re-label, or slightly adjust in the dashboard without editing PHP.
### Prerequisites
- Read `AI-AGENT-GUIDE.md` -> "JSON-Backed Public Forms"
- Check existing definitions in `content/forms/*.json`
- Decide whether the requested form is simple enough for JSON. If it needs
advanced conditional logic, custom widgets, uploads, or page-specific
calculations, keep that behaviour in a site-owned template/script and use the
JSON form only for the editable field definition when practical.
### Steps
1. Create `content/forms/{id}.json`:
```json
{
"id": "contact",
"label": "Contact form",
"description": "",
"enabled": true,
"submit": {
"store": true,
"email": true,
"subject": "{form}: {name}",
"successText": "Thank you for your message."
},
"fields": [
{ "key": "name", "type": "text", "label": "Name", "required": true, "width": 6 },
{ "key": "email", "type": "email", "label": "Email", "required": true, "width": 6 },
{ "key": "message", "type": "textarea", "label": "Message", "required": true, "width": 12 }
]
}
```
2. Use supported field types only: `text`, `email`, `tel`, `textarea`,
`select`, `radio`, `checkbox`, `date`, `time`, `hidden`, `heading`, `note`.
3. Use supported widths only: `3`, `4`, `6`, `8`, `12`.
4. For `select` and `radio`, add `options`:
```json
"options": [
{ "value": "private", "label": "Private" },
{ "value": "business", "label": "Business" }
]
```
5. Render it in a template:
```php
<?php
require_once __DIR__ . '/../includes/forms.php';
echo raijuForm('contact', ['basePath' => $basePath, 'lang' => $currentLang]);
?>
```
Or use `raijuLazyFormPlaceholder('contact', ...)` when the form should load
lazily.
### Validation
- JSON is valid.
- `php -l includes/forms.php api/form-submit.php api/form.php` passes after core changes.
- `api/form.php?form={id}` renders the form with `form_id`, `form_token`,
`.raiju-form-grid`, `.btn-text`, `.btn-loading`, and `.form-feedback`.
- Submitting stores a row in `content/mails.json` with `formId`, `formLabel`,
and `fields[]`.
- The dashboard messages view can filter by the form.
- The form appears in **Dashboard -> Settings -> Forms** and reloads with all
fields intact.
---
## Skill: create-news-post
### Description
Create a news article that appears in the news listing.
### Prerequisites
- Read `AI-AGENT-GUIDE.md` for conventions
- Check `content/news/` for existing slug patterns
### Steps
1. Create `content/news/{slug}.json`:
```json
{
"slug": "{slug}",
"lang": "{lang}",
"title": "Article Title",
"description": "Short summary for listing.",
"date": "YYYY-MM-DD",
"image": "assets/images/news_{slug}.webp",
"sections": [
{ "id": "s1", "type": "heading", "text": "Article Title", "level": "h1" },
{ "id": "s2", "type": "text", "title": "", "content": "<p>Article body.</p>" }
]
}
```
2. (Optional) Add an image to `assets/images/`
### Validation
- `content/news/{slug}.json` exists and is valid JSON
- Post appears in news listing rendered by `renderNewsList()`
- Post is accessible at `/{lang}/news/{slug}`
---
## Skill: add-component
### Description
Add a render component (FAQ, Pricing, Team, Gallery, etc.) to a custom layout page.
### Prerequisites
- Read `AI-AGENT-GUIDE.md` → Render Components for available components
- Read the target page's JSON and PHP template
### Steps
1. Add the data structure to the page's JSON file. Each component reads from a specific key:
| Component | JSON Key | Item Structure |
|---|---|---|
| `renderFeatureGrid()` | `features.items` | `{icon, title, desc}` |
| `renderPricingTable()` | `pricing.plans` | `{name, price, period, desc, features, cta}` |
| `renderFaqAccordion()` | `faq.entries` | `{question, answer}` |
| `renderTeamGrid()` | `team.members` | `{name, role, bio, image}` |
| `renderGallery()` | `gallery.images` | `{src, alt, caption}` |
| `renderTimeline()` | `timeline.entries` | `{date, title, content}` |
| `renderStats()` | `stats.items` | `{value, label}` |
| `renderTestimonials()` | `testimonials.items` | `{text, author, role}` |
| `renderComparisonTable()` | `comparison.rows` | `{feature, values[]}` |
2. Add the PHP render call in the page template:
```php
<?php echo renderFaqAccordion($_p); ?>
```
3. Component CSS classes are in `css/components.css` (auto-loaded).
### Validation
- JSON data matches expected structure for the component
- Component renders on the page without errors
- Styling matches other components (uses BEM classes from `components.css`)
---
## Skill: change-theme
### Description
Customize the site's colors, fonts, and spacing via CSS Custom Properties.
### Prerequisites
- Read `css/style.css` → `:root` block for current token values
### Steps
1. Edit CSS custom properties in `css/style.css`:
```css
:root {
--color-primary: #your-color;
--color-primary-dark: #darker-shade;
--color-primary-light: #lighter-shade;
--color-secondary: #accent-color;
--font-display: 'Your Font', sans-serif;
--font-body: 'Your Font', sans-serif;
}
```
2. For custom fonts: place font files in `assets/fonts/`, create `css/fonts.css` with `@font-face` declarations — `header.php` loads it automatically if the file exists.
3. Update gradient tokens to match new primary color:
```css
--color-primary-btn: radial-gradient(ellipse at 50% 0%, #lighter 0%, #primary 70%);
--gradient-brand: linear-gradient(135deg, #lighter 0%, #darker 100%);
```
### Validation
- All color values in `:root` are consistent (dark/light/gradient variants match primary)
- No hardcoded color values outside of `:root`
- Text contrast meets WCAG AA (4.5:1 for body text)
---
## Skill: ai-schema-override
### Description
Restrict what the AI agent (the Copilot's agent mode) may change on a page you built. The agent learns a page's fields by rendering its template and reading the `data-*` attributes (the "capability map"), so a correctly built Raiju page needs no extra file. Add an override only when a site needs tighter rules than the template shows: a headline that must stay short, a legally relevant paragraph the agent must not touch, a card list with a maximum, or a hint such as "write in the formal tone". Overrides can only **restrict**; they can never make a field editable or lift a limit.
### Prerequisites
- Read `AI-AGENT-GUIDE.md` → "AI Agent (Frontend Copilot)"
- The page is already editable (see `make-page-editable`); override the paths its `editableText()`, `editableHtml()`, `editableImage()` and list helpers produce.
### Steps
1. Find the paths the agent sees: open the page as editor, ask the agent in the chat to "list what can be edited on this page", or read the `data-field` values of the rendered page.
2. Create `content/ai-schema/{target}.json`. The file name is the target with `:` replaced by `_`:
- page `en_services__test` (the content key; `/` becomes `__`) → `page_en_services__test.json`
- news post `2026-10-10-launch` → `news_2026-10-10-launch.json`
- shared footer / sidebar → `page_footer.json` / `page_sidebar.json`
3. Describe the restrictions:
```json
{
"fields": {
"hero.title": { "maxLength": 60, "required": true },
"legal.notice": { "readOnly": true },
"team.image": { "imageAspect": "1:1", "creditPath": "team.imageCredit" }
},
"lists": {
"team.items": { "minItems": 1, "maxItems": 8 }
},
"readOnly": ["pricing.*", "legal.*"],
"hints": ["Address visitors formally.", "Never mention competitors."],
"news": { "requireImage": true }
}
```
- `fields.{path}`: `maxLength` (lowers the limit), `required`, `readOnly`, `imageAspect` (e.g. `16:9`), `creditPath` (field that receives photo credits; by default the agent uses the caption of image sections).
- `lists.{listKey}`: `minItems` and `maxItems` (0–1000).
- `readOnly`: path patterns in shell style (`*` matches any characters, e.g. `pricing.*` covers every field and list below `pricing`; letters, digits, `_`, `.`, `-`, `*` are allowed); matching fields and lists are not editable by the agent.
- `hints`: up to 10 short sentences (200 characters each) that go into the agent's view of the page. Hints are guidance, not security: the agent's text is still validated.
- `news.requireImage`: new news posts need a cover image (only used for news targets).
4. Optional site-wide file `content/ai-schema/_site.json` may use only `readOnly`, `hints` and `news`; `fields` and `lists` are ignored there because they are page specific.
5. Clear the page scan cache after you changed a **template** (Dashboard → Settings → AI Settings → "AI agent" → "Clear page scan cache"). Changing an override file takes effect immediately.
### Validation
- `php -r 'json_decode(file_get_contents("content/ai-schema/page_en_home.json"), true) ?: exit(1);'` (valid JSON; an invalid file is ignored and the agent mentions it)
- Ask the agent to change a read-only field: it must answer that the field cannot be edited, and a staged operation must fail validation.
- `python3 tests/run-smoke.py` stays green (`agent-capabilities-smoke.php` covers the restriction rules).
- `content/ai-schema/` is under `content/` and therefore not public.
---
## Skill: register-dashboard-location
### Description
Teach the AI assistants where something is in the dashboard. The Raiju map (`includes/ai/raiju-map.php`) is the one table that knows the dashboard areas, the settings tabs and the single settings people ask about ("where do I change the logo?"). The assistants answer with the exact place (Settings › Branding › Logo) and a link that opens in a new tab. Do this whenever you add a dashboard area, a settings tab or a setting.
### Prerequisites
- Read `AI-AGENT-GUIDE.md` → "Raiju map"
- The area, tab or setting exists in `admin/dashboard.php` and has its label in `admin/lang/*.json` (all nine languages).
### Steps
1. New area (`data-tab`) or settings tab (`data-settings-tab`): add it to `raijuMapSections()` or `raijuMapSettingsTabs()` (label key, role `admin` or `editor`, module key or `needs AI`), and add its `area_…` or `tab_…` entry to `raijuMapEntries()`. Also add the new tab to `VALID_DASHBOARD_TABS`/the settings tab handling of the dashboard so `#settings/{tab}` opens it.
2. New single setting: add one entry to `raijuMapEntries()`: `[area, tab, label key, [synonyms], 'English hint']`. Use the label that the dashboard shows (so the name exists in every language); synonyms are the words people use instead (any language). A synonym may belong to one entry only.
3. If the assistant may change the setting after a confirm, see "Dashboard actions" in `AI-AGENT-GUIDE.md` (`RAIJU_DASHBOARD_SETTINGS`, `raijuMapActionEntries()`, the runner in `admin/dashboard/scripts/settings.php`).
### Validation
- `php tests/raiju-map-smoke.php` (the map matches the dashboard, every name exists in nine languages, no duplicated synonyms, links stay on the site).
- Ask the dashboard assistant "Where do I find …?": it names the place and offers a card with an "Open in a new tab" link.
---
## Skill: add-language
### Description
Add a new language to the site.
### Prerequisites
- Read `includes/nav-config.php` for `$SITE_LANGUAGES` and `$NAV_ITEMS`
- Read `admin/config.php` for `SITE_LANG_DEFAULT`
### Steps
1. Create directory `{lang}/` (e.g. `fr/`)
2. Create `{lang}/index.php` (homepage template):
```php
<?php
$pageTitle = 'Accueil';
$pageDescription = 'Description.';
$currentLang = 'fr';
$currentPage = 'home';
$contentPage = 'fr_home';
$basePath = '../';
include '../includes/header.php';
include '../includes/content-loader.php';
$_p = $contentPage;
?>
<main class="main-content">
<div class="content-inner">
<?php echo renderAllSections($_p); ?>
</div>
</main>
<?php include '../includes/footer.php'; ?>
```
3. Copy content files: for each `content/pages/{source_lang}_*.json`, create `content/pages/{lang}_*.json` with translated text and updated `"page"` and `"lang"` fields.
4. Add the language to `$SITE_LANGUAGES` in `includes/nav-config.php`:
```php
$SITE_LANGUAGES = ['en' => 'English', 'de' => 'Deutsch', 'fr' => 'Français'];
```
5. Add `$NAV_ITEMS['{lang}']` array mirroring the structure of existing languages.
### Validation
- `{lang}/index.php` exists
- Content files exist for all pages in the new language
- Language appears in the language switcher
- Navigation works for the new language
---
## Skill: create-custom-layout
### Description
Create a page with a custom PHP template (hero sections, grids, components) instead of the standard `renderAllSections()` approach.
### Prerequisites
- Read `AI-AGENT-GUIDE.md` → "How to Create a Custom Layout Page"
- Read `examples/templates/page-custom.php` for reference
- Read `examples/content/custom-layout.json` for JSON structure
### Steps
1. Create `{lang}/{slug}.php`:
```php
<?php
$pageTitle = 'Page Title';
$pageDescription = 'Description.';
$currentLang = '{lang}';
$currentPage = '{slug}';
$contentPage = '{lang}_{slug}';
$basePath = '../';
include '../includes/header.php';
include '../includes/content-loader.php';
$_p = $contentPage;
?>
<main class="main-content">
<section class="hero">
<h1><?php echo editableText($_p, 'hero.title', 'Default Title'); ?></h1>
<p><?php echo editableText($_p, 'hero.subtitle', 'Default subtitle'); ?></p>
<?php echo editableImage($_p, 'hero.image', 'https://placehold.co/800x400', 'Hero image'); ?>
</section>
<?php echo renderFeatureGrid($_p); ?>
<?php echo renderFaqAccordion($_p); ?>
</main>
<?php include '../includes/footer.php'; ?>
```
2. Create `content/pages/{lang}_{slug}.json` with matching keys:
```json
{
"page": "{lang}_{slug}",
"lang": "{lang}",
"title": "Page Title",
"hero": {
"title": "Welcome",
"subtitle": "Tagline",
"image": { "src": "https://placehold.co/800x400", "alt": "Hero" }
},
"features": {
"heading": "Features",
"items": { "0": { "icon": "zap", "title": "Fast", "desc": "No database." } }
},
"faq": {
"heading": "FAQ",
"entries": { "0": { "question": "How?", "answer": "Like this." } }
}
}
```
3. Add to `$PAGE_MAPPING` and optionally `$NAV_ITEMS` in `includes/nav-config.php`.
### Validation
- PHP template renders without errors
- All `editableText()`/`editableImage()`/`editableLink()` fields display content from JSON
- Render components (features, FAQ, etc.) display correctly
- Inline editor works when logged in (fields are editable, changes save)

252
THIRD-PARTY-NOTICES.md Normal file
View File

@ -0,0 +1,252 @@
# Third-party notices
## MingCute Icons
Raiju can import icons from the public MingCute Icons collection through Iconify. Imported icons retain their source and license metadata in the site-owned icon set.
Source: https://github.com/mingcute-design/mingcute-icons
License: Apache License 2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
## Geist Mono
The dashboard's Console theme can use the Geist Mono typeface, shipped locally in `admin/fonts/` (`geist-mono-variable.woff2`). No external request is made.
Source: https://github.com/vercel/geist-font
License: MIT License (SIL Open Font License 1.1 for releases from 1.0.0)
## IBM Plex Sans
The dashboard can use the IBM Plex Sans typeface, shipped locally in `admin/fonts/` as the variable font in the latin and latin-ext subsets (`ibm-plex-sans-variable-latin.woff2`, `ibm-plex-sans-variable-latin-ext.woff2`, downloaded once from Google Fonts). No external request is made at runtime.
Source: https://github.com/IBM/plex (files via https://fonts.google.com/specimen/IBM+Plex+Sans)
License: SIL Open Font License 1.1, Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
## Atkinson Hyperlegible Next
The dashboard can use the Atkinson Hyperlegible Next typeface, shipped locally in `admin/fonts/` as the variable font in the latin and latin-ext subsets (`atkinson-hyperlegible-next-variable-latin.woff2`, `atkinson-hyperlegible-next-variable-latin-ext.woff2`, downloaded once from Google Fonts). No external request is made at runtime.
Source: https://github.com/googlefonts/atkinson-hyperlegible-next (files via https://fonts.google.com/specimen/Atkinson+Hyperlegible+Next)
License: SIL Open Font License 1.1, Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors
## Public Sans
The dashboard can use the Public Sans typeface, shipped locally in `admin/fonts/` as the variable font in the latin and latin-ext subsets (`public-sans-variable-latin.woff2`, `public-sans-variable-latin-ext.woff2`, downloaded once from Google Fonts). No external request is made at runtime.
Source: https://github.com/uswds/public-sans (files via https://fonts.google.com/specimen/Public+Sans)
License: SIL Open Font License 1.1 for the Libre Franklin original (Copyright 2015 Impallari Type with Reserved Font Name "Libre Franklin"); the modifications by the U.S. General Services Administration are dedicated to the public domain under CC0 1.0.
## Inter
The dashboard can use the Inter typeface, shipped locally in `admin/fonts/` as the variable font in the latin and latin-ext subsets (`inter-variable-latin.woff2`, `inter-variable-latin-ext.woff2`, downloaded once from Google Fonts). No external request is made at runtime.
Source: https://github.com/rsms/inter (files via https://fonts.google.com/specimen/Inter)
License: SIL Open Font License 1.1

17
UPGRADING.md Normal file
View File

@ -0,0 +1,17 @@
# Upgrading
## Upgrading to 1.0.0
1.0.0 is the first release and a fresh baseline. Deploy the complete
core; no manual step is needed.
1. Copy the complete core over the installation, including `VERSION`, `includes/`,
`admin/`, `css/`, `js/` and the root PHP files. Keep site-local data:
`admin/config.php`, `content/`, `assets/images/`, `assets/fonts/` and uploads.
2. Open the dashboard once and check **System status** for the PHP version,
extensions, paths and upload limits.
3. If the site uses a custom layout, verify that its templates and editable fields
still render.
4. Custom save API integrations must send the resource revision with each save.
The version rendered by `raijuVersion()` must read `1.0.0` from `VERSION`.

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.0.0

41
admin/api.php Normal file
View File

@ -0,0 +1,41 @@
<?php
/** Stable public endpoint; domain handlers own individual CMS workflows. */
define('RAIJU_ADMIN_DIR', __DIR__);
require_once __DIR__ . '/api/bootstrap.php';
$action = $_GET['action'] ?? $_POST['action'] ?? '';
// A POST above post_max_size reaches PHP with empty $_POST and $_FILES, so the
// action is lost too. Name the server limit instead of "Unknown action".
if ($action === '' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && empty($_POST) && empty($_FILES)
&& (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > raijuUploadServerLimit()) {
http_response_code(413);
jsonResponse(false, ['serverLimitBytes' => raijuUploadServerLimit()], sprintf('File too large: the server accepts uploads up to %s MB (upload_max_filesize / post_max_size)', raijuMegabytes(raijuUploadServerLimit())));
}
// Inbox mutations share the same transaction lock as public submissions.
if (in_array($action, ['mark-mail-read', 'update-mail-flags', 'mark-all-mails-read', 'delete-mail', 'delete-read-mails'], true)) {
$mailLock = raijuJsonLock(dirname(__DIR__) . '/content/mails.json');
if ($mailLock === false) {
http_response_code(503);
jsonResponse(false, null, 'Could not lock inbox storage');
}
register_shutdown_function(static function () use ($mailLock): void {
flock($mailLock, LOCK_UN);
fclose($mailLock);
});
$inboxPath = dirname(__DIR__) . '/content/mails.json';
if (is_file($inboxPath) && !is_array(json_decode((string)file_get_contents($inboxPath), true))) {
http_response_code(503);
jsonResponse(false, null, 'Inbox storage is damaged; existing data was preserved');
}
}
$routes = require __DIR__ . '/api/routes.php';
if (!is_string($action) || !isset($routes[$action])) {
http_response_code(404);
jsonResponse(false, null, 'Unknown action');
}
require __DIR__ . '/api/contracts.php';
require __DIR__ . '/api/' . $routes[$action] . '.php';

307
admin/api/agent.php Normal file
View File

@ -0,0 +1,307 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// AI agent: runs (start/step/status/reply/cancel) and change sets (review/apply/undo/publish).
// The dispatcher has already checked the session and the CSRF token of every POST.
require_once RAIJU_ADMIN_DIR . '/../includes/ai/agent/run.php';
require_once RAIJU_ADMIN_DIR . '/../includes/ai/agent/rate-limit.php';
if (!function_exists('raijuAgentApiGuard')) {
/** Module, right and (for actions that need the model) availability; returns the acting user. */
function raijuAgentApiGuard(bool $needsModel): array {
if (!dashboardAiModuleEnabled()) {
jsonResponse(false, null, 'AI module is disabled');
}
if (!raijuAgentCan('chat')) {
http_response_code(403);
jsonResponse(false, null, 'Forbidden');
}
if ($needsModel) {
$reason = '';
if (!raijuAgentAvailable($reason)) {
jsonResponse(false, ['available' => false], $reason);
}
}
$actor = raijuAgentActor();
if ($actor['userKey'] === '') {
http_response_code(403);
jsonResponse(false, null, 'Forbidden');
}
return $actor;
}
function raijuAgentApiRateLimit(array $actor, string $bucket): void {
$limits = raijuAgentSettings()['limits'];
$config = ['runs' => ['limit' => $limits['runsPerHour'], 'window' => 3600], 'steps' => ['limit' => $limits['stepsPerMinute'], 'window' => 60]][$bucket];
$result = raijuAgentRateLimit($actor['userKey'], $bucket, $config);
if (!$result['allowed']) {
http_response_code(429);
header('Retry-After: ' . $result['retryAfter']);
jsonResponse(false, ['retryAfter' => $result['retryAfter']], 'Too many agent requests. Please wait a moment and try again.');
}
}
function raijuAgentApiRunId(): string {
$id = $_POST['runId'] ?? $_GET['runId'] ?? '';
if (!is_string($id) || !raijuAgentValidRunId($id)) {
jsonResponse(false, null, 'Invalid run');
}
return $id;
}
function raijuAgentApiChangesetId(): string {
$id = $_POST['id'] ?? $_GET['id'] ?? '';
if (!is_string($id) || !raijuAgentValidChangesetId($id)) {
jsonResponse(false, null, 'Invalid change set');
}
return $id;
}
}
switch ($action) {
case 'ai-agent-start':
$actor = raijuAgentApiGuard(true);
try {
raijuCopilotAssertBurstLimit('agent', 120, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
raijuAgentApiRateLimit($actor, 'runs');
try {
$attached = (string)($_POST['changesetId'] ?? '');
if ($attached !== '' && !raijuAgentValidChangesetId($attached)) {
jsonResponse(false, null, 'Invalid change set');
}
$run = raijuAgentRunStart($actor, (string)($_POST['goal'] ?? ''), $attached);
} catch (InvalidArgumentException $e) {
jsonResponse(false, null, $e->getMessage());
}
// Housekeeping is cheap and rare: once an hour is enough.
$marker = raijuAgentDir() . '/cleanup.stamp';
if (!is_file($marker) || filemtime($marker) < time() - 3600) {
@touch($marker);
raijuAgentCleanup();
}
jsonResponse(true, raijuAgentRunPublicState($run), 'Run started');
break;
case 'ai-agent-step':
$actor = raijuAgentApiGuard(true);
$runId = raijuAgentApiRunId();
try {
raijuCopilotAssertBurstLimit('agent', 120, 60);
} catch (Throwable $e) {
jsonResponse(false, null, $e->getMessage());
}
raijuAgentApiRateLimit($actor, 'steps');
// A step can take a while; do not hold the session lock (other tabs, the editor) meanwhile.
session_write_close();
$result = raijuAgentRunStep($runId, $actor);
if (($result['status'] ?? '') === 'missing') {
http_response_code(404);
jsonResponse(false, null, 'Unknown run');
}
if (!empty($result['busy'])) {
jsonResponse(true, ['runId' => $runId, 'status' => 'busy'], 'Another step of this run is in progress');
}
jsonResponse(true, $result['state'], 'OK');
break;
case 'ai-agent-upload':
$actor = raijuAgentApiGuard(true);
if (!raijuAgentCan('uploadImages')) {
http_response_code(403);
jsonResponse(false, null, 'Forbidden');
}
$limits = raijuAgentSettings()['limits'];
$rate = raijuAgentRateLimit($actor['userKey'], 'uploads', ['limit' => $limits['uploadsPer10Minutes'], 'window' => 600]);
if (!$rate['allowed']) {
http_response_code(429);
header('Retry-After: ' . $rate['retryAfter']);
jsonResponse(false, ['retryAfter' => $rate['retryAfter']], 'Too many uploads. Please wait a moment and try again.');
}
$file = $_FILES['image'] ?? $_FILES['file'] ?? null;
if (!is_array($file) || !is_string($file['tmp_name'] ?? null) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) {
jsonResponse(false, null, 'Upload error');
}
$imageConfig = getMediaConfig('image');
$extension = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
jsonResponse(false, null, 'Only JPEG, PNG and WebP images are accepted.');
}
if ((int)$file['size'] > (int)$imageConfig['maxSize']) {
jsonResponse(false, null, 'File too large');
}
$attached = (string)($_POST['changesetId'] ?? '');
if ($attached !== '') {
$target = raijuAgentValidChangesetId($attached) ? raijuAgentChangesetLoad($attached) : null;
if ($target === null || (string)$target['owner'] !== $actor['userKey'] || $target['status'] !== 'staging') {
jsonResponse(false, null, 'Unknown change set');
}
} else {
$attached = raijuAgentChangesetCreate(['owner' => $actor['userKey']])['id'];
}
try {
$image = raijuAgentIngestImage($file['tmp_name'], ['maxBytes' => (int)$imageConfig['maxSize']]);
$record = raijuAgentMediaRegister($attached, $image, ['source' => 'upload', 'name' => (string)$file['name'], 'ai' => $image['ai']]);
} catch (RuntimeException $e) {
jsonResponse(false, null, $e->getMessage());
}
raijuAgentAudit('agent-upload', true, ['cs' => $attached, 'ref' => $record['ref'], 'bytes' => $record['bytes']]);
jsonResponse(true, ['changesetId' => $attached, 'media' => raijuAgentMediaSummary($record['ref'], $record),
'thumbnail' => 'admin/api.php?action=ai-agent-media&id=' . $attached . '&ref=' . $record['ref']], 'Uploaded');
break;
case 'ai-agent-clear-cache':
// The cached scan of the page templates; rebuilt on demand. Settings area, administrators only.
if (!dashboardAiModuleEnabled()) jsonResponse(false, null, 'AI module is disabled');
if (!isAdmin()) {
http_response_code(403);
jsonResponse(false, null, 'Forbidden');
}
$removed = raijuAgentClearCapabilityCache();
raijuAgentAudit('agent-cache-clear', true, ['files' => $removed]);
jsonResponse(true, ['removed' => $removed], 'Cache cleared');
break;
case 'ai-agent-media':
// Staged images (uploads, stock photos, generated images) for the review panel; owner and admins only.
$actor = raijuAgentApiGuard(false);
$cs = raijuAgentChangesetLoad(raijuAgentApiChangesetId());
$ref = (string)($_GET['ref'] ?? '');
$media = ($cs !== null && preg_match('/^m_[a-f0-9]{8}$/', $ref)) ? ($cs['media'][$ref] ?? null) : null;
if ($cs === null || $media === null || !raijuAgentCanSeeChangeset($cs, $actor)) {
http_response_code(404);
jsonResponse(false, null, 'Unknown image');
}
$stagingDir = realpath(raijuAgentMediaStagingDir($cs['id']));
$file = realpath(raijuAgentAbsolute((string)$media['stagingFile']));
if ($stagingDir === false || $file === false || !str_starts_with($file, $stagingDir . DIRECTORY_SEPARATOR) || !is_file($file)) {
http_response_code(404);
jsonResponse(false, null, 'The image is no longer staged');
}
header_remove('Content-Type');
header('Content-Type: ' . ($media['mime'] ?? 'image/webp'));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, max-age=300');
header('Content-Length: ' . filesize($file));
readfile($file);
exit;
case 'ai-agent-status':
$actor = raijuAgentApiGuard(false);
$state = raijuAgentRunStatus(raijuAgentApiRunId(), $actor, (int)($_GET['after'] ?? 0));
if ($state === null) {
http_response_code(404);
jsonResponse(false, null, 'Unknown run');
}
jsonResponse(true, $state, 'OK');
break;
case 'ai-agent-reply':
$actor = raijuAgentApiGuard(true);
$result = raijuAgentRunReply(raijuAgentApiRunId(), $actor, (string)($_POST['message'] ?? ''));
if (($result['status'] ?? '') === 'missing') {
http_response_code(404);
jsonResponse(false, null, 'Unknown run');
}
if (!empty($result['busy'])) {
jsonResponse(true, ['status' => 'busy'], 'The agent is working; try again in a moment');
}
if (isset($result['error'])) {
jsonResponse(false, null, $result['error']);
}
jsonResponse(true, $result['state'], 'OK');
break;
case 'ai-agent-cancel':
$actor = raijuAgentApiGuard(false);
$result = raijuAgentRunCancel(raijuAgentApiRunId(), $actor);
if (($result['status'] ?? '') === 'missing') {
http_response_code(404);
jsonResponse(false, null, 'Unknown run');
}
if (isset($result['error'])) {
jsonResponse(false, null, $result['error']);
}
jsonResponse(true, $result['state'] ?? ['status' => $result['status'] ?? 'cancelling'], 'OK');
break;
case 'ai-agent-changeset':
$actor = raijuAgentApiGuard(false);
$cs = raijuAgentChangesetLoad(raijuAgentApiChangesetId());
if ($cs === null || !raijuAgentCanSeeChangeset($cs, $actor)) {
http_response_code(404);
jsonResponse(false, null, 'Unknown change set');
}
// After the apply the owner needs the items to publish and the undo token, also after a reload.
$applied = $cs['status'] === 'applied' && !empty($cs['applied'])
? ['items' => array_values((array)$cs['applied']['items']), 'undoToken' => raijuAgentUndoToken($cs['id'])] : null;
jsonResponse(true, raijuAgentChangesetPreview($cs) + ['canPublish' => $actor['canPublish'], 'applied' => $applied], 'OK');
break;
case 'ai-agent-revalidate':
$actor = raijuAgentApiGuard(false);
$id = raijuAgentApiChangesetId();
$result = raijuAgentChangesetRevalidate($id, $actor);
if (empty($result['ok'])) {
// The status of a change set is only for its owner (and admins).
$current = raijuAgentChangesetLoad($id);
$visible = $current !== null && raijuAgentCanSeeChangeset($current, $actor);
jsonResponse(false, ['status' => $visible ? $current['status'] : 'missing'], (string)($result['error'] ?? 'Revalidation failed'));
}
$report = raijuAgentChangesetFinalize($id);
jsonResponse(true, ['status' => $report['status'], 'renderCheck' => $report['renderCheck']['status'] ?? 'skipped', 'previewHash' => $report['previewHash']], 'OK');
break;
case 'ai-agent-apply':
$actor = raijuAgentApiGuard(false);
$id = raijuAgentApiChangesetId();
if (($_POST['confirmed'] ?? '') !== '1') {
jsonResponse(false, null, 'Confirmation required');
}
$result = raijuAgentChangesetApply($id, (string)($_POST['previewHash'] ?? ''), $actor, ['confirmRisk' => ($_POST['confirmRisk'] ?? '') === '1']);
if (empty($result['ok'])) {
if (($result['status'] ?? '') === 'conflict') {
http_response_code(409);
}
jsonResponse(false, ['status' => $result['status'] ?? 'failed', 'needsRiskConfirm' => !empty($result['needsRiskConfirm'])], (string)$result['error']);
}
jsonResponse(true, ['status' => 'applied', 'undoToken' => $result['undoToken'], 'items' => $result['items'], 'files' => $result['files']], 'Applied');
break;
case 'ai-agent-discard':
$actor = raijuAgentApiGuard(false);
if (!raijuAgentChangesetDiscard(raijuAgentApiChangesetId(), $actor)) {
jsonResponse(false, null, 'This change set cannot be discarded');
}
jsonResponse(true, ['status' => 'discarded'], 'Discarded');
break;
case 'ai-agent-undo':
$actor = raijuAgentApiGuard(false);
$result = raijuAgentChangesetUndo(raijuAgentApiChangesetId(), (string)($_POST['token'] ?? ''), $actor);
if (empty($result['ok'])) {
jsonResponse(false, ['status' => $result['status'] ?? 'failed', 'needsRiskConfirm' => !empty($result['needsRiskConfirm'])], (string)$result['error']);
}
jsonResponse(true, ['status' => 'undone'], 'Undone');
break;
case 'ai-agent-publish':
$actor = raijuAgentApiGuard(false);
$id = raijuAgentApiChangesetId();
$item = json_decode((string)($_POST['item'] ?? ''), true);
if (!is_array($item) || !is_string($item['kind'] ?? null) || !is_string($item['target'] ?? null)) {
jsonResponse(false, null, 'Invalid item');
}
$item = array_intersect_key($item, array_flip(['kind', 'target', 'listKey', 'index', 'sectionId']));
$result = raijuAgentChangesetPublish($id, $item, $actor);
if (empty($result['ok'])) {
if (str_contains((string)$result['error'], 'not allowed to publish')) {
http_response_code(403);
}
jsonResponse(false, null, (string)$result['error']);
}
jsonResponse(true, ['status' => 'published'], 'Published');
break;
}

1773
admin/api/ai.php Normal file

File diff suppressed because it is too large Load Diff

1000
admin/api/backups.php Normal file

File diff suppressed because it is too large Load Diff

2056
admin/api/bootstrap.php Normal file

File diff suppressed because it is too large Load Diff

77
admin/api/contracts.php Normal file
View File

@ -0,0 +1,77 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// All writes use POST and a session CSRF token, including multipart uploads.
// Read-only exports may use GET with their existing signed/session checks.
if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'], true)) {
http_response_code(405); header('Allow: GET, POST'); jsonResponse(false, null, 'Unsupported request method');
}
$readOnly = preg_match('/^(load|list|get)-/', $action) || in_array($action, [
'load', 'backups', 'preview-backup', 'keepalive', 'dashboard-overview', 'system-status',
'iconify-search', 'ai-image-history', 'ai-image-jobs', 'ai-copilot-context',
'ai-copilot-history-list', 'ai-copilot-history-load', 'ai-openrouter-models', 'ai-agent-status', 'ai-agent-changeset', 'ai-agent-media',
'media-trash-file', 'image-trash-file', 'unread-mail-count', 'download-site-backup',
'backup-status', 'backup-list', 'backup-remote-list'
], true);
$oauthCallback = preg_match('/^backup-(dropbox|google|onedrive)-(oauth|broker)-callback$/', $action);
if ($_SERVER['REQUEST_METHOD'] === 'GET' && !$readOnly && !$oauthCallback) {
http_response_code(405); header('Allow: POST'); jsonResponse(false, null, 'Use POST for this action');
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !validateCsrfToken()) {
http_response_code(403);
jsonResponse(false, null, 'Invalid CSRF token');
}
// Keep editors away from server credentials and installation administration.
if (in_array($routes[$action], ['backups', 'users', 'settings'], true)
&& !in_array($action, ['load-settings', 'change-password'], true) && !isAdmin()) {
http_response_code(403);
jsonResponse(false, null, 'Forbidden');
}
// Hold the same stable lock used by fallback generation through read/compare/write.
// A shutdown release also covers jsonResponse(), which intentionally exits.
$path = null;
if (in_array($action, ['load', 'save'], true)) {
$page = $_GET['page'] ?? $_POST['page'] ?? '';
if (!is_string($page) || !validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$path = CONTENT_PATH . $page . '.json';
} elseif (in_array($action, ['load-settings', 'save-settings'], true)) {
$path = SETTINGS_PATH;
}
if (in_array($action, ['ai-copilot-apply', 'ai-copilot-apply-visibility', 'ai-copilot-undo', 'ai-content-audit-apply'], true)) {
$key = $_POST['contentPage'] ?? '';
if (!is_string($key)) jsonResponse(false, null, 'Invalid page name');
$path = raijuCopilotContentPath($key);
if ($path === '') jsonResponse(false, null, 'Invalid page name');
}
if ($action === 'restore' && validateBackupName($_POST['backup'] ?? '')) {
$page = preg_replace('/_\d{4}-\d{2}-\d{2}_\d{6}(?:_[a-f0-9]{6})?\.json$/', '', $_POST['backup']);
$path = CONTENT_PATH . $page . '.json';
}
if ($path !== null) {
$lock = raijuJsonLock($path);
if ($lock === false) { http_response_code(503); jsonResponse(false, null, 'Storage unavailable'); }
register_shutdown_function(static function () use ($lock): void { if (is_resource($lock)) { flock($lock, LOCK_UN); fclose($lock); } });
$GLOBALS['raijuRevisionLock'] = $lock;
$GLOBALS['raijuRevisionPath'] = $path;
if (is_file($path) && !is_array(json_decode((string)file_get_contents($path), true))) {
http_response_code(503);
jsonResponse(false, null, 'Storage is damaged; existing data was preserved');
}
// Existing restore clients remain compatible; revision-aware clients reject stale restores.
if (in_array($action, ['save', 'save-settings'], true)
|| ($action === 'restore' && array_key_exists('revision', $_POST))) {
$revision = $_POST['revision'] ?? null;
if (!is_string($revision) || $revision === '') {
http_response_code(428);
jsonResponse(false, null, t('conflict.reload_required'));
}
if (!hash_equals(raijuJsonRevision($path), $revision)) {
http_response_code(409);
// Client compares its pending changes with a fresh, permission-filtered load.
jsonResponse(false, ['conflict' => true], t('conflict.changed'));
}
}
}

78
admin/api/dashboard.php Normal file
View File

@ -0,0 +1,78 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'system-status':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
require_once RAIJU_ADMIN_DIR . '/../includes/system-status.php';
jsonResponse(true, raijuSystemStatus((string)($_GET['scheme'] ?? '')));
break;
case 'security-check':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
require_once RAIJU_ADMIN_DIR . '/../includes/security-check.php';
// The probe requests this site over HTTP for a few seconds at most:
// release the session lock so other dashboard requests keep working,
// and always remove the probe files, even if the browser disconnects.
session_write_close();
ignore_user_abort(true);
jsonResponse(true, raijuSecurityStatus((string)($_POST['scheme'] ?? ''), true, ($_POST['force'] ?? '') === '1'));
break;
case 'seo-check':
if (!isAdmin()) jsonResponse(false, null, 'Forbidden');
require_once RAIJU_ADMIN_DIR . '/../includes/seo-check.php';
// Three requests to the own site, a few seconds at most: do not hold the session lock meanwhile.
session_write_close();
ignore_user_abort(true);
jsonResponse(true, raijuSeoCheckStatus((string)($_POST['scheme'] ?? ''), true, ($_POST['force'] ?? '') === '1'));
break;
case 'keepalive':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
jsonResponse(true, ['time' => time()], 'Session refreshed');
break;
case 'dashboard-overview':
$analyticsPeriod = $_GET['analytics_period'] ?? 'days';
$analyticsCount = isset($_GET['analytics_count']) ? (int)$_GET['analytics_count'] : 30;
if (!in_array($analyticsPeriod, ['days', 'months', 'years'], true)) {
$analyticsPeriod = 'days';
}
if ($analyticsPeriod === 'months' && $analyticsCount <= 0) {
$analyticsCount = 12;
} elseif ($analyticsPeriod === 'years' && $analyticsCount < 0) {
$analyticsCount = 0;
} elseif ($analyticsPeriod === 'days' && $analyticsCount <= 0) {
$analyticsCount = 30;
}
$pagesPath = defined('CONTENT_PATH') ? CONTENT_PATH : dirname(RAIJU_ADMIN_DIR) . '/content/pages/';
$newsPath = dirname(RAIJU_ADMIN_DIR) . '/content/news/';
$pageCount = count(glob($pagesPath . '[a-z][a-z]_*.json') ?: []);
$newsCount = is_dir($newsPath) ? count(glob($newsPath . '*.json') ?: []) : 0;
$response = [
'pages' => $pageCount,
'news' => $newsCount,
'status' => dashboardStatusOverview($pagesPath, $newsPath),
'analytics' => raijuAnalyticsSummary($analyticsPeriod, $analyticsCount),
];
$settings = is_file(SETTINGS_PATH) ? json_decode(file_get_contents(SETTINGS_PATH), true) : [];
$response['analytics']['state'] = ($response['analytics']['state'] ?? '') === 'error' ? 'error'
: (!raijuAnalyticsEnabled() ? 'disabled' : (empty($response['analytics']['periodViews']) ? 'empty' : 'ready'));
if (dashboardAiModuleEnabled()) {
$aiSettings = raijuAiLoadSettings(true);
// Same shape as load-ai-settings, so the form rendered from the overview is complete
if (isAdmin() && function_exists('raijuAgentStatusForAdmin')) $aiSettings['agentStatus'] = raijuAgentStatusForAdmin();
$response['ai'] = [
'settings' => $aiSettings,
'usage' => raijuAiUsageSummary()
];
}
jsonResponse(true, $response);
break;
// ============================================================
// CONTENT MANAGEMENT
// ============================================================
}

338
admin/api/events.php Normal file
View File

@ -0,0 +1,338 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
if (!function_exists('raijuEventsBackup')) {
/** Copy the current events file into the backup folder and drop the oldest copies. */
function raijuEventsBackup(): void {
if (!is_file(EVENTS_PATH)) {
return;
}
copy(EVENTS_PATH, BACKUP_PATH . 'events_' . date('Y-m-d_His') . '.json');
$backups = glob(BACKUP_PATH . 'events_*.json') ?: [];
usort($backups, function($a, $b) {
return filemtime($b) - filemtime($a);
});
while (count($backups) > MAX_BACKUPS) {
unlink(array_pop($backups));
}
}
/**
* One locked read-modify-write of events.json. The callback edits the data by
* reference and returns null to write, or an error message to abort.
*
* @return true|string|false true = written, string = callback error, false = storage failure
*/
function raijuEventsMutate(callable $change, bool $backup = false) {
$error = null;
$ok = raijuJsonUpdate(EVENTS_PATH, function (array &$data) use ($change, $backup, &$error) {
if (!isset($data['events']) || !is_array($data['events'])) {
$data['events'] = [];
}
$error = $change($data);
if ($error !== null) {
return false;
}
if ($backup) {
raijuEventsBackup();
}
return true;
}, ['events' => []]);
if ($error !== null) {
return $error;
}
return $ok;
}
}
switch ($action) {
case 'load-events':
if (!file_exists(EVENTS_PATH)) {
jsonResponse(true, ['events' => [], 'lastModified' => null]);
}
$content = json_decode(file_get_contents(EVENTS_PATH), true);
jsonResponse(true, $content);
break;
case 'save-event':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$eventData = $_POST['event'] ?? '';
$event = json_decode($eventData, true);
if (!is_array($event)) {
jsonResponse(false, null, 'Invalid JSON format');
}
// Validate: date required, title in at least one language required
$defaultLang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en';
$hasTitle = false;
if (!empty($event['title']) && is_array($event['title'])) {
foreach ($event['title'] as $t) {
if (!empty($t)) { $hasTitle = true; break; }
}
}
if (empty($event['date']) || !$hasTitle) {
jsonResponse(false, null, 'Date and title are required');
}
if (empty($event['id'])) {
// Use default language title for ID, fallback to first available
$titleForId = $event['title'][$defaultLang] ?? reset($event['title']);
$event['id'] = $event['date'] . '-' . preg_replace('/[^a-z0-9-]/', '', strtolower(str_replace(' ', '-', $titleForId)));
}
$found = false;
$result = raijuEventsMutate(function (array &$data) use ($event, &$found) {
foreach ($data['events'] as $index => $existing) {
if (($existing['id'] ?? null) === $event['id']) {
$data['events'][$index] = $event;
$found = true;
break;
}
}
if (!$found) {
$data['events'][] = $event;
}
$data['lastModified'] = date('c');
return null;
}, true);
if ($result !== true) {
jsonResponse(false, null, 'Error saving');
}
jsonResponse(true, ['id' => $event['id']], $found ? 'Event updated' : 'Event created');
break;
case 'delete-event':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$eventId = $_POST['id'] ?? '';
if (empty($eventId)) {
jsonResponse(false, null, 'Event ID missing');
}
if (!file_exists(EVENTS_PATH)) {
jsonResponse(false, null, 'No events found');
}
$result = raijuEventsMutate(function (array &$data) use ($eventId) {
if (!isset($data['trash']) || !is_array($data['trash'])) {
$data['trash'] = [];
}
// Move event to trash array (instead of deleting)
$movedEvent = null;
foreach ($data['events'] as $idx => $existing) {
if (($existing['id'] ?? null) === $eventId) {
$movedEvent = $existing;
array_splice($data['events'], $idx, 1);
break;
}
}
if ($movedEvent === null) {
return 'Event not found';
}
$data['trash'][] = [
'event' => $movedEvent,
'deletedAt' => date('c'),
];
$data['lastModified'] = date('c');
return null;
}, true);
if ($result !== true) {
jsonResponse(false, null, is_string($result) ? $result : 'Error moving event to trash');
}
jsonResponse(true, null, 'Event moved to trash');
break;
case 'list-events-trash':
if (!file_exists(EVENTS_PATH)) {
jsonResponse(true, []);
}
$data = json_decode(file_get_contents(EVENTS_PATH), true);
$trash = $data['trash'] ?? [];
// Sort newest first
usort($trash, function($a, $b) {
return strcmp($b['deletedAt'] ?? '', $a['deletedAt'] ?? '');
});
jsonResponse(true, $trash);
break;
case 'restore-event':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$eventId = $_POST['id'] ?? '';
if (empty($eventId)) {
jsonResponse(false, null, 'Event ID missing');
}
if (!file_exists(EVENTS_PATH)) {
jsonResponse(false, null, 'No events found');
}
$restored = null;
$result = raijuEventsMutate(function (array &$data) use ($eventId, &$restored) {
if (!isset($data['trash']) || !is_array($data['trash'])) {
return 'Event not in trash';
}
foreach ($data['trash'] as $idx => $item) {
if (($item['event']['id'] ?? null) === $eventId) {
$restored = $item['event'];
array_splice($data['trash'], $idx, 1);
break;
}
}
if ($restored === null) {
return 'Event not found in trash';
}
// Avoid id collision: if restored id already exists, append a suffix
$existingIds = array_map(function($e) { return $e['id'] ?? ''; }, $data['events']);
if (in_array($restored['id'], $existingIds, true)) {
$restored['id'] = $restored['id'] . '-restored-' . time();
}
$data['events'][] = $restored;
$data['lastModified'] = date('c');
return null;
});
if ($result !== true) {
jsonResponse(false, null, is_string($result) ? $result : 'Error restoring event');
}
jsonResponse(true, ['id' => $restored['id']], 'Event restored');
break;
case 'delete-event-permanent':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$eventId = $_POST['id'] ?? '';
if (empty($eventId)) {
jsonResponse(false, null, 'Event ID missing');
}
if (!file_exists(EVENTS_PATH)) {
jsonResponse(false, null, 'No events found');
}
$result = raijuEventsMutate(function (array &$data) use ($eventId) {
if (!isset($data['trash']) || !is_array($data['trash'])) {
return 'Event not in trash';
}
foreach ($data['trash'] as $idx => $item) {
if (($item['event']['id'] ?? null) === $eventId) {
array_splice($data['trash'], $idx, 1);
return null;
}
}
return 'Event not found in trash';
});
if ($result !== true) {
jsonResponse(false, null, is_string($result) ? $result : 'Error deleting event permanently');
}
jsonResponse(true, null, 'Event permanently deleted');
break;
case 'empty-events-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!file_exists(EVENTS_PATH)) {
jsonResponse(true, null, 'Trash is empty');
}
$result = raijuEventsMutate(function (array &$data) {
$data['trash'] = [];
return null;
});
if ($result !== true) {
jsonResponse(false, null, 'Error emptying trash');
}
jsonResponse(true, null, 'Events trash emptied');
break;
case 'toggle-event-visibility':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$eventId = $_POST['id'] ?? '';
if (empty($eventId)) {
jsonResponse(false, null, 'Event ID missing');
}
if (!file_exists(EVENTS_PATH)) {
jsonResponse(false, null, 'No events found');
}
$nowHidden = false;
$result = raijuEventsMutate(function (array &$data) use ($eventId, &$nowHidden) {
foreach ($data['events'] as $index => $existing) {
if (($existing['id'] ?? null) === $eventId) {
$nowHidden = empty($existing['hidden']);
if ($nowHidden) {
$data['events'][$index]['hidden'] = true;
} else {
unset($data['events'][$index]['hidden']);
}
$data['lastModified'] = date('c');
return null;
}
}
return 'Event not found';
});
if ($result !== true) {
jsonResponse(false, null, is_string($result) ? $result : 'Error saving');
}
jsonResponse(true, ['hidden' => $nowHidden], $nowHidden ? 'Event hidden' : 'Event visible');
break;
case 'load-event':
$eventId = $_GET['id'] ?? '';
if (empty($eventId)) {
jsonResponse(false, null, 'Event ID missing');
}
if (!file_exists(EVENTS_PATH)) {
jsonResponse(false, null, 'No events found');
}
$data = json_decode(file_get_contents(EVENTS_PATH), true);
foreach ($data['events'] as $event) {
if ($event['id'] === $eventId) {
jsonResponse(true, $event);
}
}
jsonResponse(false, null, 'Event not found');
break;
// ============================================================
// IMAGE MANAGEMENT
// ============================================================
}

234
admin/api/forms.php Normal file
View File

@ -0,0 +1,234 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'load-mails':
$mailsFile = dirname(CONTENT_PATH) . '/mails.json';
if (!file_exists($mailsFile)) {
jsonResponse(true, ['mails' => [], 'forms' => raijuFormsList()]);
}
$mails = json_decode(file_get_contents($mailsFile), true) ?: [];
foreach ($mails as &$mail) {
$mail['formId'] = $mail['formId'] ?? 'contact';
$mail['formLabel'] = $mail['formLabel'] ?? 'Kontaktformular';
}
unset($mail);
jsonResponse(true, ['mails' => $mails, 'forms' => raijuFormsList()]);
break;
case 'list-forms':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
jsonResponse(true, raijuFormsList());
break;
case 'load-form':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
$formId = $_GET['form_id'] ?? $_POST['form_id'] ?? '';
if ($formId === '') {
jsonResponse(false, null, 'Form ID missing');
}
$form = raijuFormLoad($formId);
if (!$form) {
jsonResponse(false, null, 'Form not found');
}
jsonResponse(true, $form);
break;
case 'save-form':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$payload = json_decode((string)($_POST['form'] ?? ''), true);
if (!is_array($payload)) {
jsonResponse(false, null, 'Invalid form JSON');
}
$savedForm = raijuFormSave($payload);
jsonResponse(true, $savedForm, 'Form saved');
break;
case 'mark-mail-read':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$mailId = $_POST['mail_id'] ?? '';
if (empty($mailId)) {
jsonResponse(false, null, 'Mail ID missing');
}
$mailsFile = dirname(CONTENT_PATH) . '/mails.json';
if (!file_exists($mailsFile)) {
jsonResponse(false, null, 'No mails found');
}
$mails = json_decode(file_get_contents($mailsFile), true) ?: [];
$found = false;
foreach ($mails as &$mail) {
if ($mail['id'] === $mailId) {
$mail['read'] = true;
$found = true;
break;
}
}
unset($mail);
if (!$found) {
jsonResponse(false, null, 'Mail not found');
}
if (!raijuJsonAtomicWrite($mailsFile, $mails)) jsonResponse(false, null, 'Could not save inbox');
jsonResponse(true, null, 'Mail marked as read');
break;
case 'update-mail-flags':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$mailId = $_POST['mail_id'] ?? '';
if (empty($mailId)) {
jsonResponse(false, null, 'Mail ID missing');
}
$allowedFlags = ['read', 'starred'];
$updates = [];
foreach ($allowedFlags as $flag) {
if (array_key_exists($flag, $_POST)) {
$updates[$flag] = filter_var($_POST[$flag], FILTER_VALIDATE_BOOLEAN);
}
}
if (empty($updates)) {
jsonResponse(false, null, 'No mail flags provided');
}
$mailsFile = dirname(CONTENT_PATH) . '/mails.json';
if (!file_exists($mailsFile)) {
jsonResponse(false, null, 'No mails found');
}
$mails = json_decode(file_get_contents($mailsFile), true) ?: [];
$found = false;
foreach ($mails as &$mail) {
if (($mail['id'] ?? '') === $mailId) {
foreach ($updates as $flag => $value) {
$mail[$flag] = $value;
}
$found = true;
break;
}
}
unset($mail);
if (!$found) {
jsonResponse(false, null, 'Mail not found');
}
if (!raijuJsonAtomicWrite($mailsFile, $mails)) jsonResponse(false, null, 'Could not save inbox');
jsonResponse(true, ['mail_id' => $mailId, 'updates' => $updates], 'Mail flags updated');
break;
case 'mark-all-mails-read':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$mailsFile = dirname(CONTENT_PATH) . '/mails.json';
if (!file_exists($mailsFile)) {
jsonResponse(true, null, 'No mails found');
}
$mails = json_decode(file_get_contents($mailsFile), true) ?: [];
foreach ($mails as &$mail) {
$mail['read'] = true;
}
unset($mail);
if (!raijuJsonAtomicWrite($mailsFile, $mails)) jsonResponse(false, null, 'Could not save inbox');
jsonResponse(true, null, 'All mails marked as read');
break;
case 'delete-mail':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$mailId = $_POST['mail_id'] ?? '';
if (empty($mailId)) {
jsonResponse(false, null, 'Mail ID missing');
}
$mailsFile = dirname(CONTENT_PATH) . '/mails.json';
if (!file_exists($mailsFile)) {
jsonResponse(false, null, 'No mails found');
}
$mails = json_decode(file_get_contents($mailsFile), true) ?: [];
$originalCount = count($mails);
$mails = array_filter($mails, function($mail) use ($mailId) {
return $mail['id'] !== $mailId;
});
if (count($mails) === $originalCount) {
jsonResponse(false, null, 'Mail not found');
}
$mails = array_values($mails);
if (!raijuJsonAtomicWrite($mailsFile, $mails)) jsonResponse(false, null, 'Could not save inbox');
jsonResponse(true, null, 'Mail deleted');
break;
case 'delete-read-mails':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$mailsFile = dirname(CONTENT_PATH) . '/mails.json';
if (!file_exists($mailsFile)) {
jsonResponse(true, ['deleted' => 0], 'No mails found');
}
$mails = json_decode(file_get_contents($mailsFile), true) ?: [];
$originalCount = count($mails);
$mails = array_values(array_filter($mails, function($mail) {
return !($mail['read'] ?? false);
}));
$deletedCount = $originalCount - count($mails);
if (!raijuJsonAtomicWrite($mailsFile, $mails)) jsonResponse(false, null, 'Could not save inbox');
jsonResponse(true, ['deleted' => $deletedCount], 'Read mails deleted');
break;
case 'unread-mail-count':
$mailsFile = dirname(CONTENT_PATH) . '/mails.json';
if (!file_exists($mailsFile)) {
jsonResponse(true, ['count' => 0]);
}
$mails = json_decode(file_get_contents($mailsFile), true) ?: [];
$unreadCount = count(array_filter($mails, function($mail) {
return !($mail['read'] ?? false);
}));
jsonResponse(true, ['count' => $unreadCount]);
break;
// ============================================================
// PASSWORD MANAGEMENT
// ============================================================
}

136
admin/api/icons.php Normal file
View File

@ -0,0 +1,136 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'list-icons':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
jsonResponse(true, iconManagerListData());
break;
// ============================================================
// AI GATEWAY
// ============================================================
case 'iconify-search':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
[$validSearch, $searchData, $searchError] = searchIconifyIcons($_GET['prefix'] ?? '', $_GET['query'] ?? '');
if (!$validSearch) {
jsonResponse(false, null, $searchError);
}
jsonResponse(true, $searchData);
break;
case 'iconify-import':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
[$validImport, $importData, $importError] = importIconifyIcon($_POST['icon'] ?? '');
if (!$validImport) {
jsonResponse(false, null, $importError);
}
jsonResponse(true, $importData, 'Icon imported.');
break;
case 'save-icon':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$oldKey = sanitizeIconKeyInput($_POST['old_key'] ?? '');
[$validIcon, $normalizedIcon, $iconError] = normalizeIconManagerPayload(
$_POST['key'] ?? '',
$_POST['label'] ?? '',
$_POST['tags'] ?? '',
$_POST['svg'] ?? '',
$_POST['viewBox'] ?? ''
);
if (!$validIcon) {
jsonResponse(false, null, $iconError);
}
[$newKey, $definition] = $normalizedIcon;
if ($oldKey === 'default' && $newKey !== 'default') {
jsonResponse(false, null, 'The fallback icon key cannot be renamed.');
}
$rawIconSet = readSiteIconSetRaw();
$customIcons = normalizeIconSet($rawIconSet);
$availableIcons = iconManagerListData()['icons'];
$availableKeys = array_column($availableIcons, 'key');
if (($oldKey === '' || $oldKey !== $newKey) && in_array($newKey, $availableKeys, true)) {
jsonResponse(false, null, 'An icon with this key already exists.');
}
if ($oldKey !== '' && $oldKey !== $newKey) {
unset($rawIconSet[$oldKey]);
if (isset(getDefaultIconSet()[$oldKey])) {
$rawIconSet['_deleted'] = $rawIconSet['_deleted'] ?? [];
$rawIconSet['_deleted'][] = $oldKey;
}
}
$previousKey = $oldKey ?: $newKey;
$previousDefinition = isset($rawIconSet[$previousKey]) && is_array($rawIconSet[$previousKey]) ? $rawIconSet[$previousKey] : [];
$definition['createdAt'] = isset($previousDefinition['createdAt']) && is_string($previousDefinition['createdAt'])
? $previousDefinition['createdAt']
: date('c');
$definition['updatedAt'] = date('c');
$rawIconSet[$newKey] = $definition;
if (isset($rawIconSet['_deleted']) && is_array($rawIconSet['_deleted'])) {
$rawIconSet['_deleted'] = array_values(array_filter($rawIconSet['_deleted'], function($key) use ($newKey) {
return $key !== $newKey;
}));
}
if (!writeSiteIconSetRaw($rawIconSet)) {
jsonResponse(false, null, 'Could not write icon set.');
}
jsonResponse(true, iconManagerListData(), 'Icon saved.');
break;
case 'delete-icon':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$key = sanitizeIconKeyInput($_POST['key'] ?? '');
if ($key === '') {
jsonResponse(false, null, 'Invalid icon key.');
}
if ($key === 'default') {
jsonResponse(false, null, 'The fallback icon cannot be deleted.');
}
$rawIconSet = readSiteIconSetRaw();
unset($rawIconSet[$key]);
if (isset(getDefaultIconSet()[$key])) {
$rawIconSet['_deleted'] = $rawIconSet['_deleted'] ?? [];
$rawIconSet['_deleted'][] = $key;
}
if (!writeSiteIconSetRaw($rawIconSet)) {
jsonResponse(false, null, 'Could not write icon set.');
}
jsonResponse(true, iconManagerListData(), 'Icon deleted.');
break;
}

950
admin/api/media.php Normal file
View File

@ -0,0 +1,950 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
require_once RAIJU_ADMIN_DIR . '/../includes/svg-sanitizer.php';
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'list-images':
jsonResponse(true, listImageFiles(IMAGES_PATH, '../assets/images/'));
break;
case 'list-media':
$requestedType = $_GET['type'] ?? 'all';
$trash = ($_GET['trash'] ?? '0') === '1';
$types = $requestedType === 'all'
? array_keys(getMediaConfig())
: array_filter(array_map('normalizeMediaType', explode(',', $requestedType)));
$items = [];
foreach ($types as $type) {
$items = array_merge($items, listMediaFiles($type, $trash));
}
$folders = [];
foreach ($types as $type) {
$folders = array_merge($folders, listMediaFolders($type, $trash));
}
$folders = array_values(array_unique($folders));
natcasesort($folders);
$folders = array_values($folders);
usort($items, function($a, $b) {
return ($b['modified'] ?? 0) <=> ($a['modified'] ?? 0);
});
jsonResponse(true, [
'items' => $items,
'folders' => $folders,
// What the server lets through and what Raiju allows, so the library
// can check before sending and say which limit applies
'limits' => raijuMediaUploadLimits(),
'types' => array_values(array_map(function($config) {
return [
'type' => $config['type'],
'label' => $config['label'],
'extensions' => $config['extensions'],
'maxSize' => $config['maxSize'],
];
}, getMediaConfig())),
]);
break;
case 'upload-media':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? 'image');
$config = getMediaConfig($type);
if (!$config) {
jsonResponse(false, null, 'Invalid media type');
}
$field = $config['field'];
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
$field = isset($_FILES['file']) ? 'file' : $field;
}
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
// A request above post_max_size reaches PHP with an empty $_FILES, so an
// empty upload with a Content-Length counts as "too large" as well
$code = $_FILES[$field]['error'] ?? null;
$contentLength = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($code === UPLOAD_ERR_INI_SIZE || $code === UPLOAD_ERR_FORM_SIZE || (empty($_FILES) && $contentLength > raijuUploadServerLimit())) {
$serverMb = raijuMegabytes(raijuUploadServerLimit());
jsonResponse(false, ['serverLimitBytes' => raijuUploadServerLimit()], sprintf('File too large: the server accepts uploads up to %s MB (upload_max_filesize / post_max_size)', $serverMb));
}
jsonResponse(false, null, $code === UPLOAD_ERR_PARTIAL ? 'Upload error: the file arrived only partially' : 'Upload error');
}
$file = $_FILES[$field];
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($extension, $config['extensions'], true)) {
jsonResponse(false, null, 'Invalid file extension');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
unset($finfo);
if (!in_array($mimeType, $config['mimeTypes'], true)) {
jsonResponse(false, null, 'Invalid file type');
}
if ($file['size'] > $config['maxSize']) {
jsonResponse(false, null, sprintf('File too large: the limit for %s is %s MB', $config['type'], raijuMegabytes((int)$config['maxSize'])));
}
$originalName = pathinfo($file['name'], PATHINFO_FILENAME);
$safeName = preg_replace('/[^a-z0-9\-_]/i', '-', $originalName);
$safeName = preg_replace('/-+/', '-', $safeName);
$safeName = trim($safeName, '-');
if ($safeName === '') {
$safeName = $config['fallbackName'] . '-' . time();
}
$folder = trim((string)($_POST['folder'] ?? ''));
if ($folder !== '' && !validateMediaFolderName($folder)) {
jsonResponse(false, null, 'Invalid folder name');
}
$targetBase = $config['path'] . ($folder !== '' ? $folder . '/' : '');
$targetPublicBase = $config['publicPath'] . ($folder !== '' ? $folder . '/' : '');
if (!is_dir($targetBase)) {
mkdir($targetBase, 0755, true);
}
if ($type === 'image' && in_array($mimeType, ['image/jpeg', 'image/png', 'image/webp'], true)) {
// Provenance sits in metadata that re-encoding drops: read it from the upload
$aiLabel = raijuDetectAiLabel($file['tmp_name']);
// The web version and the original share a stem; it must be free in both places
$stem = pathinfo(uniqueMediaFilename($targetBase, $safeName . '.' . $extension), PATHINFO_FILENAME);
while (file_exists($targetBase . $stem . '.webp') || raijuMediaOriginalFor(($folder !== '' ? $folder . '/' : '') . $stem . '.webp') !== null) {
$stem = pathinfo(uniqueMediaFilename($targetBase, $stem . '-1.' . $extension), PATHINFO_FILENAME);
}
$stored = raijuMediaStoreImage($file['tmp_name'], $targetBase, $stem, $extension, $mimeType, 'move_uploaded_file');
if ($stored === null) {
jsonResponse(false, null, 'Error saving');
}
$relativeName = ($folder !== '' ? $folder . '/' : '') . $stored['name'];
if ($aiLabel !== '') {
raijuSetMediaAiLabel($type, $relativeName, $aiLabel);
}
jsonResponse(true, [
'type' => $type,
'name' => $relativeName,
'path' => $targetPublicBase . $stored['name'],
'aiLabel' => $aiLabel,
'webVersion' => $stored['webVersion'],
'original' => raijuMediaOriginalFor($relativeName),
], 'Media uploaded');
}
$filename = uniqueMediaFilename($targetBase, $safeName . '.' . $extension);
if (move_uploaded_file($file['tmp_name'], $targetBase . $filename)) {
if (strtolower($extension) === 'svg' && !raijuSanitizeSvgFile($targetBase . $filename)) {
@unlink($targetBase . $filename);
jsonResponse(false, null, 'SVG could not be processed safely');
}
$relativeName = ($folder !== '' ? $folder . '/' : '') . $filename;
$aiLabel = in_array($type, ['image', 'video'], true) ? raijuDetectAiLabel($targetBase . $filename) : '';
if ($aiLabel !== '') {
raijuSetMediaAiLabel($type, $relativeName, $aiLabel);
}
jsonResponse(true, [
'type' => $type,
'name' => $relativeName,
'path' => $targetPublicBase . $filename,
'aiLabel' => $aiLabel,
'webVersion' => null,
], 'Media uploaded');
}
jsonResponse(false, null, 'Error saving');
break;
case 'create-media-folder':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? 'image');
$folder = trim((string)($_POST['folder'] ?? ''));
$config = getMediaConfig($type);
if (!$config || !validateMediaFolderName($folder)) {
jsonResponse(false, null, 'Invalid folder name');
}
$path = $config['path'] . $folder;
if (is_dir($path)) {
jsonResponse(true, ['folder' => $folder], 'Folder exists');
}
if (file_exists($path)) {
jsonResponse(false, null, 'A file with this name already exists');
}
if (mkdir($path, 0755, true)) {
jsonResponse(true, ['folder' => $folder], 'Folder created');
}
jsonResponse(false, null, 'Error creating folder');
break;
case 'delete-media-folder':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? 'image');
$folder = trim((string)($_POST['folder'] ?? ''));
$config = getMediaConfig($type);
if (!$config || !validateMediaFolderName($folder)) {
jsonResponse(false, null, 'Invalid folder name');
}
$path = $config['path'] . $folder;
if (!is_dir($path) || is_link($path)) {
jsonResponse(false, null, 'Folder not found');
}
$contents = array_values(array_diff(scandir($path) ?: [], ['.', '..']));
// OS metadata files do not count as content and are removed with the folder.
$junkFiles = ['.DS_Store', 'Thumbs.db', 'desktop.ini'];
$realContents = array_values(array_diff($contents, $junkFiles));
if (count($realContents) > 0) {
jsonResponse(false, null, 'Folder must be empty before it can be deleted');
}
foreach (array_intersect($contents, $junkFiles) as $junkFile) {
@unlink($path . '/' . $junkFile);
}
if (rmdir($path)) {
jsonResponse(true, ['folder' => $folder], 'Folder deleted');
}
jsonResponse(false, null, 'Error deleting folder');
break;
case 'move-media':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? 'image');
$filename = $_POST['filename'] ?? '';
$targetFolder = trim((string)($_POST['folder'] ?? ''));
$config = getMediaConfig($type);
if (!$config || !validateMediaFilename($filename, $type)) {
jsonResponse(false, null, 'Invalid media file');
}
if ($targetFolder !== '' && !validateMediaFolderName($targetFolder)) {
jsonResponse(false, null, 'Invalid folder name');
}
$sourcePath = $config['path'] . $filename;
if (!is_file($sourcePath)) {
jsonResponse(false, null, 'File not found');
}
$basename = basename($filename);
$targetRelative = ($targetFolder !== '' ? $targetFolder . '/' : '') . $basename;
if ($targetRelative === $filename) {
jsonResponse(true, [
'type' => $type,
'name' => $filename,
'path' => $config['publicPath'] . $filename,
], 'Media already in folder');
}
$targetDirectory = $config['path'] . ($targetFolder !== '' ? $targetFolder : '');
if (!is_dir($targetDirectory) && !mkdir($targetDirectory, 0755, true)) {
jsonResponse(false, null, 'Error creating target folder');
}
$targetRelative = uniqueMediaRelativePath($config['path'], $targetRelative);
if (rename($sourcePath, $config['path'] . $targetRelative)) {
raijuMoveMediaMeta($type, $filename, $type, $targetRelative);
if ($type === 'image') raijuMediaOriginalRelocate($filename, false, $targetRelative, false);
jsonResponse(true, [
'type' => $type,
'name' => $targetRelative,
'path' => $config['publicPath'] . $targetRelative,
], 'Media moved');
}
jsonResponse(false, null, 'Error moving media');
break;
case 'rename-media':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? 'image');
$filename = $_POST['filename'] ?? '';
$newName = trim((string)($_POST['newName'] ?? ''));
$scanReferences = ($_POST['scanReferences'] ?? '0') === '1';
$confirmReferences = ($_POST['confirmReferences'] ?? '0') === '1';
$config = getMediaConfig($type);
if (!$config || !validateMediaFilename($filename, $type)) {
jsonResponse(false, null, 'Invalid media file');
}
if (!validateMediaRenameBasename($newName, $type)) {
jsonResponse(false, null, 'Invalid file name');
}
$oldBasename = basename($filename);
$oldExt = normalizeRenameExtension(pathinfo($oldBasename, PATHINFO_EXTENSION));
$newExt = normalizeRenameExtension(pathinfo($newName, PATHINFO_EXTENSION));
if ($oldExt === '' || $newExt === '' || $oldExt !== $newExt) {
jsonResponse(false, null, 'File extension cannot be changed');
}
$folder = trim(str_replace('\\', '/', dirname($filename)), '.');
$targetRelative = ($folder !== '' ? $folder . '/' : '') . $newName;
if (!validateMediaFilename($targetRelative, $type)) {
jsonResponse(false, null, 'Invalid file name');
}
if ($targetRelative === $filename) {
jsonResponse(true, [
'type' => $type,
'name' => $filename,
'path' => $config['publicPath'] . $filename,
'references' => [],
], 'Media already has this name');
}
$sourcePath = $config['path'] . $filename;
$targetPath = $config['path'] . $targetRelative;
if (!is_file($sourcePath) || is_link($sourcePath)) {
jsonResponse(false, null, 'File not found');
}
if (file_exists($targetPath)) {
jsonResponse(false, null, 'A file with this name already exists');
}
$references = [];
if ($scanReferences) {
$referencesByPath = [];
foreach ([
$oldBasename,
$filename,
ltrim($config['publicPath'], './') . $filename,
'/' . ltrim($config['publicPath'], './') . $filename,
] as $needle) {
foreach (findMediaJsonReferences($needle) as $match) {
$key = $match['file'];
$referencesByPath[$key] = $match;
}
}
$references = array_values($referencesByPath);
if ($references && !$confirmReferences) {
jsonResponse(false, [
'requiresConfirmation' => true,
'references' => $references,
'oldName' => $filename,
'newName' => $targetRelative,
], 'References found');
}
}
if (rename($sourcePath, $targetPath)) {
raijuMoveMediaMeta($type, $filename, $type, $targetRelative);
if ($type === 'image') raijuMediaOriginalRelocate($filename, false, $targetRelative, false);
jsonResponse(true, [
'type' => $type,
'name' => $targetRelative,
'path' => $config['publicPath'] . $targetRelative,
'oldName' => $filename,
'references' => $references,
], 'Media renamed');
}
jsonResponse(false, null, 'Error renaming media');
break;
case 'set-media-ai-label':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? 'image');
$filename = $_POST['filename'] ?? '';
$aiLabel = (string)($_POST['label'] ?? '');
$config = getMediaConfig($type);
if (!$config || !in_array($type, ['image', 'video'], true) || !validateMediaFilename($filename, $type)) {
jsonResponse(false, null, 'Invalid media file');
}
if ($aiLabel !== '' && !in_array($aiLabel, RAIJU_AI_LABEL_KINDS, true)) {
jsonResponse(false, null, 'Invalid AI label');
}
if (!is_file($config['path'] . $filename)) {
jsonResponse(false, null, 'File not found');
}
if (!raijuSetMediaAiLabel($type, $filename, $aiLabel)) {
jsonResponse(false, null, 'Error saving');
}
jsonResponse(true, ['type' => $type, 'name' => $filename, 'aiLabel' => $aiLabel], 'AI label saved');
break;
case 'delete-media':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? '');
$filename = $_POST['filename'] ?? '';
$config = getMediaConfig($type);
if (!$config || !validateMediaFilename($filename, $type)) {
jsonResponse(false, null, 'Invalid media file');
}
$sourcePath = $config['path'] . $filename;
if (!file_exists($sourcePath)) {
jsonResponse(false, null, 'File not found');
}
if (!is_dir($config['trashPath'])) {
mkdir($config['trashPath'], 0755, true);
}
$targetFilename = uniqueMediaRelativePath($config['trashPath'], $filename);
$targetDirectory = dirname($config['trashPath'] . $targetFilename);
if (!is_dir($targetDirectory)) {
mkdir($targetDirectory, 0755, true);
}
if (rename($sourcePath, $config['trashPath'] . $targetFilename)) {
raijuMoveMediaMeta($type, $filename, $type . '-trash', $targetFilename);
if ($type === 'image') raijuMediaOriginalRelocate($filename, false, $targetFilename, true);
jsonResponse(true, null, 'Media moved to trash');
}
jsonResponse(false, null, 'Error moving');
break;
case 'restore-media':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? '');
$filename = $_POST['filename'] ?? '';
$config = getMediaConfig($type);
if (!$config || !validateMediaFilename($filename, $type)) {
jsonResponse(false, null, 'Invalid media file');
}
$sourcePath = $config['trashPath'] . $filename;
if (!file_exists($sourcePath)) {
jsonResponse(false, null, 'File not found');
}
$targetFilename = uniqueMediaRelativePath($config['path'], $filename);
$targetDirectory = dirname($config['path'] . $targetFilename);
if (!is_dir($targetDirectory)) {
mkdir($targetDirectory, 0755, true);
}
if (rename($sourcePath, $config['path'] . $targetFilename)) {
raijuMoveMediaMeta($type . '-trash', $filename, $type, $targetFilename);
if ($type === 'image') raijuMediaOriginalRelocate($filename, true, $targetFilename, false);
jsonResponse(true, [
'type' => $type,
'name' => $targetFilename,
'path' => $config['publicPath'] . $targetFilename,
], 'Media restored');
}
jsonResponse(false, null, 'Error restoring');
break;
case 'delete-media-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$type = normalizeMediaType($_POST['type'] ?? '');
$filename = $_POST['filename'] ?? '';
$config = getMediaConfig($type);
if (!$config || !validateMediaFilename($filename, $type)) {
jsonResponse(false, null, 'Invalid media file');
}
$path = $config['trashPath'] . $filename;
if (!file_exists($path)) {
jsonResponse(false, null, 'File not found');
}
if (unlink($path)) {
raijuForgetMediaMeta($type . '-trash', $filename);
if ($type === 'image') raijuMediaOriginalForget($filename, true);
jsonResponse(true, null, 'Media permanently deleted');
}
jsonResponse(false, null, 'Error deleting');
break;
case 'empty-media-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$requestedType = $_POST['type'] ?? 'all';
$types = $requestedType === 'all'
? array_keys(getMediaConfig())
: array_filter(array_map('normalizeMediaType', explode(',', $requestedType)));
$deleted = 0;
foreach ($types as $type) {
$config = getMediaConfig($type);
foreach (listMediaFiles($type, true) as $media) {
$path = $config['trashPath'] . $media['name'];
if (is_file($path) && unlink($path)) {
raijuForgetMediaMeta($type . '-trash', $media['name']);
if ($type === 'image') raijuMediaOriginalForget($media['name'], true);
$deleted++;
}
}
}
jsonResponse(true, ['deleted' => $deleted], 'Media trash emptied');
break;
case 'media-trash-file':
$type = normalizeMediaType($_GET['type'] ?? '');
$filename = $_GET['filename'] ?? '';
$config = getMediaConfig($type);
if (!$config || !validateMediaFilename($filename, $type)) {
http_response_code(400);
header_remove('Content-Type');
echo 'Invalid media file';
exit;
}
$path = $config['trashPath'] . $filename;
if (!is_file($path)) {
http_response_code(404);
header_remove('Content-Type');
echo 'File not found';
exit;
}
serveLocalImage($path, $filename);
break;
case 'list-image-trash':
$images = listImageFiles(IMAGES_TRASH_PATH, 'api.php?action=image-trash-file&filename=');
foreach ($images as &$image) {
$image['path'] = 'api.php?action=image-trash-file&filename=' . rawurlencode($image['name']);
}
unset($image);
jsonResponse(true, $images);
break;
case 'image-trash-file':
$filename = $_GET['filename'] ?? '';
if (!validateImageFilename($filename)) {
http_response_code(400);
header_remove('Content-Type');
echo 'Invalid filename';
exit;
}
$path = IMAGES_TRASH_PATH . $filename;
if (!is_file($path)) {
http_response_code(404);
header_remove('Content-Type');
echo 'File not found';
exit;
}
serveLocalImage($path, $filename);
break;
case 'upload-image':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
$errorMsg = 'Upload error';
if (isset($_FILES['image']['error'])) {
switch ($_FILES['image']['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$errorMsg = sprintf('File too large: the server accepts uploads up to %s MB (upload_max_filesize / post_max_size)', raijuMegabytes(raijuUploadServerLimit()));
break;
case UPLOAD_ERR_NO_FILE:
$errorMsg = 'No file selected';
break;
}
} elseif (empty($_FILES) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > raijuUploadServerLimit()) {
$errorMsg = sprintf('File too large: the server accepts uploads up to %s MB (upload_max_filesize / post_max_size)', raijuMegabytes(raijuUploadServerLimit()));
}
jsonResponse(false, null, $errorMsg);
}
$file = $_FILES['image'];
$allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
unset($finfo);
if (!in_array($mimeType, $allowedMimeTypes)) {
jsonResponse(false, null, 'Only JPG, PNG, WebP and SVG allowed');
}
if ($file['size'] > raijuMediaImageMaxBytes()) {
jsonResponse(false, null, sprintf('Maximum file size: %s MB', raijuMegabytes(raijuMediaImageMaxBytes())));
}
$explicitFilename = $_POST['filename'] ?? '';
$replaceMode = ($_POST['replace'] ?? '0') === '1';
if (!empty($explicitFilename)) {
if (!validateMediaFilename($explicitFilename, 'image')) {
jsonResponse(false, null, 'Invalid file extension');
}
$extension = strtolower(pathinfo($explicitFilename, PATHINFO_EXTENSION));
$filename = $explicitFilename;
if ($replaceMode && file_exists(IMAGES_PATH . $filename)) {
if (!is_dir(IMAGES_TRASH_PATH)) {
mkdir(IMAGES_TRASH_PATH, 0755, true);
}
$folder = trim(str_replace('\\', '/', dirname($filename)), '.');
$backupName = ($folder !== '' ? $folder . '/' : '') . pathinfo($filename, PATHINFO_FILENAME) . '_' . date('Y-m-d_His') . '.' . $extension;
$backupDirectory = dirname(IMAGES_TRASH_PATH . $backupName);
if (!is_dir($backupDirectory)) {
mkdir($backupDirectory, 0755, true);
}
rename(IMAGES_PATH . $filename, IMAGES_TRASH_PATH . $backupName);
} elseif (!$replaceMode && file_exists(IMAGES_PATH . $filename)) {
$filename = uniqueMediaRelativePath(IMAGES_PATH, $filename);
}
} else {
$originalName = pathinfo($file['name'], PATHINFO_FILENAME);
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$safeName = preg_replace('/[^a-z0-9\-_]/i', '-', $originalName);
$safeName = preg_replace('/-+/', '-', $safeName);
$safeName = trim($safeName, '-');
if (empty($safeName)) {
$safeName = 'image-' . time();
}
$filename = $safeName . '.' . $extension;
$counter = 1;
while (file_exists(IMAGES_PATH . $filename)) {
$filename = $safeName . '-' . $counter . '.' . $extension;
$counter++;
}
}
// Final gate: never store a filename whose extension is not an allowed
// image type. finfo checks the MIME type, but a polyglot file can carry
// valid image magic bytes while keeping a .php name, so the extension
// must be validated independently before the file lands in a web-served
// directory (assets/images/ has no PHP-execution block).
if (!validateMediaFilename($filename, 'image')) {
jsonResponse(false, null, 'Invalid file extension');
}
$targetDirectory = dirname(IMAGES_PATH . $filename);
if (!is_dir($targetDirectory)) {
mkdir($targetDirectory, 0755, true);
}
if (move_uploaded_file($file['tmp_name'], IMAGES_PATH . $filename)) {
if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) === 'svg'
&& !raijuSanitizeSvgFile(IMAGES_PATH . $filename)) {
@unlink(IMAGES_PATH . $filename);
jsonResponse(false, null, 'SVG could not be processed safely');
}
$detectedAiLabel = raijuDetectAiLabel(IMAGES_PATH . $filename);
if ($detectedAiLabel !== '') {
raijuSetMediaAiLabel('image', $filename, $detectedAiLabel);
}
jsonResponse(true, [
'name' => $filename,
'path' => '../assets/images/' . $filename,
'aiLabel' => raijuMediaAiLabelFor('image', $filename),
], $replaceMode ? 'Image replaced' : 'Image uploaded');
} else {
jsonResponse(false, null, 'Error saving');
}
break;
case 'delete-image':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$filename = $_POST['filename'] ?? '';
if (!validateImageFilename($filename)) {
jsonResponse(false, null, 'Invalid filename');
}
$sourcePath = IMAGES_PATH . $filename;
if (!file_exists($sourcePath)) {
jsonResponse(false, null, 'File not found');
}
if (!is_dir(IMAGES_TRASH_PATH)) {
mkdir(IMAGES_TRASH_PATH, 0755, true);
}
$targetFilename = $filename;
$counter = 1;
while (file_exists(IMAGES_TRASH_PATH . $targetFilename)) {
$name = pathinfo($filename, PATHINFO_FILENAME);
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$targetFilename = $name . '-' . $counter . '.' . $ext;
$counter++;
}
if (rename($sourcePath, IMAGES_TRASH_PATH . $targetFilename)) {
jsonResponse(true, null, 'Image moved to trash');
} else {
jsonResponse(false, null, 'Error moving');
}
break;
case 'restore-image':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$filename = $_POST['filename'] ?? '';
if (!validateImageFilename($filename)) {
jsonResponse(false, null, 'Invalid filename');
}
$sourcePath = IMAGES_TRASH_PATH . $filename;
if (!file_exists($sourcePath)) {
jsonResponse(false, null, 'File not found');
}
if (!is_dir(IMAGES_PATH)) {
mkdir(IMAGES_PATH, 0755, true);
}
$targetFilename = $filename;
$counter = 1;
while (file_exists(IMAGES_PATH . $targetFilename)) {
$name = pathinfo($filename, PATHINFO_FILENAME);
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$targetFilename = $name . '-' . $counter . '.' . $ext;
$counter++;
}
if (rename($sourcePath, IMAGES_PATH . $targetFilename)) {
jsonResponse(true, [
'name' => $targetFilename,
'path' => '../assets/images/' . $targetFilename
], 'Image restored');
}
jsonResponse(false, null, 'Error restoring');
break;
case 'delete-image-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$filename = $_POST['filename'] ?? '';
if (!validateImageFilename($filename)) {
jsonResponse(false, null, 'Invalid filename');
}
$path = IMAGES_TRASH_PATH . $filename;
if (!file_exists($path)) {
jsonResponse(false, null, 'File not found');
}
if (unlink($path)) {
jsonResponse(true, null, 'Image permanently deleted');
}
jsonResponse(false, null, 'Error deleting');
break;
case 'empty-image-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$deleted = 0;
foreach (listImageFiles(IMAGES_TRASH_PATH, '../assets/images-trash/') as $image) {
$path = IMAGES_TRASH_PATH . $image['name'];
if (is_file($path) && unlink($path)) {
$deleted++;
}
}
jsonResponse(true, ['deleted' => $deleted], 'Image trash emptied');
break;
// ============================================================
// AUDIO MANAGEMENT
// ============================================================
case 'list-audio':
if (!is_dir(AUDIO_PATH)) {
jsonResponse(true, []);
}
$audioFiles = [];
$allowedExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'];
$files = scandir(AUDIO_PATH);
foreach ($files as $file) {
if ($file === '.' || $file === '..') continue;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (in_array($ext, $allowedExtensions)) {
$sizeBytes = filesize(AUDIO_PATH . $file);
$modified = filemtime(AUDIO_PATH . $file);
if ($sizeBytes >= 1048576) {
$sizeFormatted = round($sizeBytes / 1048576, 1) . ' MB';
} elseif ($sizeBytes >= 1024) {
$sizeFormatted = round($sizeBytes / 1024, 0) . ' KB';
} else {
$sizeFormatted = $sizeBytes . ' B';
}
$audioFiles[] = [
'name' => $file,
'path' => '../assets/audio/' . $file,
'sizeBytes' => $sizeBytes,
'size' => $sizeFormatted,
'modified' => $modified,
'dateFormatted' => date('d.m.Y H:i', $modified)
];
}
}
usort($audioFiles, function($a, $b) {
return strcasecmp($a['name'], $b['name']);
});
jsonResponse(true, $audioFiles);
break;
case 'upload-audio':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
$errorMsg = 'Upload error';
if (isset($_FILES['audio']['error'])) {
switch ($_FILES['audio']['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$errorMsg = 'File too large';
break;
case UPLOAD_ERR_NO_FILE:
$errorMsg = 'No file selected';
break;
}
}
jsonResponse(false, null, $errorMsg);
}
$file = $_FILES['audio'];
$allowedMimeTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/x-m4a', 'audio/aac', 'audio/flac'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
unset($finfo);
if (!in_array($mimeType, $allowedMimeTypes)) {
jsonResponse(false, null, 'Only MP3, WAV, OGG, M4A, AAC and FLAC allowed');
}
if ($file['size'] > 50 * 1024 * 1024) {
jsonResponse(false, null, 'Maximum file size: 50 MB');
}
$originalName = pathinfo($file['name'], PATHINFO_FILENAME);
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$safeName = preg_replace('/[^a-z0-9\-_]/i', '-', $originalName);
$safeName = preg_replace('/-+/', '-', $safeName);
$safeName = trim($safeName, '-');
if (empty($safeName)) {
$safeName = 'audio-' . time();
}
$filename = $safeName . '.' . $extension;
$counter = 1;
while (file_exists(AUDIO_PATH . $filename)) {
$filename = $safeName . '-' . $counter . '.' . $extension;
$counter++;
}
// Validate the final extension independently of the MIME type: a polyglot
// can pass the finfo check while keeping an executable .php name.
if (!validateMediaFilename($filename, 'audio')) {
jsonResponse(false, null, 'Invalid file extension');
}
if (!is_dir(AUDIO_PATH)) {
mkdir(AUDIO_PATH, 0755, true);
}
if (move_uploaded_file($file['tmp_name'], AUDIO_PATH . $filename)) {
jsonResponse(true, [
'name' => $filename,
'path' => '../assets/audio/' . $filename
], 'Audio file uploaded');
} else {
jsonResponse(false, null, 'Error saving');
}
break;
case 'delete-audio':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$filename = $_POST['filename'] ?? '';
if (empty($filename) || strpos($filename, '/') !== false || strpos($filename, '\\') !== false || strpos($filename, '..') !== false) {
jsonResponse(false, null, 'Invalid filename');
}
$sourcePath = AUDIO_PATH . $filename;
if (!file_exists($sourcePath)) {
jsonResponse(false, null, 'File not found');
}
if (!is_dir(AUDIO_TRASH_PATH)) {
mkdir(AUDIO_TRASH_PATH, 0755, true);
}
$targetFilename = $filename;
$counter = 1;
while (file_exists(AUDIO_TRASH_PATH . $targetFilename)) {
$name = pathinfo($filename, PATHINFO_FILENAME);
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$targetFilename = $name . '-' . $counter . '.' . $ext;
$counter++;
}
if (rename($sourcePath, AUDIO_TRASH_PATH . $targetFilename)) {
jsonResponse(true, null, 'Audio file moved to trash');
} else {
jsonResponse(false, null, 'Error moving');
}
break;
// ============================================================
// NEWS / BLOG MANAGEMENT
// ============================================================
}

67
admin/api/navigation.php Normal file
View File

@ -0,0 +1,67 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'get-menu-items':
require_once RAIJU_ADMIN_DIR . '/../includes/menu-helpers.php';
if (!file_exists(RAIJU_ADMIN_DIR . '/../includes/nav-config.php')) {
$NAV_ITEMS = [];
} else {
include_once RAIJU_ADMIN_DIR . '/../includes/nav-config.php';
if (!isset($NAV_ITEMS)) $NAV_ITEMS = [];
}
$menuId = trim($_GET['menu'] ?? '');
$lang = trim($_GET['lang'] ?? (defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'));
if (!$menuId) {
jsonResponse(false, null, 'Missing menu parameter');
}
$allNavItems = $NAV_ITEMS[$lang] ?? [];
$items = getMenuItems($menuId, $lang, '', $allNavItems);
jsonResponse(true, ['items' => $items, 'menu' => $menuId, 'lang' => $lang]);
break;
case 'save-menu-order':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$menuId = trim($_POST['menu'] ?? '');
$lang = trim($_POST['lang'] ?? '');
$orderRaw = $_POST['order'] ?? '';
if (!$menuId || !$lang) {
jsonResponse(false, null, 'Missing menu or lang parameter');
}
$order = json_decode($orderRaw, true);
if (!is_array($order)) {
jsonResponse(false, null, 'Invalid order data');
}
// Sanitize: only allow valid slug characters
$order = array_values(array_filter($order, fn($s) => is_string($s) && raijuPageIsValidPath($s)));
$menusPath = RAIJU_ADMIN_DIR . '/../content/menus.json';
$registry = file_exists($menusPath) ? json_decode(file_get_contents($menusPath), true) : ['menus' => []];
if (!isset($registry['menus'][$menuId])) {
jsonResponse(false, null, 'Unknown menu: ' . $menuId);
}
if (!isset($registry['menus'][$menuId]['order'])) {
$registry['menus'][$menuId]['order'] = [];
}
$registry['menus'][$menuId]['order'][$lang] = $order;
file_put_contents($menusPath, json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
jsonResponse(true, null, 'Menu order saved');
break;
}

194
admin/api/news.php Normal file
View File

@ -0,0 +1,194 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'load-news':
$newsDir = dirname(CONTENT_PATH) . '/news/';
if (!is_dir($newsDir)) {
jsonResponse(true, []);
}
$filterLang = $_GET['lang'] ?? '';
$posts = [];
foreach (glob($newsDir . '*.json') as $file) {
$post = json_decode(file_get_contents($file), true);
if (!is_array($post)) continue;
// Posts without lang field default to primary language
if (empty($post['lang'])) {
$post['lang'] = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en';
}
// Filter by language if requested
if ($filterLang && $post['lang'] !== $filterLang) continue;
$posts[] = $post;
}
// Sort by date descending
usort($posts, function($a, $b) {
return strcmp($b['date'] ?? '', $a['date'] ?? '');
});
jsonResponse(true, $posts);
break;
case 'save-news':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$postJson = $_POST['post'] ?? '';
$post = json_decode($postJson, true);
if (!is_array($post)) {
jsonResponse(false, null, 'Invalid JSON format');
}
// Validate required fields
$title = trim($post['title'] ?? '');
$date = trim($post['date'] ?? '');
if (empty($title) || empty($date)) {
jsonResponse(false, null, 'Date and title are required');
}
// Generate slug from title if not provided
$slug = trim($post['slug'] ?? '');
if (empty($slug)) {
$slug = strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $title), '-'));
}
// Validate slug
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) {
jsonResponse(false, null, 'Invalid slug format');
}
// Validate date format
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
jsonResponse(false, null, 'Invalid date format');
}
// Validate language
$lang = trim($post['lang'] ?? '');
if (empty($lang) || !preg_match('/^[a-z]{2}$/', $lang)) {
$lang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en';
}
// Build post ID from date + slug (+ lang suffix for non-default)
$defaultLang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en';
$postId = $date . '-' . $slug;
if ($lang !== $defaultLang) {
$postId .= '-' . $lang;
}
// If editing an existing post with a different ID, delete the old file
$oldId = $post['id'] ?? '';
if (!is_string($oldId) || ($oldId !== '' && !preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $oldId))) {
jsonResponse(false, null, 'Invalid news ID');
}
$newsDir = dirname(CONTENT_PATH) . '/news/';
if (!is_dir($newsDir)) {
mkdir($newsDir, 0755, true);
}
$filepath = $newsDir . $postId . '.json';
if ($oldId !== $postId && is_file($filepath)) {
jsonResponse(false, null, 'A news post with this date and slug already exists.');
}
// Sanitize content
$sanitized = [
'id' => $postId,
'lang' => $lang,
'title' => $title,
'slug' => $slug,
'date' => $date,
'author' => trim($post['author'] ?? ''),
'excerpt' => trim($post['excerpt'] ?? ''),
'image' => trim($post['image'] ?? ''),
'content' => sanitizeHtml((string)($post['content'] ?? '')),
'hidden' => !empty($post['hidden']),
'lastModified' => date('c'),
];
$filepath = $newsDir . $postId . '.json';
$result = raijuJsonAtomicWrite($filepath, $sanitized);
if ($result === false) {
jsonResponse(false, null, 'Error saving post');
}
if ($oldId !== '' && $oldId !== $postId) {
@unlink($newsDir . $oldId . '.json');
}
jsonResponse(true, $sanitized, 'Post saved');
break;
case 'toggle-news-status':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$postId = $_POST['post_id'] ?? '';
if (empty($postId) || !preg_match('/^[a-z0-9][a-z0-9-]*$/', $postId)) {
jsonResponse(false, null, 'Invalid post ID');
}
$newsDir = dirname(CONTENT_PATH) . '/news/';
$filepath = $newsDir . $postId . '.json';
if (!is_file($filepath)) {
jsonResponse(false, null, 'Post not found');
}
$post = json_decode(file_get_contents($filepath), true);
if (!is_array($post)) {
jsonResponse(false, null, 'Invalid post data');
}
$post['hidden'] = !($post['hidden'] ?? false);
$post['lastModified'] = date('c');
$result = file_put_contents(
$filepath,
json_encode($post, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
if ($result === false) {
jsonResponse(false, null, 'Error updating post');
}
jsonResponse(true, ['hidden' => $post['hidden']]);
break;
case 'delete-news':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$postId = $_POST['post_id'] ?? '';
if (empty($postId) || !preg_match('/^[a-z0-9][a-z0-9-]*$/', $postId)) {
jsonResponse(false, null, 'Invalid post ID');
}
$newsDir = dirname(CONTENT_PATH) . '/news/';
$filepath = $newsDir . $postId . '.json';
if (!is_file($filepath)) {
jsonResponse(false, null, 'Post not found');
}
if (!unlink($filepath)) {
jsonResponse(false, null, 'Error deleting post');
}
jsonResponse(true, null, 'Post deleted');
break;
// ============================================================
// MAIL MANAGEMENT
// ============================================================
}

530
admin/api/pages.php Normal file
View File

@ -0,0 +1,530 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'list-pages':
jsonResponse(true, buildPageList());
break;
case 'create-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$title = trim($_POST['title'] ?? '');
$slug = trim($_POST['slug'] ?? '');
$lang = trim($_POST['lang'] ?? '');
if (empty($title)) {
jsonResponse(false, null, 'Title is required');
}
if (!raijuPageIsValidPath($slug)) {
jsonResponse(false, null, 'Invalid path (use lowercase URL segments separated by slashes)');
}
if (!preg_match('/^[a-z]{2}$/', $lang)) {
jsonResponse(false, null, 'Invalid language');
}
$pageName = raijuPageContentKey($lang, $slug);
$filepath = CONTENT_PATH . $pageName . '.json';
if (file_exists($filepath)) {
jsonResponse(false, null, 'A page with this slug already exists');
}
$content = [
'page' => $pageName,
'path' => $slug,
'lang' => $lang,
'title' => $title,
'description' => '',
'visibility' => [
'status' => 'public',
'title' => '',
'text' => '',
],
'seo' => [
'title' => '',
'description' => '',
'answerSummary' => '',
'canonical' => '',
'robots' => 'index, follow',
'ogTitle' => '',
'ogDescription' => '',
'ogImage' => '',
'sitemap' => true,
],
'lastModified' => date('c'),
'sections' => [
[
'id' => 'section_heading',
'type' => 'text',
'title' => $title,
'titleTag' => 'h1',
'content' => '<p>Page content goes here.</p>',
],
],
];
$result = file_put_contents(
$filepath,
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
if ($result === false) {
jsonResponse(false, null, 'Error creating page');
}
jsonResponse(true, ['page' => $pageName, 'pageList' => buildPageList()], 'Page created');
break;
case 'copy-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$sourcePage = $_POST['source'] ?? '';
$targetLang = $_POST['targetLang'] ?? '';
$slug = $_POST['slug'] ?? '';
if (!validatePageName($sourcePage)) {
jsonResponse(false, null, 'Invalid source page name');
}
if (!preg_match('/^[a-z]{2}$/', $targetLang)) {
jsonResponse(false, null, 'Invalid target language');
}
if (!raijuPageIsValidPath($slug)) {
jsonResponse(false, null, 'Invalid slug');
}
$sourceFile = CONTENT_PATH . $sourcePage . '.json';
if (!file_exists($sourceFile)) {
jsonResponse(false, null, 'Source page does not exist');
}
$targetPage = raijuPageContentKey($targetLang, $slug);
$targetFile = CONTENT_PATH . $targetPage . '.json';
if (file_exists($targetFile)) {
jsonResponse(false, null, 'Target page already exists');
}
$content = json_decode(file_get_contents($sourceFile), true);
if ($content === null) {
jsonResponse(false, null, 'Error reading source page');
}
// Update metadata for the new language
$content['page'] = $targetPage;
$content['path'] = $slug;
$content['lang'] = $targetLang;
$content['lastModified'] = date('c');
$result = file_put_contents(
$targetFile,
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
if ($result === false) {
jsonResponse(false, null, 'Error creating page');
}
jsonResponse(true, ['page' => $targetPage, 'pageList' => buildPageList()], 'Page created as copy');
break;
case 'delete-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$page = $_POST['page'] ?? '';
if (!validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$filepath = CONTENT_PATH . $page . '.json';
if (!file_exists($filepath)) {
jsonResponse(false, null, 'Page does not exist');
}
// Move to trash instead of deleting
if (!is_dir(PAGES_TRASH_PATH)) {
mkdir(PAGES_TRASH_PATH, 0755, true);
}
$timestamp = date('Y-m-d_His');
$trashName = $page . '_' . $timestamp . '.json';
if (!rename($filepath, PAGES_TRASH_PATH . $trashName)) {
jsonResponse(false, null, 'Error moving page to trash');
}
jsonResponse(true, ['pageList' => buildPageList()], 'Page moved to trash');
break;
case 'duplicate-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$sourcePage = $_POST['source'] ?? '';
if (!validatePageName($sourcePage)) {
jsonResponse(false, null, 'Invalid page name');
}
$sourceFile = CONTENT_PATH . $sourcePage . '.json';
if (!file_exists($sourceFile)) {
jsonResponse(false, null, 'Source page does not exist');
}
// Find a unique slug: append -copy, -copy-2, etc.
// Extract lang and slug from source
$underscorePos = strpos($sourcePage, '_');
$lang = substr($sourcePage, 0, $underscorePos);
$slug = substr($sourcePage, $underscorePos + 1);
$copySlug = $slug . '-copy';
$counter = 2;
while (file_exists(CONTENT_PATH . raijuPageContentKey($lang, $copySlug) . '.json')) {
$copySlug = $slug . '-copy-' . $counter;
$counter++;
}
$newPage = raijuPageContentKey($lang, $copySlug);
$content = json_decode(file_get_contents($sourceFile), true);
if ($content === null) {
jsonResponse(false, null, 'Error reading source page');
}
$content['page'] = $newPage;
$content['path'] = $copySlug;
if (isset($content['title'])) {
$content['title'] .= ' (Copy)';
}
$content['lastModified'] = date('c');
$result = file_put_contents(
CONTENT_PATH . $newPage . '.json',
json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
if ($result === false) {
jsonResponse(false, null, 'Error duplicating page');
}
jsonResponse(true, ['page' => $newPage, 'slug' => $copySlug, 'pageList' => buildPageList()], 'Page duplicated');
break;
// ============================================================
// PAGE TRASH
// ============================================================
case 'list-trash':
if (!is_dir(PAGES_TRASH_PATH)) {
jsonResponse(true, []);
}
$trashItems = [];
$files = glob(PAGES_TRASH_PATH . '*.json');
foreach ($files as $file) {
$filename = basename($file, '.json');
// Parse: {lang}_{slug}_{date}_{time}
if (!preg_match('/^(.+)_(\d{4}-\d{2}-\d{2})_(\d{6})$/', $filename, $m) || !validatePageName($m[1])) {
continue;
}
$pageName = $m[1];
$date = $m[2];
$time = substr($m[3], 0, 2) . ':' . substr($m[3], 2, 2) . ':' . substr($m[3], 4, 2);
$data = json_decode(file_get_contents($file), true);
$trashItems[] = [
'filename' => basename($file),
'page' => $pageName,
'title' => $data['title'] ?? ucfirst(str_replace('-', ' ', substr($pageName, 3))),
'lang' => $data['lang'] ?? substr($pageName, 0, 2),
'deletedDate' => $date,
'deletedTime' => $time,
'timestamp' => filemtime($file),
];
}
usort($trashItems, function($a, $b) {
return $b['timestamp'] - $a['timestamp'];
});
jsonResponse(true, $trashItems);
break;
case 'restore-page':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$trashFile = $_POST['filename'] ?? '';
if (empty($trashFile) || !validateBackupName($trashFile)) {
jsonResponse(false, null, 'Invalid trash filename');
}
$trashPath = PAGES_TRASH_PATH . $trashFile;
if (!file_exists($trashPath)) {
jsonResponse(false, null, 'Trash file not found');
}
// Extract original page name (remove timestamp suffix)
$pageName = preg_replace('/_\d{4}-\d{2}-\d{2}_\d{6}(?:_[a-f0-9]{6})?\.json$/', '', $trashFile);
$targetPath = CONTENT_PATH . $pageName . '.json';
// If a page with the same name already exists, abort
if (file_exists($targetPath)) {
jsonResponse(false, null, 'A page with this name already exists. Delete or rename it first.');
}
if (!rename($trashPath, $targetPath)) {
jsonResponse(false, null, 'Error restoring page');
}
jsonResponse(true, ['page' => $pageName, 'pageList' => buildPageList()], 'Page restored');
break;
case 'delete-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$trashFile = $_POST['filename'] ?? '';
if (empty($trashFile) || !validateBackupName($trashFile)) {
jsonResponse(false, null, 'Invalid trash filename');
}
$trashPath = PAGES_TRASH_PATH . $trashFile;
if (!file_exists($trashPath)) {
jsonResponse(false, null, 'Trash file not found');
}
if (!unlink($trashPath)) {
jsonResponse(false, null, 'Error deleting permanently');
}
jsonResponse(true, null, 'Page permanently deleted');
break;
case 'empty-trash':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
if (!is_dir(PAGES_TRASH_PATH)) {
jsonResponse(true, null, 'Trash is already empty');
}
$files = glob(PAGES_TRASH_PATH . '*.json');
$deleted = 0;
foreach ($files as $file) {
if (unlink($file)) {
$deleted++;
}
}
jsonResponse(true, ['deleted' => $deleted], $deleted . ' page(s) permanently deleted');
break;
case 'load':
$page = $_GET['page'] ?? $_POST['page'] ?? '';
if (!validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$filepath = CONTENT_PATH . $page . '.json';
if (!file_exists($filepath)) {
jsonResponse(true, [
'page' => $page,
'lastModified' => null,
'sections' => []
]);
}
$content = json_decode(file_get_contents($filepath), true);
jsonResponse(true, $content);
break;
case 'save':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$page = $_POST['page'] ?? '';
if (!validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$content = $_POST['content'] ?? '';
$contentData = json_decode($content, true);
if (!is_array($contentData)) {
jsonResponse(false, null, 'Invalid JSON format');
}
$filepath = CONTENT_PATH . $page . '.json';
$existingData = file_exists($filepath) ? (json_decode(file_get_contents($filepath), true) ?: []) : [];
$contentData = normalizePageVisibility($contentData, $existingData);
$contentData = normalizePageSeo($contentData);
// Create backup if file exists
if (file_exists($filepath)) {
$timestamp = date('Y-m-d_His') . '_' . bin2hex(random_bytes(3));
$backupPath = BACKUP_PATH . $page . '_' . $timestamp . '.json';
if (!copy($filepath, $backupPath)) jsonResponse(false, null, 'Could not create a backup before saving');
cleanupOldBackups($page);
}
$contentData['lastModified'] = date('c');
$result = raijuJsonAtomicWrite($filepath, $contentData);
if ($result === false) {
jsonResponse(false, null, 'Error saving');
}
$responseData = ['lastModified' => $contentData['lastModified']];
if (($pageParts = raijuPageParseContentKey($page)) !== null) {
$responseData['seoHealth'] = buildPageSeoHealth($pageParts['lang'], $pageParts['path'], $contentData);
}
// Optional: re-render the full sections list so the client can patch
// the .editable-content-area DOM without a full page reload (used
// after add/delete/reorder). Returning the whole list keeps card-grid
// grouping and index-based data-field attributes in sync.
if (isset($GLOBALS['raijuRevisionLock']) && is_resource($GLOBALS['raijuRevisionLock'])) {
flock($GLOBALS['raijuRevisionLock'], LOCK_UN); fclose($GLOBALS['raijuRevisionLock']);
}
if (!empty($_POST['render_sections'])) {
require_once RAIJU_ADMIN_DIR . '/../includes/content-loader.php';
// Force admin mode for the renderer: we're already authenticated
// via the save endpoint, but renderAllSections checks isAdminLoggedIn.
$responseData['sectionsHtml'] = renderAllSections($page);
}
jsonResponse(true, $responseData, 'Saved successfully');
break;
// ============================================================
// BACKUPS
// ============================================================
case 'backups':
$page = $_GET['page'] ?? '';
if (!validatePageName($page)) {
jsonResponse(false, null, 'Invalid page name');
}
$backups = glob(BACKUP_PATH . $page . '_*.json');
$backupList = [];
foreach ($backups as $backup) {
$filename = basename($backup);
if (preg_match('/^' . preg_quote($page, '/') . '_(\d{4}-\d{2}-\d{2})_(\d{6})(?:_[a-f0-9]{6})?\.json$/', $filename, $matches)) {
$date = $matches[1];
$time = substr($matches[2], 0, 2) . ':' . substr($matches[2], 2, 2) . ':' . substr($matches[2], 4, 2);
$backupList[] = [
'filename' => $filename,
'date' => $date,
'time' => $time,
'timestamp' => filemtime($backup)
];
}
}
usort($backupList, function($a, $b) {
return $b['timestamp'] - $a['timestamp'];
});
jsonResponse(true, $backupList);
break;
case 'restore':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$backup = $_POST['backup'] ?? '';
if (!validateBackupName($backup)) {
jsonResponse(false, null, 'Invalid backup name');
}
$backupPath = BACKUP_PATH . $backup;
if (!file_exists($backupPath)) {
jsonResponse(false, null, 'Backup not found');
}
$page = preg_replace('/_\d{4}-\d{2}-\d{2}_\d{6}(?:_[a-f0-9]{6})?\.json$/', '', $backup);
$filepath = CONTENT_PATH . $page . '.json';
if (isset($_POST['page']) && $_POST['page'] !== $page) {
http_response_code(400);
jsonResponse(false, null, 'Backup does not belong to the requested page');
}
// Save current state before restoring
if (file_exists($filepath)) {
$timestamp = date('Y-m-d_His') . '_' . bin2hex(random_bytes(3));
$newBackupPath = BACKUP_PATH . $page . '_' . $timestamp . '.json';
if (!copy($filepath, $newBackupPath)) {
jsonResponse(false, null, 'Could not create a backup before restoring');
}
}
$restoredData = json_decode((string)file_get_contents($backupPath), true);
$result = is_array($restoredData) && raijuJsonAtomicWrite($filepath, $restoredData);
if (!$result) {
jsonResponse(false, null, 'Error restoring');
}
cleanupOldBackups($page);
jsonResponse(true, null, 'Backup restored successfully');
break;
case 'delete-backup':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$backup = $_POST['backup'] ?? '';
if (!validateBackupName($backup)) {
jsonResponse(false, null, 'Invalid backup name');
}
$backupPath = BACKUP_PATH . $backup;
if (!file_exists($backupPath)) {
jsonResponse(false, null, 'Backup not found');
}
$result = unlink($backupPath);
if (!$result) {
jsonResponse(false, null, 'Error deleting');
}
jsonResponse(true, null, 'Backup deleted');
break;
case 'preview-backup':
$backup = $_GET['backup'] ?? '';
if (!validateBackupName($backup)) {
jsonResponse(false, null, 'Invalid backup name');
}
$backupPath = BACKUP_PATH . $backup;
if (!file_exists($backupPath)) {
jsonResponse(false, null, 'Backup not found');
}
$content = json_decode(file_get_contents($backupPath), true);
jsonResponse(true, $content);
break;
// ============================================================
// EVENTS
// ============================================================
}

163
admin/api/routes.php Normal file
View File

@ -0,0 +1,163 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
return [
'system-status' => 'dashboard',
'security-check' => 'dashboard',
'seo-check' => 'dashboard',
'ai-resolve-request' => 'ai',
'keepalive' => 'dashboard',
'dashboard-overview' => 'dashboard',
'list-pages' => 'pages',
'create-page' => 'pages',
'copy-page' => 'pages',
'delete-page' => 'pages',
'duplicate-page' => 'pages',
'list-trash' => 'pages',
'restore-page' => 'pages',
'delete-trash' => 'pages',
'empty-trash' => 'pages',
'load' => 'pages',
'save' => 'pages',
'backups' => 'pages',
'restore' => 'pages',
'delete-backup' => 'pages',
'preview-backup' => 'pages',
'list-icons' => 'icons',
'iconify-search' => 'icons',
'iconify-import' => 'icons',
'save-icon' => 'icons',
'delete-icon' => 'icons',
'load-ai-settings' => 'ai',
'ai-image-history' => 'ai',
'ai-image-jobs' => 'ai',
'ai-image-job-run' => 'ai',
'clear-ai-image-history' => 'ai',
'save-ai-settings' => 'ai',
'ai-clear-secret' => 'ai',
'ai-test' => 'ai',
'ai-chat' => 'ai',
'ai-copilot-context' => 'ai',
'ai-copilot-history-list' => 'ai',
'ai-copilot-history-load' => 'ai',
'ai-copilot-history-save' => 'ai',
'ai-copilot-history-delete' => 'ai',
'ai-copilot-chat' => 'ai',
'ai-copilot-chat-stream' => 'ai',
'ai-copilot-suggest' => 'ai',
'ai-copilot-translate' => 'ai',
'ai-copilot-format-html' => 'ai',
'ai-copilot-visibility' => 'ai',
'ai-copilot-apply' => 'ai',
'ai-copilot-apply-visibility' => 'ai',
'ai-copilot-undo' => 'ai',
'ai-copilot-draft-content' => 'ai',
'ai-copilot-create-content' => 'ai',
'ai-copilot-publish-content' => 'ai',
'ai-copilot-generate-image' => 'ai',
'ai-generate-text' => 'ai',
'ai-generate-seo' => 'ai',
'ai-openrouter-models' => 'ai',
'ai-image-models' => 'ai',
'ai-list-models' => 'ai',
'ai-content-audit' => 'ai',
'ai-content-audit-suggest' => 'ai',
'ai-content-audit-apply' => 'ai',
'ai-generate-image' => 'ai',
'ai-agent-start' => 'agent',
'ai-agent-step' => 'agent',
'ai-agent-status' => 'agent',
'ai-agent-reply' => 'agent',
'ai-agent-cancel' => 'agent',
'ai-agent-changeset' => 'agent',
'ai-agent-revalidate' => 'agent',
'ai-agent-apply' => 'agent',
'ai-agent-discard' => 'agent',
'ai-agent-undo' => 'agent',
'ai-agent-publish' => 'agent',
'ai-agent-upload' => 'agent',
'ai-agent-media' => 'agent',
'ai-agent-clear-cache' => 'agent',
'load-events' => 'events',
'save-event' => 'events',
'delete-event' => 'events',
'list-events-trash' => 'events',
'restore-event' => 'events',
'delete-event-permanent' => 'events',
'empty-events-trash' => 'events',
'toggle-event-visibility' => 'events',
'load-event' => 'events',
'list-images' => 'media',
'list-media' => 'media',
'upload-media' => 'media',
'create-media-folder' => 'media',
'delete-media-folder' => 'media',
'move-media' => 'media',
'rename-media' => 'media',
'set-media-ai-label' => 'media',
'delete-media' => 'media',
'restore-media' => 'media',
'delete-media-trash' => 'media',
'empty-media-trash' => 'media',
'media-trash-file' => 'media',
'list-image-trash' => 'media',
'image-trash-file' => 'media',
'upload-image' => 'media',
'delete-image' => 'media',
'restore-image' => 'media',
'delete-image-trash' => 'media',
'empty-image-trash' => 'media',
'list-audio' => 'media',
'upload-audio' => 'media',
'delete-audio' => 'media',
'load-news' => 'news',
'save-news' => 'news',
'toggle-news-status' => 'news',
'delete-news' => 'news',
'load-mails' => 'forms',
'list-forms' => 'forms',
'load-form' => 'forms',
'save-form' => 'forms',
'mark-mail-read' => 'forms',
'update-mail-flags' => 'forms',
'mark-all-mails-read' => 'forms',
'delete-mail' => 'forms',
'delete-read-mails' => 'forms',
'unread-mail-count' => 'forms',
'change-password' => 'users',
'list-users' => 'users',
'create-user' => 'users',
'update-user' => 'users',
'delete-user' => 'users',
'admin-reset-password' => 'users',
'load-settings' => 'settings',
'save-settings' => 'settings',
'test-email' => 'settings',
'total-reset' => 'settings',
'create-site-backup' => 'backups',
'download-site-backup' => 'backups',
'restore-site-backup' => 'backups',
'backup-status' => 'backups',
'backup-list' => 'backups',
'backup-create-now' => 'backups',
'backup-prune' => 'backups',
'backup-delete' => 'backups',
'backup-upload-remote' => 'backups',
'backup-remote-list' => 'backups',
'backup-remote-import' => 'backups',
'backup-remote-delete' => 'backups',
'backup-test-remote' => 'backups',
'backup-dropbox-oauth-start' => 'backups',
'backup-dropbox-oauth-callback' => 'backups',
'backup-dropbox-broker-callback' => 'backups',
'backup-google-oauth-start' => 'backups',
'backup-onedrive-oauth-start' => 'backups',
'backup-google-broker-callback' => 'backups',
'backup-onedrive-broker-callback' => 'backups',
'backup-google-oauth-callback' => 'backups',
'backup-onedrive-oauth-callback' => 'backups',
'backup-prepare-download' => 'backups',
'backup-update-settings' => 'backups',
'get-menu-items' => 'navigation',
'save-menu-order' => 'navigation',
];

603
admin/api/settings.php Normal file
View File

@ -0,0 +1,603 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'load-settings':
$defaults = [
'favicon' => defined('RAIJU_DEFAULT_FAVICON') ? RAIJU_DEFAULT_FAVICON : '/assets/images/favicon.svg',
'favicon_png' => '',
'branding' => [
'logo' => '',
'logoDark' => '',
'adminLogo' => '',
'name' => defined('SITE_NAME') ? SITE_NAME : 'CMS',
'showBranding' => true,
'logoDisplay' => 'both',
'logoSize' => 'medium'
],
'theme' => [
'adminTheme' => 'light',
'preset' => 'soft',
'density' => 'auto',
'fontSize' => 'default',
'font' => 'system',
'primaryColor' => '#3858e9',
'accentColor' => '',
'dangerColor' => '',
'warningColor' => '',
'successColor' => '',
'sidebarBg' => '',
'darkPrimaryColor' => '',
'darkAccentColor' => '',
'darkSidebarBg' => '',
'buttonGlow' => true,
'buttonRadius' => 6
],
'email' => [
'method' => 'inactive',
'recipientEmail' => '',
'bccEmail' => '',
'fromEmail' => '',
'fromName' => defined('SITE_NAME') ? SITE_NAME : '',
'smtpHost' => '',
'smtpPort' => 587,
'smtpUsername' => '',
'smtpPassword' => '',
'smtpEncryption' => 'tls'
],
'privacy' => [
'emailObfuscation' => false,
'analyticsEnabled' => true,
'rememberPublicTheme' => true
],
'modules' => [
'ai' => true,
'news' => true,
'events' => true,
'messages' => true,
'iconManager' => true
],
'dashboard' => [
'itemsPerPage' => 50,
'iconManagerItemsPerPage' => 50,
'mediaItemsPerPage' => 25
],
'media' => raijuMediaSettingsDefaults(),
'access' => [
'maintenance' => [
'enabled' => false,
'mode' => 'maintenance',
'title' => t('settings.maintenance_default_title'),
'text' => t('settings.maintenance_default_text'),
'until' => '',
'showCountdown' => false,
'brandAsset' => 'none',
'image' => '',
'imageLayout' => 'none',
'overlayColor' => '',
'overlayOpacity' => 88,
'bypassParam' => 'preview',
'bypassKeyHash' => '',
'bypassLinks' => []
]
],
'login' => [
'brandAsset' => 'favicon',
'image' => '',
'imageLayout' => 'none',
'overlayColor' => '',
'overlayOpacity' => 86,
'boxStyle' => 'card',
'boxColor' => '',
'boxTextColor' => ''
],
'seo' => [
'siteUrl' => '',
'organizationName' => '',
'defaultOgImage' => '',
'noindexSite' => false
]
];
if (!defined('SETTINGS_PATH') || !file_exists(SETTINGS_PATH)) {
$defaults['access']['maintenance']['hasBypassKey'] = false;
unset($defaults['access']['maintenance']['bypassKeyHash']);
jsonResponse(true, $defaults);
}
$settings = json_decode(file_get_contents(SETTINGS_PATH), true);
if ($settings === null) {
$defaults['access']['maintenance']['hasBypassKey'] = false;
unset($defaults['access']['maintenance']['bypassKeyHash']);
jsonResponse(true, $defaults);
}
// Merge with defaults to ensure all keys exist
$merged = array_replace_recursive($defaults, $settings);
$merged['access']['maintenance']['hasBypassKey'] = !empty($merged['access']['maintenance']['bypassKeyHash']);
unset($merged['access']['maintenance']['bypassKeyHash']);
if (!isAdmin()) { unset($merged['email'], $merged['ai'], $merged['backup']); }
jsonResponse(true, $merged);
break;
case 'save-settings':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!defined('SETTINGS_PATH')) {
jsonResponse(false, null, 'Settings path not configured');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$settingsJson = $_POST['settings'] ?? '';
$settings = json_decode($settingsJson, true);
if (!is_array($settings)) {
jsonResponse(false, null, 'Invalid JSON format');
}
// Whitelist allowed keys
$allowed = [
'branding' => ['logo', 'logoDark', 'adminLogo', 'name', 'showBranding', 'logoDisplay', 'logoSize'],
'theme' => ['adminTheme', 'publicDefault', 'preset', 'density', 'font', 'fontSize', 'primaryColor', 'accentColor', 'sidebarBg', 'darkPrimaryColor', 'darkAccentColor', 'darkSidebarBg', 'dangerColor', 'warningColor', 'successColor', 'buttonGlow', 'buttonRadius'],
'general' => ['adminLanguage', 'frontendLoginRedirect', 'aiLabelArtwork'],
'email' => ['method', 'recipientEmail', 'bccEmail', 'fromEmail', 'fromName', 'smtpHost', 'smtpPort', 'smtpUsername', 'smtpPassword', 'smtpEncryption'],
'seo' => ['siteUrl', 'organizationName', 'defaultOgImage', 'noindexSite']
];
// Top-level scalar settings (not nested under a group)
$allowedScalar = ['favicon', 'favicon_png'];
// A path or address that is already stored is not checked again: an old invalid value must not block saving
// unrelated settings (colours, radius, language ...), because the dashboard sends every group along.
// Only a changed value has to be valid.
$storedSettings = [];
if (file_exists(SETTINGS_PATH)) {
$storedSettings = json_decode(file_get_contents(SETTINGS_PATH), true) ?: [];
}
$isStored = static function (?string $group, string $key, $value) use ($storedSettings): bool {
$stored = $group === null ? ($storedSettings[$key] ?? null) : ($storedSettings[$group][$key] ?? null);
return is_string($value) && is_string($stored) && $value !== '' && $stored === $value;
};
$sanitized = [];
foreach ($allowed as $group => $keys) {
if (!isset($settings[$group])) continue;
$sanitized[$group] = [];
foreach ($keys as $key) {
if (array_key_exists($key, $settings[$group])) {
$value = $settings[$group][$key];
// Validate the base colour — required (must be set)
if ($key === 'primaryColor') {
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $value)) {
jsonResponse(false, null, 'Invalid color value for ' . $key);
}
}
// Validate optional color values — empty string means "auto"
// (accentColor derives from the base colour unless overridden)
if (in_array($key, ['accentColor', 'sidebarBg', 'darkPrimaryColor', 'darkAccentColor', 'darkSidebarBg', 'dangerColor', 'warningColor', 'successColor'])) {
$value = (string)$value;
if ($value !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $value)) {
jsonResponse(false, null, 'Invalid color value for ' . $key);
}
}
// Validate theme choices
if (in_array($key, ['adminTheme', 'publicDefault'], true) && !in_array($value, ['light', 'dark', 'system'], true)) {
jsonResponse(false, null, 'Invalid theme value');
}
if ($key === 'preset' && !in_array($value, ['soft', 'classic', 'paper', 'studio', 'console', 'pop'], true)) {
jsonResponse(false, null, 'Invalid theme preset');
}
if ($key === 'density' && !in_array($value, ['auto', 'compact', 'comfortable'], true)) {
jsonResponse(false, null, 'Invalid density value');
}
if ($key === 'font') {
if ($value === 'geist') $value = 'plex'; // retired in 1.0.0
if (!in_array($value, ['system', 'inter', 'plex', 'atkinson', 'public', 'geist-mono'], true)) {
jsonResponse(false, null, 'Invalid font value');
}
}
if ($key === 'fontSize' && !in_array($value, ['small', 'default', 'large'], true)) {
jsonResponse(false, null, 'Invalid font size value');
}
// Validate buttonGlow (boolean)
if ($key === 'buttonGlow') {
$value = (bool)$value;
}
// Validate buttonRadius (integer 0-24)
if ($key === 'buttonRadius') {
$value = max(0, min(24, intval($value)));
}
// Validate frontendLoginRedirect mode
if ($key === 'frontendLoginRedirect' && !in_array($value, ['auto', 'dashboard'], true)) {
jsonResponse(false, null, 'Invalid frontendLoginRedirect value');
}
// Validate logo paths (prevent traversal and protocol injection)
if (in_array($key, ['logo', 'logoDark', 'adminLogo'], true)) {
$value = (string)$value;
if ($value !== '' && !$isStored($group, $key, $value) && (
strpos($value, '..') !== false ||
!str_starts_with($value, '/assets/images/') ||
preg_match('#[:\x00]#', $value)
)) {
jsonResponse(false, null, 'Invalid logo path');
}
}
if ($group === 'seo' && $key === 'defaultOgImage' && !$isStored($group, $key, $value)) {
$value = normalizeOgImagePath((string)$value);
if (!validateOgImagePath($value)) {
jsonResponse(false, null, 'Default Open Graph image must be a JPG or PNG file from /assets/images/');
}
}
if ($group === 'seo' && $key === 'siteUrl' && !$isStored($group, $key, $value)) {
$value = rtrim(trim((string)$value), '/');
if ($value !== '' && filter_var($value, FILTER_VALIDATE_URL) === false) {
jsonResponse(false, null, 'Invalid SEO site URL');
}
}
if ($group === 'seo' && $key === 'organizationName') {
$value = trim((string)$value);
}
if ($group === 'seo' && $key === 'noindexSite') {
$value = (bool)$value;
}
// Validate logoDisplay (3-way selector)
if ($key === 'logoDisplay' && !in_array($value, ['favicon', 'text', 'both'], true)) {
$value = 'both';
}
// Validate public logo size selector
if ($key === 'logoSize' && !in_array($value, ['small', 'medium', 'large'], true)) {
$value = 'medium';
}
// Validate name
if ($key === 'name') {
$value = trim((string)$value);
if (strlen($value) > 100) {
$value = substr($value, 0, 100);
}
}
// Validate adminLanguage
if ($key === 'adminLanguage') {
$value = trim((string)$value);
if ($value !== '' && !preg_match('/^[a-z]{2,5}$/', $value)) {
jsonResponse(false, null, 'Invalid language code');
}
if ($value !== '' && !is_file(RAIJU_ADMIN_DIR . '/lang/' . $value . '.json')) {
jsonResponse(false, null, 'Language file not found');
}
}
// AI label artwork: English everywhere or German on German pages
if ($key === 'aiLabelArtwork' && !in_array($value, ['en', 'page'], true)) {
$value = 'en';
}
// Validate boolean
if ($key === 'showBranding') {
$value = (bool)$value;
}
// Validate email settings
if ($group === 'email') {
if ($key === 'method' && !in_array($value, ['smtp', 'sendmail', 'inactive'])) {
$value = 'smtp';
}
if (in_array($key, ['recipientEmail', 'bccEmail'], true)) {
if (!raijuValidateEmailList($value)) {
jsonResponse(false, null, 'Invalid email address list for ' . $key);
}
$value = raijuNormalizeEmailList($value);
}
if ($key === 'fromEmail') {
$value = trim((string)$value);
}
if ($key === 'fromEmail' && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
jsonResponse(false, null, 'Invalid email address for ' . $key);
}
if ($key === 'smtpPort') {
$value = max(1, min(65535, intval($value)));
}
if ($key === 'smtpEncryption' && !in_array($value, ['tls', 'ssl', 'none'])) {
$value = 'tls';
}
if (in_array($key, ['smtpHost', 'smtpUsername', 'fromName'])) {
$value = trim((string)$value);
}
// smtpPassword: allow empty (means "keep existing")
if ($key === 'smtpPassword' && $value === '') {
// Load existing password and keep it
if (file_exists(SETTINGS_PATH)) {
$existingSettings = json_decode(file_get_contents(SETTINGS_PATH), true) ?: [];
$existingPw = $existingSettings['email']['smtpPassword'] ?? '';
if ($existingPw !== '') {
$value = $existingPw;
}
}
}
}
$sanitized[$group][$key] = $value;
}
}
}
// Top-level scalars (favicon, PNG fallback)
foreach ($allowedScalar as $scalarKey) {
if (!array_key_exists($scalarKey, $settings)) continue;
$value = (string)$settings[$scalarKey];
if (in_array($scalarKey, ['favicon', 'favicon_png'], true)) {
if ($value !== '' && !$isStored(null, $scalarKey, $value) && (
strpos($value, '..') !== false ||
!str_starts_with($value, '/assets/images/') ||
preg_match('#[:\x00]#', $value)
)) {
jsonResponse(false, null, 'Invalid favicon path');
}
}
$sanitized[$scalarKey] = $value;
}
if (!empty($sanitized['theme']) && is_array($sanitized['theme'])) {
$sanitized['theme'] = raijuSanitizeThemeContrast($sanitized['theme']);
}
$contentDir = dirname(SETTINGS_PATH);
if (!is_dir($contentDir)) {
mkdir($contentDir, 0755, true);
}
// Merge with existing file to preserve non-whitelisted keys (e.g. favicon)
$existing = [];
if (file_exists(SETTINGS_PATH)) {
$existing = json_decode(file_get_contents(SETTINGS_PATH), true) ?: [];
}
$accessInput = isset($settings['access']) ? ['access' => array_replace_recursive($existing['access'] ?? [], $settings['access'])] : [];
// Lists replace as a whole: a recursive merge would keep rows by index
if (isset($settings['access']['maintenance']) && is_array($settings['access']['maintenance']) && array_key_exists('bypassLinks', $settings['access']['maintenance'])) {
$accessInput['access']['maintenance']['bypassLinks'] = $settings['access']['maintenance']['bypassLinks'];
}
$accessPatch = sanitizeAccessSettings($accessInput, $existing);
if ($accessPatch) {
$sanitized['access'] = $accessPatch;
}
$loginInput = isset($settings['login']) ? ['login' => array_replace_recursive($existing['login'] ?? [], $settings['login'])] : [];
$loginPatch = sanitizeLoginVisualSettings($loginInput);
if ($loginPatch) {
$sanitized['login'] = $loginPatch;
}
$privacyPatch = sanitizePrivacySettings($settings);
if ($privacyPatch) {
$sanitized['privacy'] = $privacyPatch;
}
$modulesPatch = sanitizeModuleSettings($settings);
if ($modulesPatch) {
$sanitized['modules'] = $modulesPatch;
}
$dashboardPatch = sanitizeDashboardSettings($settings);
if ($dashboardPatch) {
$sanitized['dashboard'] = $dashboardPatch;
}
$mediaPatch = sanitizeMediaSettings($settings);
if ($mediaPatch) {
$sanitized['media'] = $mediaPatch;
}
$merged = array_replace_recursive($existing, $sanitized);
if (isset($sanitized['access']['maintenance']['bypassLinks'])) {
$merged['access']['maintenance']['bypassLinks'] = $sanitized['access']['maintenance']['bypassLinks'];
}
$result = raijuJsonAtomicWrite(SETTINGS_PATH, $merged);
if ($result === false) {
jsonResponse(false, null, 'Error saving settings');
}
$publicMerged = $merged;
$publicMerged['access']['maintenance']['hasBypassKey'] = !empty($publicMerged['access']['maintenance']['bypassKeyHash']);
unset($publicMerged['access']['maintenance']['bypassKeyHash']);
jsonResponse(true, $publicMerged, 'Settings saved');
break;
case 'test-email':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$testConfig = json_decode($_POST['emailConfig'] ?? '{}', true);
if (!$testConfig || empty($testConfig['recipientEmail'])) {
jsonResponse(false, null, 'Recipient email is required');
}
if (!raijuValidateEmailList($testConfig['recipientEmail'] ?? '')) {
jsonResponse(false, null, 'Invalid email address list for recipientEmail');
}
if (!raijuValidateEmailList($testConfig['bccEmail'] ?? '')) {
jsonResponse(false, null, 'Invalid email address list for bccEmail');
}
$testToRecipients = raijuParseEmailList($testConfig['recipientEmail'] ?? '');
$testBccRecipients = raijuParseEmailList($testConfig['bccEmail'] ?? '');
if (empty($testToRecipients)) {
jsonResponse(false, null, 'Recipient email is required');
}
$testTo = implode(', ', $testToRecipients);
$testFrom = trim((string)($testConfig['fromEmail'] ?? '')) ?: $testToRecipients[0];
if (!filter_var($testFrom, FILTER_VALIDATE_EMAIL)) {
jsonResponse(false, null, 'Invalid email address for fromEmail');
}
$testFromName = $testConfig['fromName'] ?: 'raiju CMS';
$testSubject = 'raiju CMS — Test Email';
$testBody = "This is a test email from raiju CMS.\n\nIf you can read this, your email settings are working correctly.\n\nTimestamp: " . date('Y-m-d H:i:s');
$testMethod = $testConfig['method'] ?? 'smtp';
$testSent = false;
$testError = '';
if ($testMethod === 'smtp') {
require_once RAIJU_ADMIN_DIR . '/../api/SmtpMailer.php';
$mailer = new SmtpMailer(
$testConfig['smtpHost'] ?? '',
intval($testConfig['smtpPort'] ?? 587),
$testConfig['smtpUsername'] ?? '',
$testConfig['smtpPassword'] ?? '',
$testConfig['smtpEncryption'] ?? 'tls'
);
// If password is empty, try to load from saved settings
if (empty($testConfig['smtpPassword']) && defined('SETTINGS_PATH') && file_exists(SETTINGS_PATH)) {
$savedSettings = json_decode(file_get_contents(SETTINGS_PATH), true) ?: [];
$savedPw = $savedSettings['email']['smtpPassword'] ?? '';
if ($savedPw) {
$mailer = new SmtpMailer(
$testConfig['smtpHost'] ?? '',
intval($testConfig['smtpPort'] ?? 587),
$testConfig['smtpUsername'] ?? '',
$savedPw,
$testConfig['smtpEncryption'] ?? 'tls'
);
}
}
$testSent = $mailer->send($testToRecipients, $testSubject, $testBody, $testFrom, $testFromName, '', $testBccRecipients);
if (!$testSent) {
$testError = $mailer->getLastError();
}
} elseif ($testMethod === 'sendmail') {
$headers = [];
$headers[] = 'From: ' . ($testFromName ? "=?UTF-8?B?" . base64_encode($testFromName) . "?= <$testFrom>" : $testFrom);
$headers[] = 'Content-Type: text/plain; charset=UTF-8';
$headers[] = 'X-Mailer: raiju CMS';
$testSent = @mail($testTo, '=?UTF-8?B?' . base64_encode($testSubject) . '?=', $testBody, implode("\r\n", $headers));
foreach ($testBccRecipients as $bccRecipient) {
$testSent = @mail($bccRecipient, '=?UTF-8?B?' . base64_encode($testSubject) . '?=', $testBody, implode("\r\n", $headers)) && $testSent;
}
if (!$testSent) {
$testError = 'PHP mail() returned false. Check server mail configuration.';
}
}
if ($testSent) {
jsonResponse(true, null, 'Test email sent successfully');
} else {
jsonResponse(false, null, $testError ?: 'Failed to send test email');
}
break;
case 'total-reset':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$confirm = $_POST['confirm'] ?? '';
if ($confirm !== 'DELETE') {
jsonResponse(false, null, 'Confirmation mismatch');
}
$root = dirname(RAIJU_ADMIN_DIR);
// Collect language directories from config
$langDirs = [];
if (isset($SITE_LANGUAGES) && is_array($SITE_LANGUAGES)) {
foreach (array_keys($SITE_LANGUAGES) as $code) {
$dir = $root . '/' . $code;
if (is_dir($dir)) {
$langDirs[] = $dir;
}
}
}
// Recursive directory delete helper
$rmdir = function($dir) use (&$rmdir) {
if (!is_dir($dir)) return;
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
if (is_dir($path)) {
$rmdir($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
};
// Delete content directory (pages, news, settings, events)
$rmdir($root . '/content');
// Delete language directories
foreach ($langDirs as $dir) {
$rmdir($dir);
}
// Delete backups
$rmdir($root . '/backups');
// Delete user-uploaded images (but keep favicon.svg)
$imagesDir = $root . '/assets/images';
if (is_dir($imagesDir)) {
$items = scandir($imagesDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..' || $item === 'favicon.svg') continue;
$path = $imagesDir . '/' . $item;
if (is_dir($path)) {
$rmdir($path);
} else {
@unlink($path);
}
}
}
// Delete trash directories
$rmdir($root . '/assets/images-trash');
$rmdir($root . '/assets/audio-trash');
// Clean audio directory (keep directory, remove files)
$audioDir = $root . '/assets/audio';
if (is_dir($audioDir)) {
$items = scandir($audioDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $audioDir . '/' . $item;
if (is_dir($path)) {
$rmdir($path);
} else {
@unlink($path);
}
}
}
// Delete nav-config.php
@unlink($root . '/includes/nav-config.php');
// Delete config.php (must be last — triggers setup wizard)
@unlink(RAIJU_ADMIN_DIR . '/config.php');
// Destroy session
session_destroy();
jsonResponse(true, null, 'Installation reset');
break;
// ─── Site Backup ────────────────────────────────────────────────
}

242
admin/api/users.php Normal file
View File

@ -0,0 +1,242 @@
<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'change-password':
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$currentPassword = $_POST['current_password'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
$newPasswordConfirm = $_POST['new_password_confirm'] ?? '';
if (empty($currentPassword) || empty($newPassword) || empty($newPasswordConfirm)) {
jsonResponse(false, null, 'All fields are required');
}
$userId = $_SESSION['admin_user_id'] ?? '';
$currentUser = findUserById($userId);
if (!$currentUser) {
jsonResponse(false, null, 'User not found');
}
if (!password_verify($currentPassword, $currentUser['passwordHash'])) {
jsonResponse(false, null, 'Current password is incorrect');
}
if ($newPassword !== $newPasswordConfirm) {
jsonResponse(false, null, 'New passwords do not match');
}
if ($currentPassword === $newPassword) {
jsonResponse(false, null, 'New password must be different from current password');
}
// Password strength check
if (strlen($newPassword) < 8 ||
!preg_match('/[A-Z]/', $newPassword) ||
!preg_match('/[a-z]/', $newPassword) ||
!preg_match('/[0-9]/', $newPassword) ||
!preg_match('/[^A-Za-z0-9]/', $newPassword)) {
jsonResponse(false, null, 'Password does not meet requirements: at least 8 characters with uppercase, lowercase, digits, and special characters');
}
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
if (!updateUserPassword($userId, $newHash)) {
jsonResponse(false, null, 'Could not update password');
}
// Keep this session valid; other sessions must authenticate again.
$_SESSION['admin_password_fingerprint'] = hash('sha256', $newHash);
session_regenerate_id(true);
// Clear password warning
unset($_SESSION['password_warning']);
jsonResponse(true, null, 'Password changed successfully');
break;
// ============================================================
// SETTINGS
// ============================================================
case 'list-users':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
jsonResponse(true, getUsersForApi());
break;
case 'create-user':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$newUsername = trim($_POST['username'] ?? '');
$newEmail = trim($_POST['email'] ?? '');
$newRole = $_POST['role'] ?? 'editor';
$newPw = $_POST['password'] ?? '';
if (empty($newUsername) || strlen($newUsername) < 3) {
jsonResponse(false, null, 'Username must be at least 3 characters');
}
if (findUserByUsername($newUsername)) {
jsonResponse(false, null, 'Username already exists');
}
if (!empty($newEmail) && findUserByEmail($newEmail)) {
jsonResponse(false, null, 'Email already in use');
}
if (empty($newPw)) {
jsonResponse(false, null, 'Password is required');
}
if (strlen($newPw) < 8 ||
!preg_match('/[A-Z]/', $newPw) ||
!preg_match('/[a-z]/', $newPw) ||
!preg_match('/[0-9]/', $newPw) ||
!preg_match('/[^A-Za-z0-9]/', $newPw)) {
jsonResponse(false, null, 'Password does not meet requirements');
}
$createdBy = $_SESSION['admin_username'] ?? 'admin';
try {
$newUser = createUser($newUsername, $newEmail, $newPw, $newRole, $createdBy);
} catch (RuntimeException $error) {
jsonResponse(false, null, $error->getMessage());
}
jsonResponse(true, [
'id' => $newUser['id'],
'username' => $newUser['username'],
'email' => $newUser['email'],
'role' => $newUser['role'],
], 'User created');
break;
case 'update-user':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$editUserId = $_POST['user_id'] ?? '';
$editUser = findUserById($editUserId);
if (!$editUser) {
jsonResponse(false, null, 'User not found');
}
$fields = [];
if (isset($_POST['username'])) {
$uname = trim($_POST['username']);
if (strlen($uname) < 3) {
jsonResponse(false, null, 'Username must be at least 3 characters');
}
$existing = findUserByUsername($uname);
if ($existing && $existing['id'] !== $editUserId) {
jsonResponse(false, null, 'Username already exists');
}
$fields['username'] = $uname;
}
if (isset($_POST['email'])) {
$uemail = trim($_POST['email']);
if (!empty($uemail)) {
$existing = findUserByEmail($uemail);
if ($existing && $existing['id'] !== $editUserId) {
jsonResponse(false, null, 'Email already in use');
}
}
$fields['email'] = $uemail;
}
if (isset($_POST['role'])) {
$newRole = $_POST['role'];
if (!in_array($newRole, ['admin', 'editor'])) {
jsonResponse(false, null, 'Invalid role');
}
// Prevent demoting the last admin
if ($editUser['role'] === 'admin' && $newRole === 'editor' && countUsersByRole('admin') <= 1) {
jsonResponse(false, null, 'Cannot demote the last admin');
}
$fields['role'] = $newRole;
}
if (!empty($fields)) {
if (!updateUser($editUserId, $fields)) jsonResponse(false, null, 'Could not update user');
// Update session if editing self
if ($editUserId === ($_SESSION['admin_user_id'] ?? '')) {
if (isset($fields['username'])) $_SESSION['admin_username'] = $fields['username'];
if (isset($fields['role'])) $_SESSION['admin_role'] = $fields['role'];
}
}
jsonResponse(true, null, 'User updated');
break;
case 'delete-user':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$delUserId = $_POST['user_id'] ?? '';
if ($delUserId === ($_SESSION['admin_user_id'] ?? '')) {
jsonResponse(false, null, 'Cannot delete yourself');
}
$delUser = findUserById($delUserId);
if (!$delUser) {
jsonResponse(false, null, 'User not found');
}
if ($delUser['role'] === 'admin' && countUsersByRole('admin') <= 1) {
jsonResponse(false, null, 'Cannot delete the last admin');
}
if (!deleteUser($delUserId)) jsonResponse(false, null, 'Could not delete user');
jsonResponse(true, null, 'User deleted');
break;
case 'admin-reset-password':
if (!isAdmin()) {
jsonResponse(false, null, 'Forbidden');
}
if (!validateCsrfToken()) {
jsonResponse(false, null, 'Invalid CSRF token');
}
$resetUserId = $_POST['user_id'] ?? '';
$resetNewPw = $_POST['password'] ?? '';
$resetUser = findUserById($resetUserId);
if (!$resetUser) {
jsonResponse(false, null, 'User not found');
}
if (empty($resetNewPw)) {
jsonResponse(false, null, 'Password is required');
}
if (strlen($resetNewPw) < 8 ||
!preg_match('/[A-Z]/', $resetNewPw) ||
!preg_match('/[a-z]/', $resetNewPw) ||
!preg_match('/[0-9]/', $resetNewPw) ||
!preg_match('/[^A-Za-z0-9]/', $resetNewPw)) {
jsonResponse(false, null, 'Password does not meet requirements');
}
$resetHash = password_hash($resetNewPw, PASSWORD_DEFAULT);
if (!updateUserPassword($resetUserId, $resetHash)) jsonResponse(false, null, 'Could not save password');
jsonResponse(true, null, 'Password reset successfully');
break;
// ============================================================
// MENU ORDER
// ============================================================
}

73
admin/config.example.php Normal file
View File

@ -0,0 +1,73 @@
<?php
/**
* Raiju CMS - Configuration
* Copy this file to config.php and adjust the values for your site.
* User accounts are managed in content/users.json (created by the setup wizard).
*/
// Default favicon path used as fallback by the admin UI before settings.json
// values are applied. Override in settings.json -> "favicon".
define('RAIJU_DEFAULT_FAVICON', '/assets/images/favicon.svg');
// ============================================================
// SITE SETTINGS
// ============================================================
define('SITE_NAME', 'My Website');
// Optional stable identifier for backup filenames and remote folders.
// If omitted, Raiju derives it from the request host or hosting path.
// define('RAIJU_SITE_DOMAIN', 'example.com');
// Development login. Existing admin users can log in with password "dev" only
// from loopback hosts (localhost, 127.0.0.1, ::1). This is ignored on non-local
// hosts even if accidentally left enabled. Set false to disable it locally too.
define('RAIJU_DEV_LOGIN', true);
// ============================================================
// LANGUAGES
// ============================================================
// Primary language: pages live at root (no URL prefix).
// Additional languages: pages live under /{code}/ (e.g. /de/).
// ISO 639-1 codes: de, en, fr, es, it, pt, nl, etc.
define('SITE_LANG_DEFAULT', 'en');
$SITE_LANGUAGES = [
'en' => 'English',
'de' => 'Deutsch',
'es' => 'Español',
];
// ============================================================
// PATHS (relative to webroot)
// ============================================================
define('CONTENT_PATH', __DIR__ . '/../content/pages/');
define('PAGES_TRASH_PATH', __DIR__ . '/../content/pages-trash/');
define('BACKUP_PATH', __DIR__ . '/../backups/');
define('EVENTS_PATH', __DIR__ . '/../content/events.json');
define('IMAGES_PATH', __DIR__ . '/../assets/images/');
define('IMAGES_TRASH_PATH', __DIR__ . '/../assets/images-trash/');
define('AUDIO_PATH', __DIR__ . '/../assets/audio/');
define('AUDIO_TRASH_PATH', __DIR__ . '/../assets/audio-trash/');
define('VIDEO_PATH', __DIR__ . '/../assets/videos/');
define('VIDEO_TRASH_PATH', __DIR__ . '/../assets/videos-trash/');
define('DOCUMENTS_PATH', __DIR__ . '/../assets/documents/');
define('DOCUMENTS_TRASH_PATH', __DIR__ . '/../assets/documents-trash/');
define('SETTINGS_PATH', __DIR__ . '/../content/settings.json');
define('USERS_PATH', __DIR__ . '/../content/users.json');
// ============================================================
// LIMITS
// ============================================================
define('MAX_BACKUPS', 30);
define('SESSION_LIFETIME', 3600); // 1 hour
// ============================================================
// REMOTE BACKUP OAUTH
// ============================================================
// Public Raiju OAuth broker for Dropbox connect flows. The broker only
// handles OAuth codes/tokens; backup ZIP files upload directly from this
// installation to the selected remote target.
define('RAIJU_AUTH_BROKER_URL', 'https://1studi.ru');

3118
admin/dashboard.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,922 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// ============================================================
// SITE BACKUP
// ============================================================
(function() {
var btn = document.getElementById('createSiteBackupBtn');
var progress = document.getElementById('backupProgress');
var progressText = document.getElementById('backupProgressText');
btn.addEventListener('click', async function() {
btn.disabled = true;
btn.style.display = 'none';
progress.style.display = 'flex';
try {
var formData = new FormData();
formData.append('action', 'create-site-backup');
formData.append('csrf_token', CSRF_TOKEN);
var response = await fetch('api.php', { method: 'POST', body: formData });
var result = await response.json();
if (result.success) {
progressText.textContent = t('settings.backup_downloading');
var downloadUrl = 'api.php?action=download-site-backup'
+ '&token=' + encodeURIComponent(result.data.token)
+ '&csrf_token=' + encodeURIComponent(CSRF_TOKEN)
+ '&filename=' + encodeURIComponent(result.data.filename);
var a = document.createElement('a');
a.href = downloadUrl;
a.download = result.data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showToast(t('toast.backup_site_created'), 'success');
} else {
showToast(t('toast.backup_site_failed', {message: result.message}), 'error');
}
} catch (error) {
showToast(t('toast.backup_site_failed', {message: error.message}), 'error');
}
setTimeout(function() {
btn.disabled = false;
btn.style.display = '';
progress.style.display = 'none';
progressText.textContent = t('settings.backup_creating');
}, 2000);
});
})();
// ============================================================
// RESTORE FROM BACKUP
// ============================================================
(function() {
var fileInput = document.getElementById('restoreFileInput');
var selectBtn = document.getElementById('restoreSelectBtn');
var filenameEl = document.getElementById('restoreFilename');
var modeSelector = document.getElementById('restoreModeSelector');
var actionsEl = document.getElementById('restoreActions');
var restoreBtn = document.getElementById('restoreBtn');
var progress = document.getElementById('restoreProgress');
var progressText = document.getElementById('restoreProgressText');
selectBtn.addEventListener('click', function() {
fileInput.click();
});
fileInput.addEventListener('change', function() {
var file = fileInput.files[0];
if (file) {
filenameEl.textContent = file.name + ' (' + (file.size / 1024 / 1024).toFixed(1) + ' MB)';
filenameEl.style.display = '';
selectBtn.querySelector('span').textContent = t('settings.restore_change_file');
modeSelector.style.display = '';
actionsEl.style.display = '';
} else {
filenameEl.style.display = 'none';
selectBtn.querySelector('span').textContent = t('settings.restore_select_file');
modeSelector.style.display = 'none';
actionsEl.style.display = 'none';
}
});
restoreBtn.addEventListener('click', function() {
var file = fileInput.files[0];
if (!file) return;
var mode = document.querySelector('input[name="restore_mode"]:checked').value;
var warningKey = mode === 'full' ? 'settings.restore_warning_full' : 'settings.restore_warning_content';
showModal(t('settings.restore_title'), t(warningKey), async function() {
closeModal();
restoreBtn.disabled = true;
restoreBtn.style.display = 'none';
progress.style.display = 'flex';
try {
var formData = new FormData();
formData.append('action', 'restore-site-backup');
formData.append('csrf_token', CSRF_TOKEN);
formData.append('restore_mode', mode);
formData.append('backup_zip', file);
var response = await fetch('api.php', { method: 'POST', body: formData });
var result = await response.json();
if (result.success) {
var toastKey = mode === 'full' ? 'toast.restore_success_full' : 'toast.restore_success_content';
showToast(t(toastKey, {extracted: result.data.extracted}), 'success');
setTimeout(function() { location.reload(); }, 2000);
} else {
showToast(t('toast.restore_failed', {message: result.message}), 'error');
restoreBtn.disabled = false;
restoreBtn.style.display = '';
progress.style.display = 'none';
}
} catch (error) {
showToast(t('toast.restore_failed', {message: error.message}), 'error');
restoreBtn.disabled = false;
restoreBtn.style.display = '';
progress.style.display = 'none';
}
});
});
})();
// ============================================================
// SCHEDULED / AUTOMATED BACKUPS
// ============================================================
(function() {
var statusEl = document.getElementById('scheduledStatus');
var lastRunEl = document.getElementById('scheduledLastRun');
var statusMsgEl = document.getElementById('scheduledStatusMessage');
var storageCountEl = document.getElementById('scheduledStorageCount');
var storageDatesEl = document.getElementById('scheduledStorageDates');
var form = document.getElementById('scheduledBackupForm');
var enabledEl = document.getElementById('scheduledEnabled');
var cronModeEls = Array.from(document.querySelectorAll('input[name="scheduledCronMode"]'));
var webCronUrlEl = document.getElementById('webCronUrl');
var dailyEl = document.getElementById('retentionDaily');
var weeklyEl = document.getElementById('retentionWeekly');
var monthlyEl = document.getElementById('retentionMonthly');
var yearlyEl = document.getElementById('retentionYearly');
var limitEl = document.getElementById('storageLimitMb');
var saveBtn = document.getElementById('scheduledSaveBtn');
var listBody = document.getElementById('scheduledListBody');
var cronCopyBtns = Array.from(document.querySelectorAll('[data-copy-code]'));
var remoteListEl = document.getElementById('remoteTargetList');
var remoteAddBtn = document.getElementById('remoteAddBtn');
var remoteSelectEl = document.getElementById('remoteProviderSelect');
var remoteTargets = [];
var remoteProviders = {};
var remoteFileCache = {};
if (!statusEl) return; // Tab not on this dashboard
var remoteFieldLabels = {
app_key: t('settings.remote_field_app_key'),
access_token: t('settings.remote_field_access_token'),
refresh_token: t('settings.remote_field_refresh_token'),
client_id: t('settings.remote_field_client_id'),
client_secret: t('settings.remote_field_client_secret'),
path: t('settings.remote_field_path'),
folder_id: t('settings.remote_field_folder_id'),
folder_path: t('settings.remote_field_folder_path'),
host: t('settings.remote_field_host'),
port: t('settings.remote_field_port'),
username: t('settings.remote_field_username'),
password: t('settings.remote_field_password'),
remote_path: t('settings.remote_field_remote_path'),
ssl: t('settings.remote_field_ssl'),
passive: t('settings.remote_field_passive'),
endpoint: t('settings.remote_field_endpoint'),
region: t('settings.remote_field_region'),
bucket: t('settings.remote_field_bucket'),
prefix: t('settings.remote_field_prefix'),
access_key: t('settings.remote_field_access_key'),
secret_key: t('settings.remote_field_secret_key'),
path_style: t('settings.remote_field_path_style'),
url: t('settings.remote_field_url'),
bearer_token: t('settings.remote_field_bearer_token')
};
var remotePlaceholders = {
dropbox: { app_key: 'Dropbox app key', path: '/raiju Backups' },
google_drive: { client_id: 'Google OAuth client ID', client_secret: t('settings.remote_placeholder_optional'), folder_id: t('settings.remote_placeholder_optional') },
onedrive: { client_id: 'Microsoft app client ID', client_secret: t('settings.remote_placeholder_optional'), folder_path: '/raiju Backups' },
sftp: { port: '22', remote_path: '/home/user/backups' },
ftp: { port: '21', remote_path: 'backups', ssl: '0', passive: '1' },
s3: { endpoint: 'https://s3.example.com', region: 'eu-central-1', prefix: 'raiju', path_style: '0' },
webdav: { url: 'https://cloud.example.com/remote.php/dav/files/user/backups' }
};
var remoteProviderHints = {
sftp: t('settings.remote_hint_sftp'),
ftp: t('settings.remote_hint_ftp')
};
var remoteDefaultSettings = {
sftp: { port: '22' },
ftp: { port: '21', passive: true }
};
function fmtSize(bytes) {
if (!bytes) return '0 B';
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
}
function fmtDate(unixOrIso) {
if (!unixOrIso) return '—';
var d = (typeof unixOrIso === 'number') ? new Date(unixOrIso * 1000) : new Date(unixOrIso);
if (isNaN(d.getTime())) return '—';
return rjFormatDate(d, 'datetime');
}
function selectedCronMode() {
var checked = cronModeEls.find(function(input) { return input.checked; });
return checked ? checked.value : 'server';
}
function setCronMode(mode) {
cronModeEls.forEach(function(input) {
input.checked = input.value === (mode === 'web' ? 'web' : 'server');
});
}
function webCronUrl(token) {
if (!token) return '—';
var url = new URL('../api/backup-cron.php', window.location.href);
url.searchParams.set('token', token);
return url.toString();
}
async function refresh() {
try {
var res = await fetch('api.php?action=backup-status');
var json = await res.json();
if (!json.success) throw new Error(json.message);
var s = json.data;
// Form fields
enabledEl.checked = !!s.enabled;
setCronMode(s.cron_mode || 'server');
if (webCronUrlEl) webCronUrlEl.textContent = webCronUrl(s.web_cron_token || '');
dailyEl.value = s.retention.daily;
weeklyEl.value = s.retention.weekly;
monthlyEl.value = s.retention.monthly;
yearlyEl.value = s.retention.yearly;
limitEl.value = s.storage_limit_mb;
remoteTargets = s.remote_targets || [];
remoteProviders = s.remote_providers || {};
renderRemoteTargets();
// Last run + cron health
if (s.last_run) {
lastRunEl.textContent = fmtDate(s.last_run);
var ageMs = Date.now() - new Date(s.last_run).getTime();
var ageDays = Math.floor(ageMs / 86400000);
if (s.last_status === 'error') {
statusEl.dataset.state = 'error';
statusMsgEl.textContent = t('settings.last_run_error', { message: s.last_message || '' });
} else if (s.enabled && ageDays >= 2) {
statusEl.dataset.state = 'warning';
statusMsgEl.textContent = t('settings.last_run_warning', { days: ageDays });
} else {
statusEl.dataset.state = 'ok';
statusMsgEl.textContent = t('settings.last_run_ok');
}
} else {
lastRunEl.textContent = t('settings.last_run_never');
if (s.enabled) {
statusEl.dataset.state = 'warning';
statusMsgEl.textContent = t('settings.last_run_warning', { days: '∞' });
} else {
statusEl.dataset.state = 'idle';
statusMsgEl.textContent = '';
}
}
// Storage summary
storageCountEl.textContent = t('settings.storage_count', {
count: s.count, size: fmtSize(s.total_bytes)
});
if (s.oldest && s.newest && s.count > 0) {
storageDatesEl.textContent = ' · '
+ t('settings.storage_oldest', { date: fmtDate(s.oldest) })
+ ' · '
+ t('settings.storage_newest', { date: fmtDate(s.newest) });
} else {
storageDatesEl.textContent = '';
}
// Backup list
var listRes = await fetch('api.php?action=backup-list');
var listJson = await listRes.json();
if (!listJson.success) throw new Error(listJson.message);
var backups = listJson.data.backups || [];
renderList(backups);
} catch (err) {
statusEl.dataset.state = 'error';
statusMsgEl.textContent = err.message || String(err);
}
}
function renderList(backups) {
if (backups.length === 0) {
listBody.innerHTML = '<tr><td colspan="4" class="scheduled-list__empty">'
+ t('settings.backup_list_empty') + '</td></tr>';
return;
}
listBody.innerHTML = '';
backups.forEach(function(b) {
var tr = document.createElement('tr');
var tdDate = document.createElement('td');
tdDate.textContent = fmtDate(b.mtime);
tr.appendChild(tdDate);
var tdTier = document.createElement('td');
var tierBadge = document.createElement('span');
tierBadge.className = 'scheduled-tier scheduled-tier--' + b.tier;
tierBadge.textContent = b.tier;
tdTier.appendChild(tierBadge);
tr.appendChild(tdTier);
var tdSize = document.createElement('td');
tdSize.textContent = fmtSize(b.size);
tr.appendChild(tdSize);
var tdActions = document.createElement('td');
tdActions.className = 'scheduled-list__actions';
var dlBtn = document.createElement('button');
dlBtn.className = 'btn btn-secondary btn-sm';
dlBtn.textContent = t('settings.backup_download');
dlBtn.onclick = function() { downloadBackup(b.file); };
tdActions.appendChild(dlBtn);
var rsBtn = document.createElement('button');
rsBtn.className = 'btn btn-secondary btn-sm';
rsBtn.textContent = t('settings.backup_restore_from_pool');
rsBtn.onclick = function() { restoreBackup(b.file); };
tdActions.appendChild(rsBtn);
var delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-sm';
delBtn.textContent = t('settings.backup_delete');
delBtn.onclick = function() { deleteBackup(b.file); };
tdActions.appendChild(delBtn);
if (remoteTargets.length > 0) {
var upBtn = document.createElement('button');
upBtn.className = 'btn btn-secondary btn-sm';
upBtn.textContent = t('settings.remote_upload');
upBtn.onclick = function() { uploadBackupRemote(b.file, upBtn); };
tdActions.appendChild(upBtn);
}
tr.appendChild(tdActions);
listBody.appendChild(tr);
});
}
function targetLabel(type) {
return (remoteProviders[type] && remoteProviders[type].label) || type;
}
function localizeRemoteMessage(message) {
var text = message || '';
var map = {
'Dropbox access token is missing.': t('settings.remote_error_dropbox_access_token_missing'),
'Google Drive access token is missing.': t('settings.remote_error_google_access_token_missing'),
'OneDrive access token is missing.': t('settings.remote_error_onedrive_access_token_missing'),
'PHP ssh2 extension is required for SFTP/SCP uploads.': t('settings.remote_error_ssh2_required'),
'PHP FTP extension is required for FTP/FTPS uploads.': t('settings.remote_error_ftp_required')
};
return map[text] || text;
}
function makeTarget(type) {
return {
id: type + '-' + Date.now().toString(36),
type: type,
name: targetLabel(type),
enabled: true,
settings: Object.assign({}, remoteDefaultSettings[type] || {}),
last_upload: null,
last_status: null,
last_message: null,
last_file: null
};
}
function collectRemoteTargets() {
if (!remoteListEl) return [];
return Array.from(remoteListEl.querySelectorAll('.backup-remote-target')).map(function(card) {
var target = {
id: card.dataset.id,
type: card.dataset.type,
name: card.querySelector('[data-field="name"]').value.trim(),
enabled: card.querySelector('[data-field="enabled"]').checked,
settings: {},
last_upload: card.dataset.lastUpload || null,
last_status: card.dataset.lastStatus || null,
last_message: card.dataset.lastMessage || null,
last_file: card.dataset.lastFile || null
};
card.querySelectorAll('[data-setting]').forEach(function(input) {
target.settings[input.dataset.setting] = input.type === 'checkbox' ? input.checked : input.value;
});
return target;
});
}
function renderRemoteTargets() {
if (!remoteListEl) return;
if (!remoteTargets.length) {
remoteListEl.innerHTML = '<div class="backup-remote__empty">' + t('settings.remote_empty') + '</div>';
return;
}
remoteListEl.innerHTML = '';
remoteTargets.forEach(function(target, idx) {
var provider = remoteProviders[target.type] || {};
var oauthProvider = target.type === 'dropbox' || target.type === 'google_drive' || target.type === 'onedrive';
var card = document.createElement('div');
card.className = 'backup-remote-target';
card.dataset.id = target.id;
card.dataset.type = target.type;
card.dataset.lastUpload = target.last_upload || '';
card.dataset.lastStatus = target.last_status || '';
card.dataset.lastMessage = target.last_message || '';
card.dataset.lastFile = target.last_file || '';
var header = document.createElement('div');
header.className = 'backup-remote-target__header';
var title = document.createElement('div');
title.className = 'backup-remote-target__title';
var enabled = document.createElement('input');
enabled.type = 'checkbox';
enabled.checked = !!target.enabled;
enabled.dataset.field = 'enabled';
title.appendChild(enabled);
var name = document.createElement('input');
name.type = 'text';
name.value = target.name || targetLabel(target.type);
name.dataset.field = 'name';
title.appendChild(name);
var badge = document.createElement('span');
badge.textContent = targetLabel(target.type);
title.appendChild(badge);
header.appendChild(title);
var actions = document.createElement('div');
actions.className = 'backup-remote-target__actions';
if (target.type === 'dropbox' || target.type === 'google_drive' || target.type === 'onedrive') {
var connectBtn = document.createElement('button');
connectBtn.type = 'button';
connectBtn.className = 'btn btn-secondary btn-sm';
connectBtn.textContent = target.type === 'dropbox'
? t('settings.remote_connect_dropbox')
: (target.type === 'google_drive' ? t('settings.remote_connect_google') : t('settings.remote_connect_onedrive'));
connectBtn.onclick = function() { connectOAuthTarget(target.id, target.type); };
actions.appendChild(connectBtn);
}
var testBtn = document.createElement('button');
testBtn.type = 'button';
testBtn.className = 'btn btn-secondary btn-sm';
testBtn.textContent = t('settings.remote_test');
testBtn.onclick = function() { testRemoteTarget(target.id); };
actions.appendChild(testBtn);
var filesBtn = document.createElement('button');
filesBtn.type = 'button';
filesBtn.className = 'btn btn-secondary btn-sm';
filesBtn.textContent = t('settings.remote_files');
filesBtn.onclick = function() { loadRemoteFiles(target.id); };
actions.appendChild(filesBtn);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger btn-sm';
removeBtn.textContent = t('btn.delete');
removeBtn.onclick = function() {
remoteTargets.splice(idx, 1);
renderRemoteTargets();
};
actions.appendChild(removeBtn);
header.appendChild(actions);
card.appendChild(header);
var grid = document.createElement('div');
grid.className = 'backup-remote-target__grid';
(provider.fields || []).forEach(function(field) {
if (oauthProvider && (field === 'access_token' || field === 'refresh_token')) return;
if (target.type === 'dropbox' && provider.has_global_oauth && field === 'app_key') return;
if ((target.type === 'google_drive' || target.type === 'onedrive') && provider.has_global_oauth && (field === 'client_id' || field === 'client_secret')) return;
var label = document.createElement('label');
if (field === 'path_style' || field === 'ssl' || field === 'passive') label.className = 'backup-remote-target__check';
var span = document.createElement('span');
span.textContent = remoteFieldLabels[field] || field;
label.appendChild(span);
var input = document.createElement('input');
input.dataset.setting = field;
if (field === 'path_style' || field === 'ssl' || field === 'passive') {
input.type = 'checkbox';
input.checked = !!(target.settings && target.settings[field]);
} else {
input.type = 'text';
input.value = (target.settings && target.settings[field]) || '';
input.placeholder = (remotePlaceholders[target.type] && remotePlaceholders[target.type][field]) || '';
input.name = 'raiju_remote_' + target.type + '_' + field + '_' + target.id;
input.id = 'raiju_remote_' + target.id + '_' + field;
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.setAttribute('data-lpignore', 'true');
input.setAttribute('data-1p-ignore', 'true');
input.setAttribute('data-form-type', 'other');
if ((provider.secret_fields || []).indexOf(field) !== -1) {
input.className = 'backup-remote-target__secret';
}
}
label.appendChild(input);
grid.appendChild(label);
});
card.appendChild(grid);
if (remoteProviderHints[target.type]) {
var hint = document.createElement('p');
hint.className = 'form-hint backup-remote-target__hint';
hint.textContent = remoteProviderHints[target.type];
card.appendChild(hint);
}
var status = document.createElement('div');
status.className = 'backup-remote-target__status';
status.dataset.state = target.last_status || 'idle';
status.textContent = target.last_upload
? t('settings.remote_last_upload', { date: fmtDate(target.last_upload), message: localizeRemoteMessage(target.last_message || '') })
: t('settings.remote_not_tested');
card.appendChild(status);
if (remoteFileCache[target.id]) {
card.appendChild(renderRemoteFileList(target.id, remoteFileCache[target.id]));
}
remoteListEl.appendChild(card);
});
}
function renderRemoteFileList(targetId, files) {
var box = document.createElement('div');
box.className = 'backup-remote-files';
if (!files.length) {
box.textContent = t('settings.remote_files_empty');
return box;
}
files.forEach(function(file) {
var row = document.createElement('div');
row.className = 'backup-remote-file';
var meta = document.createElement('div');
meta.className = 'backup-remote-file__meta';
var name = document.createElement('strong');
name.textContent = file.file;
var details = document.createElement('span');
details.textContent = file.tier + ' · ' + fmtSize(file.size) + ' · ' + fmtDate(file.mtime);
meta.appendChild(name);
meta.appendChild(details);
row.appendChild(meta);
var actions = document.createElement('div');
actions.className = 'backup-remote-file__actions';
var importBtn = document.createElement('button');
importBtn.type = 'button';
importBtn.className = 'btn btn-secondary btn-sm';
importBtn.textContent = t('settings.remote_import');
importBtn.onclick = function() { importRemoteBackup(targetId, file.file); };
actions.appendChild(importBtn);
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn btn-danger btn-sm';
deleteBtn.textContent = t('btn.delete');
deleteBtn.onclick = function() { deleteRemoteBackup(targetId, file.file); };
actions.appendChild(deleteBtn);
row.appendChild(actions);
box.appendChild(row);
});
return box;
}
async function saveRemoteSettingsOnly() {
remoteTargets = collectRemoteTargets();
var fd = new FormData();
fd.append('action', 'backup-update-settings');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('remote_targets', JSON.stringify(remoteTargets));
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (!json.success) throw new Error(json.message);
remoteTargets = json.data.remote_targets || remoteTargets;
remoteProviders = json.data.remote_providers || remoteProviders;
renderRemoteTargets();
}
async function testRemoteTarget(targetId) {
try {
await saveRemoteSettingsOnly();
var fd = new FormData();
fd.append('action', 'backup-test-remote');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('target_id', targetId);
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (!json.success) throw new Error(json.message);
showToast(t('toast.remote_test_success'), 'success');
refresh();
} catch (err) {
showToast(t('toast.remote_test_failed', { message: localizeRemoteMessage(err.message || String(err)) }), 'error');
refresh();
}
}
async function connectOAuthTarget(targetId, type) {
var popup = window.open('about:blank', '_blank');
try {
await saveRemoteSettingsOnly();
var target = remoteTargets.find(function(tg) { return tg.id === targetId; });
var requiredField = type === 'dropbox' ? 'app_key' : 'client_id';
var provider = remoteProviders[type] || {};
if (!target || !target.settings || (!target.settings[requiredField] && !provider.has_global_oauth)) {
if (popup) popup.close();
showToast(t(type === 'dropbox' ? 'toast.remote_dropbox_app_key_missing' : 'toast.remote_client_id_missing'), 'error');
return;
}
var action = type === 'dropbox'
? 'backup-dropbox-oauth-start'
: (type === 'google_drive' ? 'backup-google-oauth-start' : 'backup-onedrive-oauth-start');
var url = 'api.php?action=' + action
+ '&csrf_token=' + encodeURIComponent(CSRF_TOKEN)
+ '&target_id=' + encodeURIComponent(targetId);
if (popup) {
popup.location = url;
} else {
window.location.href = url;
}
} catch (err) {
if (popup) popup.close();
showToast(t('toast.remote_oauth_connect_failed', { message: localizeRemoteMessage(err.message || String(err)) }), 'error');
}
}
async function uploadBackupRemote(file, button) {
var originalHtml = button ? button.innerHTML : '';
var actionButtons = [];
function setBusy(isBusy) {
if (!button) return;
var row = button.closest('tr');
actionButtons = row ? Array.from(row.querySelectorAll('button')) : [button];
actionButtons.forEach(function(btn) { btn.disabled = isBusy; });
button.classList.toggle('is-loading', isBusy);
button.setAttribute('aria-busy', isBusy ? 'true' : 'false');
button.innerHTML = isBusy
? '<span class="btn-spinner" aria-hidden="true"></span><span>' + t('settings.remote_uploading') + '</span>'
: originalHtml;
}
try {
setBusy(true);
await saveRemoteSettingsOnly();
var enabledTargets = remoteTargets.filter(function(tg) { return tg.enabled; });
if (!enabledTargets.length) {
showToast(t('toast.remote_no_enabled_targets'), 'error');
return;
}
var fd = new FormData();
fd.append('action', 'backup-upload-remote');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('file', file);
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (!json.success) throw new Error(json.message);
showToast(t('toast.remote_upload_success'), 'success');
refresh();
} catch (err) {
showToast(t('toast.remote_upload_failed', { message: localizeRemoteMessage(err.message || String(err)) }), 'error');
refresh();
} finally {
setBusy(false);
}
}
async function loadRemoteFiles(targetId) {
try {
var res = await fetch('api.php?action=backup-remote-list&target_id=' + encodeURIComponent(targetId));
var json = await res.json();
if (!json.success) throw new Error(json.message);
remoteFileCache[targetId] = json.data.files || [];
renderRemoteTargets();
} catch (err) {
showToast(t('toast.remote_list_failed', { message: localizeRemoteMessage(err.message || String(err)) }), 'error');
}
}
async function importRemoteBackup(targetId, file) {
try {
var fd = new FormData();
fd.append('action', 'backup-remote-import');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('target_id', targetId);
fd.append('file', file);
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (!json.success) throw new Error(json.message);
showToast(t('toast.remote_import_success'), 'success');
refresh();
} catch (err) {
showToast(t('toast.remote_import_failed', { message: localizeRemoteMessage(err.message || String(err)) }), 'error');
}
}
function deleteRemoteBackup(targetId, file) {
showModal(t('settings.remote_delete'), t('settings.remote_delete_confirm'), async function() {
closeModal();
try {
var fd = new FormData();
fd.append('action', 'backup-remote-delete');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('target_id', targetId);
fd.append('file', file);
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (!json.success) throw new Error(json.message);
showToast(t('toast.remote_delete_success'), 'success');
await loadRemoteFiles(targetId);
} catch (err) {
showToast(t('toast.remote_delete_failed', { message: localizeRemoteMessage(err.message || String(err)) }), 'error');
}
});
}
async function downloadBackup(file) {
try {
var fd = new FormData();
fd.append('action', 'backup-prepare-download');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('file', file);
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (!json.success) throw new Error(json.message);
var url = 'api.php?action=download-site-backup'
+ '&token=' + encodeURIComponent(json.data.token)
+ '&csrf_token=' + encodeURIComponent(CSRF_TOKEN)
+ '&filename=' + encodeURIComponent(json.data.filename);
var a = document.createElement('a');
a.href = url;
a.download = json.data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (err) {
showToast(t('toast.backup_site_failed', { message: err.message || String(err) }), 'error');
}
}
function restoreBackup(file) {
showModal(
t('settings.restore_title'),
'<div class="modal-choice-list">' +
'<label class="modal-choice">' +
'<input type="radio" name="restoreMode" value="content" checked>' +
'<span><strong>' + escapeHtml(t('settings.restore_content')) + '</strong><small>' + escapeHtml(t('settings.restore_content_desc')) + '</small></span>' +
'</label>' +
'<label class="modal-choice">' +
'<input type="radio" name="restoreMode" value="full">' +
'<span><strong>' + escapeHtml(t('settings.restore_full')) + '</strong><small>' + escapeHtml(t('settings.restore_full_desc')) + '</small></span>' +
'</label>' +
'</div>',
function() {
var selected = document.querySelector('input[name="restoreMode"]:checked');
var mode = selected ? selected.value : 'content';
closeModal();
var warningKey = mode === 'full'
? 'settings.backup_restore_pool_warning_full'
: 'settings.backup_restore_pool_warning_content';
showModal(t('settings.restore_title'), t(warningKey), async function() {
closeModal();
try {
var fd = new FormData();
fd.append('action', 'restore-site-backup');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('restore_mode', mode);
fd.append('pool_file', file);
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (json.success) {
var toastKey = mode === 'full' ? 'toast.restore_success_full' : 'toast.restore_success_content';
showToast(t(toastKey, { extracted: json.data.extracted }), 'success');
setTimeout(function() { location.reload(); }, 2000);
} else {
showToast(t('toast.restore_failed', { message: json.message }), 'error');
}
} catch (err) {
showToast(t('toast.restore_failed', { message: err.message || String(err) }), 'error');
}
});
},
{
html: true,
confirmText: t('settings.restore_title'),
confirmClass: 'btn btn-primary'
}
);
}
function deleteBackup(file) {
showModal(t('settings.backup_delete'), t('settings.backup_delete_confirm'), async function() {
closeModal();
try {
var fd = new FormData();
fd.append('action', 'backup-delete');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('file', file);
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (json.success) {
showToast(t('toast.backup_deleted'), 'success');
refresh();
} else {
showToast(t('toast.backup_delete_failed', { message: json.message }), 'error');
}
} catch (err) {
showToast(t('toast.backup_delete_failed', { message: err.message || String(err) }), 'error');
}
});
}
form.addEventListener('submit', async function(e) {
e.preventDefault();
saveBtn.disabled = true;
try {
var fd = new FormData();
fd.append('action', 'backup-update-settings');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('enabled', enabledEl.checked ? 'true' : 'false');
fd.append('cron_mode', selectedCronMode());
fd.append('retention_daily', dailyEl.value);
fd.append('retention_weekly', weeklyEl.value);
fd.append('retention_monthly', monthlyEl.value);
fd.append('retention_yearly', yearlyEl.value);
fd.append('storage_limit_mb', limitEl.value);
remoteTargets = collectRemoteTargets();
fd.append('remote_targets', JSON.stringify(remoteTargets));
var res = await fetch('api.php', { method: 'POST', body: fd });
var json = await res.json();
if (json.success) {
showToast(t('toast.scheduled_backup_settings_saved'), 'success');
refresh();
} else {
showToast(t('toast.scheduled_backup_settings_failed', { message: json.message }), 'error');
}
} catch (err) {
showToast(t('toast.scheduled_backup_settings_failed', { message: err.message || String(err) }), 'error');
} finally {
saveBtn.disabled = false;
}
});
if (remoteAddBtn) {
remoteAddBtn.addEventListener('click', function() {
remoteTargets = collectRemoteTargets();
remoteTargets.push(makeTarget(remoteSelectEl.value));
renderRemoteTargets();
});
}
window.addEventListener('message', function(event) {
if (event.origin === window.location.origin && event.data && event.data.type === 'raiju:backup-oauth') {
refresh();
showToast(t('toast.remote_oauth_connected'), 'success');
}
});
cronCopyBtns.forEach(function(cronCopyBtn) {
cronCopyBtn.addEventListener('click', function() {
var codeEl = document.getElementById(cronCopyBtn.dataset.copyCode);
if (!codeEl) return;
var text = codeEl.textContent;
var done = function() {
var orig = cronCopyBtn.textContent;
cronCopyBtn.textContent = t('settings.cron_copied');
setTimeout(function() { cronCopyBtn.textContent = orig; }, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, done);
} else {
// Fallback: select + execCommand
var range = document.createRange();
range.selectNode(codeEl);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
try { document.execCommand('copy'); done(); } catch (e) { done(); }
window.getSelection().removeAllRanges();
}
});
});
// Refresh whenever the backup tab is shown (lazy: only fetch when visible).
var backupTab = document.getElementById('backupTab');
var observer = new MutationObserver(function() {
if (backupTab.style.display !== 'none') refresh();
});
observer.observe(backupTab, { attributes: true, attributeFilter: ['style'] });
if (backupTab.style.display !== 'none') refresh();
// Refresh after a manual create-site-backup so the new entry shows up.
var createBtn = document.getElementById('createSiteBackupBtn');
if (createBtn) {
createBtn.addEventListener('click', function() { setTimeout(refresh, 3000); });
}
})();

View File

@ -0,0 +1,65 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// Block Type Registry
window.BlockTypeRegistry = <?php
require_once dirname(RAIJU_DASHBOARD_DIR) . '/includes/content-loader.php';
require_once dirname(RAIJU_DASHBOARD_DIR) . '/includes/menu-helpers.php';
echo json_encode(getBlockTypes(), JSON_UNESCAPED_UNICODE);
?>;
// Admin translations for JS
const RJ_LANG = <?php echo json_encode(array_merge(tEditorAll(), tAll()), JSON_UNESCAPED_UNICODE); ?>;
window.RJ_LANG = RJ_LANG;
window.RJ_AI_CATALOG = <?php echo json_encode(raijuAiModelCatalog(), JSON_UNESCAPED_SLASHES); ?>;
window.RJ_AI_IMAGE_SERVICES = <?php echo json_encode(raijuAiImageServicesForUi(), JSON_UNESCAPED_SLASHES); ?>;
window.RJ_AI_CAPS = <?php echo json_encode(raijuAiCapabilitiesForUi(), JSON_UNESCAPED_SLASHES); ?>;
window.RJ_AI_FEATURES_ENABLED = <?php echo json_encode($aiFeaturesEnabled); ?>;
window.RJ_AI_COPILOT_AVAILABLE = <?php echo json_encode($_aiDashboardCopilotAvailable); ?>;
window.RJ_AI_COPILOT_MODE = 'dashboard';
window.RJ_AI_ASSISTANT_LANGUAGE = <?php echo json_encode(function_exists('_nbAdminLang') ? _nbAdminLang() : (defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en')); ?>;
window.RJ_ADMIN_API_URL = 'api.php';
window.RJ_AI_COPILOT_GET_CONTENT_PAGE = function() {
if (typeof currentPage === 'string' && currentPage) return currentPage;
var select = document.getElementById('pageSelect');
return select && select.value ? select.value : '';
};
// Menu registry for Page Settings nav checkboxes
window.RJ_MENUS = <?php echo json_encode(getMenuRegistry()['menus'] ?? [], JSON_UNESCAPED_UNICODE); ?>;
const DASHBOARD_MODULES = <?php echo json_encode($dashboardModules, JSON_UNESCAPED_UNICODE); ?>;
const AI_FEATURES_ENABLED = DASHBOARD_MODULES.ai !== false;
const AI_NOTICE_DISMISSED_KEY = 'raiju.aiUnavailableNotice.dismissed';
const AI_FEATURE_DEFAULTS = {
backendAssistant: true,
seoTextGeneration: true,
imageGeneration: true
};
function t(key, params) {
let s = RJ_LANG[key] || key;
if (params) { for (const [k, v] of Object.entries(params)) { s = s.replace('{' + k + '}', v); } }
return s;
}
function isDashboardModuleEnabled(tab) {
const keyByTab = { news: 'news', events: 'events', mails: 'messages', icons: 'iconManager', ai: 'ai' };
const key = keyByTab[tab];
if (!key) return true;
return DASHBOARD_MODULES[key] !== false;
}
// SVG icon paths (viewBox 0 0 24 24)
const ICONS = {
edit: '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>',
eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
duplicate: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>',
trash: '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14z"/>',
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
back: '<path d="M19 12H5M12 19l-7-7 7-7"/>',
ai: '<path d="M12 2l1.6 5.2L19 9l-5.4 1.8L12 16l-1.6-5.2L5 9l5.4-1.8L12 2z"/><path d="M19 14l.8 2.7 2.2.8-2.2.8L19 21l-.8-2.7-2.2-.8 2.2-.8L19 14z"/>',
};
function icon(name, size = 16, strokeWidth = '1.5') {
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" width="${size}" height="${size}">${ICONS[name]}</svg>`;
}
// Configuration
const CSRF_TOKEN = '<?php echo $csrfToken; ?>';
const USER_ROLE = '<?php echo htmlspecialchars($userRole); ?>';

View File

@ -0,0 +1,688 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// ============================================================
// EVENTS EDITOR
// ============================================================
const SITE_LANGUAGES = <?php echo json_encode($siteLanguages); ?>;
const DEFAULT_LANG = '<?php echo defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'; ?>';
let eventsData = null;
let eventsLoaded = false;
let currentEventIndex = null;
const EVENT_TRANSLATABLE = ['title', 'location', 'description', 'admission'];
function adminLangCodes() {
return Object.keys(SITE_LANGUAGES || {});
}
function adminLangLabel(code) {
return String((SITE_LANGUAGES && SITE_LANGUAGES[code]) || code).toUpperCase();
}
function activeAdminLang(section) {
return section.querySelector('.ce-lang-tab.active')?.dataset.lang || DEFAULT_LANG;
}
function readAdminLangField(fieldEl) {
const input = fieldEl.querySelector('textarea, input:not([type="hidden"]), select');
return input ? input.value || '' : '';
}
function writeAdminLangField(fieldEl, value) {
const input = fieldEl.querySelector('textarea, input:not([type="hidden"]), select');
if (!input) return;
input.value = value == null ? '' : String(value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
async function translateAdminLanguageSection(section, button) {
if (!AI_FEATURES_ENABLED || !section) return;
const sourceLang = activeAdminLang(section);
const targetLangs = adminLangCodes().filter(code => code !== sourceLang);
const fields = {};
section.querySelectorAll('[data-lang="' + CSS.escape(sourceLang) + '"][data-i18n-field]').forEach(fieldEl => {
const value = readAdminLangField(fieldEl);
if (value.trim() !== '') fields[fieldEl.dataset.i18nField] = value;
});
if (!Object.keys(fields).length) {
showToast(t('editor.translate_no_source') || 'No source text to translate.', 'error');
return;
}
const previous = button.textContent;
button.disabled = true;
button.textContent = t('editor.translating') || 'Translating...';
try {
const formData = new FormData();
formData.append('action', 'ai-generate-text');
formData.append('maxOutputTokens', '1800');
formData.append('prompt', [
'Translate these raiju admin editor fields from ' + sourceLang + ' into the requested target languages.',
'Return strict JSON only. Shape: {"translations":{"LANG":{"field":"translated value"}}}.',
'Keep HTML tags if present. Do not add Markdown or explanations.',
'',
JSON.stringify({ sourceLang, targetLangs, fields: fields }, null, 2)
].join('\n'));
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', { method: 'POST', body: formData });
const result = await response.json();
if (!result.success) throw new Error(result.message || t('toast.error'));
let payload;
try {
payload = JSON.parse(String(result.data?.text || '').replace(/^```json\s*|\s*```$/g, '').trim());
} catch (error) {
throw new Error(t('editor.translate_invalid_response') || 'AI returned an unreadable translation.');
}
const translations = payload.translations || payload;
targetLangs.forEach(lang => {
Object.entries(translations[lang] || {}).forEach(([field, value]) => {
const fieldEl = section.querySelector('[data-lang="' + CSS.escape(lang) + '"][data-i18n-field="' + CSS.escape(field) + '"]');
if (fieldEl) writeAdminLangField(fieldEl, value);
});
});
updateAiUsage(result.data ? result.data.limits : null);
showToast(t('editor.translate_done') || 'Translations inserted.', 'success');
} catch (error) {
showToast(error.message, 'error');
} finally {
button.disabled = false;
button.textContent = previous;
}
}
async function loadEventsEditor() {
try {
const response = await fetch('api.php?action=load-events');
const result = await response.json();
if (result.success) {
eventsData = result.data;
renderEventsList();
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_loading_events', {message: error.message}), 'error');
}
}
function renderEventsList() {
const tbody = document.getElementById('eventsListBody');
if (!tbody) return;
tbody.innerHTML = '';
if (eventsData && eventsData.lastModified) {
const d = new Date(eventsData.lastModified);
document.getElementById('eventsLastModified').textContent = t('editor.last_saved', {date: formatDateShort(d)});
} else {
document.getElementById('eventsLastModified').textContent = '';
}
const events = (eventsData && eventsData.events) || [];
if (events.length === 0) {
rjRenderEmptyRow(tbody, 4, t('events.no_events'), { label: t('events.new_event'), onClick: addNewEvent });
renderAdminListFooter('eventsListFooter', 'events', 0, getDashboardPageSize(), renderEventsList, 'eventsListFooterTop');
return;
}
// Sort by date ascending
events.sort((a, b) => (a.date || '').localeCompare(b.date || ''));
const pageSize = getDashboardPageSize();
const paged = pageSlice(events, 'events', pageSize);
renderAdminListFooter('eventsListFooter', 'events', events.length, pageSize, renderEventsList, 'eventsListFooterTop');
const todayStr = new Date().toISOString().split('T')[0];
paged.items.forEach((event) => {
const index = events.indexOf(event);
const title = event.title?.[DEFAULT_LANG] || event.title?.en || event.title?.de || t('events.untitled');
const dateStr = event.date || '';
const endDateStr = event['end-date'] || dateStr;
// Status — upcoming / today / past
let statusKey, statusClass;
if (!dateStr) {
statusKey = 'events.status_draft';
statusClass = 'events-status events-status--draft';
} else if (dateStr === todayStr || (dateStr <= todayStr && endDateStr >= todayStr)) {
statusKey = 'events.status_today';
statusClass = 'events-status events-status--today';
} else if (dateStr > todayStr) {
statusKey = 'events.status_upcoming';
statusClass = 'events-status events-status--upcoming';
} else {
statusKey = 'events.status_past';
statusClass = 'events-status events-status--past';
}
const tr = document.createElement('tr');
tr.className = 'page-list-row';
const tdTitle = document.createElement('td');
tdTitle.className = 'page-list-cell-title';
const titleLink = document.createElement('a');
titleLink.href = '#';
titleLink.className = 'page-list-title-link';
titleLink.textContent = title;
titleLink.onclick = (e) => { e.preventDefault(); openEventEditor(index); };
tdTitle.appendChild(titleLink);
// Hover action row
const actions = document.createElement('div');
actions.className = 'page-list-row-actions';
const editLink = document.createElement('a');
editLink.href = '#';
editLink.className = 'page-list-row-action';
editLink.innerHTML = icon('edit', 12, '2') + ' ' + t('pages.edit');
editLink.onclick = (e) => { e.preventDefault(); openEventEditor(index); };
actions.appendChild(editLink);
if (event.url) {
const sep1 = document.createElement('span');
sep1.className = 'page-list-row-action-sep';
sep1.textContent = '|';
actions.appendChild(sep1);
const viewLink = document.createElement('a');
viewLink.href = event.url;
viewLink.target = '_blank';
viewLink.rel = 'noopener';
viewLink.className = 'page-list-row-action';
viewLink.innerHTML = icon('eye', 12, '2') + ' ' + t('pages.view');
actions.appendChild(viewLink);
}
const sep2 = document.createElement('span');
sep2.className = 'page-list-row-action-sep';
sep2.textContent = '|';
actions.appendChild(sep2);
const dupLink = document.createElement('a');
dupLink.href = '#';
dupLink.className = 'page-list-row-action';
dupLink.innerHTML = icon('duplicate', 12, '2') + ' ' + t('pages.duplicate');
dupLink.onclick = (e) => { e.preventDefault(); duplicateEvent(index); };
actions.appendChild(dupLink);
const sep3 = document.createElement('span');
sep3.className = 'page-list-row-action-sep';
sep3.textContent = '|';
actions.appendChild(sep3);
const trashLink = document.createElement('a');
trashLink.href = '#';
trashLink.className = 'page-list-row-action page-list-row-action--danger';
trashLink.innerHTML = icon('trash', 12, '2') + ' ' + t('pages.trash');
trashLink.onclick = (e) => { e.preventDefault(); deleteEventDashboard(index); };
actions.appendChild(trashLink);
tdTitle.appendChild(actions);
const tdDate = document.createElement('td');
tdDate.className = 'page-list-cell-date';
tdDate.textContent = dateStr ? (window.rjFormatDate ? rjFormatDate(dateStr) : dateStr) : '—';
const tdLocation = document.createElement('td');
tdLocation.textContent = event.location?.[DEFAULT_LANG] || event.location?.en || event.location?.de || '';
const tdStatus = document.createElement('td');
tdStatus.className = 'page-list-cell-date';
const statusBadge = document.createElement('span');
statusBadge.className = statusClass;
statusBadge.textContent = t(statusKey);
tdStatus.appendChild(statusBadge);
tr.appendChild(tdTitle);
tr.appendChild(tdDate);
tr.appendChild(tdLocation);
tr.appendChild(tdStatus);
tbody.appendChild(tr);
});
}
function openEventEditor(index) {
currentEventIndex = index;
document.getElementById('eventsListView').style.display = 'none';
document.getElementById('eventsEditorView').style.display = '';
const event = eventsData.events[index];
const title = event.title?.[DEFAULT_LANG] || event.title?.en || t('events.untitled');
document.getElementById('eventEditorTitle').textContent = title;
document.getElementById('eventEditorDeleteBtn').style.display = event.id ? '' : 'none';
const body = document.getElementById('eventEditorBody');
body.innerHTML = '';
renderEventFields(body, event, index);
}
function closeEventEditor() {
document.getElementById('eventsEditorView').style.display = 'none';
document.getElementById('eventsListView').style.display = '';
currentEventIndex = null;
}
function saveCurrentEvent() {
if (currentEventIndex === null) return;
saveEventDashboard(currentEventIndex);
}
function deleteCurrentEvent() {
if (currentEventIndex === null) return;
deleteEventDashboard(currentEventIndex);
}
function duplicateEvent(index) {
const src = eventsData.events[index];
const copy = JSON.parse(JSON.stringify(src));
copy.id = ''; // unsaved → server assigns on save
eventsData.events.push(copy);
renderEventsList();
openEventEditor(eventsData.events.length - 1);
}
function renderEventFields(container, eventObj, index) {
const prefix = `events.${index}`;
// Date/time row
const dateRow = document.createElement('div');
dateRow.className = 'ce-field-row';
dateRow.innerHTML = `
<div class="ce-field"><label class="ce-field-label">${t('events.start_date')}</label>
<input type="date" class="ce-input" data-event-path="${prefix}.date" value="${escapeHtml(eventObj.date || '')}"></div>
<div class="ce-field"><label class="ce-field-label">${t('events.start_time')}</label>
<input type="time" class="ce-input" data-event-path="${prefix}.time" value="${escapeHtml(eventObj.time || '')}"></div>
<div class="ce-field"><label class="ce-field-label">${t('events.end_date')}</label>
<input type="date" class="ce-input" data-event-path="${prefix}.end-date" value="${escapeHtml(eventObj['end-date'] || '')}"></div>
<div class="ce-field"><label class="ce-field-label">${t('events.end_time')}</label>
<input type="time" class="ce-input" data-event-path="${prefix}.end-time" value="${escapeHtml(eventObj['end-time'] || '')}"></div>
`;
container.appendChild(dateRow);
// URL
const urlField = document.createElement('div');
urlField.className = 'ce-field';
urlField.innerHTML = `<label class="ce-field-label">URL</label>
<input type="url" class="ce-input" data-event-path="${prefix}.url" value="${escapeHtml(eventObj.url || '')}">`;
container.appendChild(urlField);
// Image
const imgField = document.createElement('div');
imgField.className = 'ce-field';
imgField.innerHTML = `<label class="ce-field-label">Image</label>
<div class="ce-image-input-row">
<input type="text" class="ce-input" data-event-path="${prefix}.image" value="${escapeHtml(eventObj.image || '')}">
<button type="button" class="btn btn-secondary btn-sm ce-browse-btn">Browse</button>
</div>`;
const eventBrowseBtn = imgField.querySelector('.ce-browse-btn');
const eventImgInput = imgField.querySelector('.ce-input');
eventBrowseBtn.addEventListener('click', function() {
browseImageForField(eventImgInput, null);
});
container.appendChild(imgField);
// Translatable fields — language tabs
const langSection = document.createElement('div');
langSection.className = 'ce-lang-section';
const langCodes = Object.keys(SITE_LANGUAGES);
const isMultiLang = langCodes.length > 1;
const tabsHtml = isMultiLang ? langCodes.map(code => {
const isDefault = code === DEFAULT_LANG;
return `<button type="button" class="ce-lang-tab${isDefault ? ' active' : ''}" role="tab" aria-selected="${isDefault ? 'true' : 'false'}" tabindex="${isDefault ? '0' : '-1'}" data-lang="${code}" data-event-idx="${index}">${escapeHtml(adminLangLabel(code))}${isDefault ? ' ★' : ''}</button>`;
}).join('') : '';
langSection.innerHTML = isMultiLang
? `<div class="ce-lang-header"><div class="ce-lang-tabs" role="tablist" aria-label="${escapeHtml(t('editor.language_tabs') || 'Languages')}">${tabsHtml}</div>${AI_FEATURES_ENABLED ? `<button type="button" class="btn btn-secondary btn-sm ce-lang-translate">${escapeHtml(t('editor.translate_from_active') || 'Translate from active language')}</button>` : ''}</div>`
: '';
langCodes.forEach(code => {
const panel = document.createElement('div');
panel.className = 'ce-lang-panel';
if (code === DEFAULT_LANG) panel.classList.add('active');
panel.setAttribute('role', 'tabpanel');
panel.dataset.lang = code;
panel.dataset.eventIdx = index;
panel.hidden = isMultiLang && code !== DEFAULT_LANG;
EVENT_TRANSLATABLE.forEach(field => {
const val = eventObj[field]?.[code] || '';
const isLong = field === 'description';
const fieldDiv = document.createElement('div');
fieldDiv.className = 'ce-field';
fieldDiv.dataset.lang = code;
fieldDiv.dataset.i18nField = field;
fieldDiv.innerHTML = `<label class="ce-field-label">${field.charAt(0).toUpperCase() + field.slice(1)}</label>`;
if (isLong) {
const ta = document.createElement('textarea');
ta.className = 'ce-textarea';
ta.value = val;
ta.dataset.eventPath = `${prefix}.${field}.${code}`;
ta.rows = 3;
fieldDiv.appendChild(ta);
} else {
const input = document.createElement('input');
input.type = 'text';
input.className = 'ce-input';
input.value = val;
input.dataset.eventPath = `${prefix}.${field}.${code}`;
fieldDiv.appendChild(input);
}
panel.appendChild(fieldDiv);
});
langSection.appendChild(panel);
});
container.appendChild(langSection);
// Tab switching
langSection.querySelectorAll('.ce-lang-tab').forEach((tab, tabIndex) => {
const activateTab = (targetTab, focus = false) => {
langSection.querySelectorAll('.ce-lang-tab').forEach(t => {
const active = t === targetTab;
t.classList.toggle('active', active);
t.setAttribute('aria-selected', active ? 'true' : 'false');
t.tabIndex = active ? 0 : -1;
if (active && focus) t.focus();
});
langSection.querySelectorAll('.ce-lang-panel').forEach(p => {
const active = p.dataset.lang === targetTab.dataset.lang;
p.hidden = !active;
p.classList.toggle('active', active);
});
};
tab.addEventListener('click', () => {
activateTab(tab);
});
tab.addEventListener('keydown', e => {
const tabs = Array.from(langSection.querySelectorAll('.ce-lang-tab'));
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
e.preventDefault();
let next = tabIndex;
if (e.key === 'ArrowLeft') next = tabIndex === 0 ? tabs.length - 1 : tabIndex - 1;
if (e.key === 'ArrowRight') next = tabIndex === tabs.length - 1 ? 0 : tabIndex + 1;
if (e.key === 'Home') next = 0;
if (e.key === 'End') next = tabs.length - 1;
activateTab(tabs[next], true);
});
});
const translateBtn = langSection.querySelector('.ce-lang-translate');
if (translateBtn) {
translateBtn.addEventListener('click', function() {
translateAdminLanguageSection(langSection, translateBtn);
});
}
// Save button is in the editor header, not duplicated inline.
}
function collectEventData(index) {
const prefix = `events.${index}`;
const event = { id: eventsData.events[index].id };
// Scalar fields
['date', 'time', 'end-date', 'end-time', 'url', 'image'].forEach(field => {
const el = document.querySelector(`[data-event-path="${prefix}.${field}"]`);
event[field] = el ? el.value : '';
});
// Translatable fields
const langCodes = Object.keys(SITE_LANGUAGES);
EVENT_TRANSLATABLE.forEach(field => {
event[field] = {};
langCodes.forEach(code => {
const el = document.querySelector(`[data-event-path="${prefix}.${field}.${code}"]`);
event[field][code] = el ? el.value : '';
});
});
return event;
}
async function saveEventDashboard(index) {
const event = collectEventData(index);
if (!event.date || !event.title[DEFAULT_LANG]) {
showToast(t('toast.events_date_required'), 'error');
return;
}
try {
const formData = new FormData();
formData.append('action', 'save-event');
formData.append('event', JSON.stringify(event));
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
showToast(result.message || t('toast.events_saved'), 'success');
// Reload data, then return to list view
const reload = await fetch('api.php?action=load-events').then(r => r.json());
if (reload.success) eventsData = reload.data;
closeEventEditor();
renderEventsList();
} else {
showToast(t('toast.error_generic', {message: result.message}), 'error');
}
} catch (error) {
showToast(t('toast.error_saving', {message: error.message}), 'error');
}
}
function addNewEvent() {
const langCodes = Object.keys(SITE_LANGUAGES);
const newEvent = {
id: '',
date: new Date().toISOString().split('T')[0],
time: '',
'end-date': '',
'end-time': '',
url: '',
image: ''
};
EVENT_TRANSLATABLE.forEach(field => {
newEvent[field] = {};
langCodes.forEach(code => { newEvent[field][code] = ''; });
});
if (!eventsData) eventsData = { events: [], lastModified: null };
eventsData.events.push(newEvent);
renderEventsList();
// Open the new event in the editor view immediately
openEventEditor(eventsData.events.length - 1);
}
// Move event to trash (no confirm — symmetrical to Pages "move to trash")
function deleteEventDashboard(index) {
const event = eventsData.events[index];
if (!event) return;
if (!event.id) {
// New unsaved event, just remove from array
eventsData.events.splice(index, 1);
if (currentEventIndex !== null) closeEventEditor();
renderEventsList();
return;
}
const formData = new FormData();
formData.append('action', 'delete-event');
formData.append('id', event.id);
formData.append('csrf_token', CSRF_TOKEN);
fetch('api.php', { method: 'POST', body: formData })
.then(r => r.json())
.then(result => {
if (result.success) {
showToast(t('toast.event_trashed'), 'success');
if (currentEventIndex !== null) closeEventEditor();
loadEventsEditor();
} else {
showToast(t('toast.error_generic', {message: result.message}), 'error');
}
})
.catch(error => showToast(t('toast.error_generic', {message: error.message}), 'error'));
}
// ===== EVENTS TRASH =====
async function showEventsTrash() {
document.getElementById('eventsListView').style.display = 'none';
document.getElementById('eventsEditorView').style.display = 'none';
document.getElementById('eventsTrashView').style.display = '';
await loadEventsTrash();
}
function closeEventsTrash() {
document.getElementById('eventsTrashView').style.display = 'none';
document.getElementById('eventsListView').style.display = '';
// Reload main list — restore/permanent-delete may have changed the event set
loadEventsEditor();
}
async function loadEventsTrash() {
try {
const response = await fetch('api.php?action=list-events-trash&_=' + Date.now());
const result = await response.json();
if (result.success) {
renderEventsTrash(result.data);
}
} catch (error) {
console.error('Error loading events trash:', error);
}
}
function renderEventsTrash(items) {
const tbody = document.getElementById('eventsTrashBody');
const emptyMsg = document.getElementById('eventsTrashEmptyMsg');
const emptyBtn = document.getElementById('emptyEventsTrashBtn');
const table = document.getElementById('eventsTrashTable');
tbody.innerHTML = '';
if (!items || items.length === 0) {
table.style.display = 'none';
emptyMsg.style.display = 'block';
emptyBtn.style.display = 'none';
renderAdminListFooter('eventsTrashFooter', 'eventsTrash', 0, getDashboardPageSize(), function() {
renderEventsTrash(items);
}, 'eventsTrashFooterTop');
return;
}
table.style.display = '';
emptyMsg.style.display = 'none';
emptyBtn.style.display = '';
const pageSize = getDashboardPageSize();
const paged = pageSlice(items, 'eventsTrash', pageSize);
renderAdminListFooter('eventsTrashFooter', 'eventsTrash', items.length, pageSize, function() {
renderEventsTrash(items);
}, 'eventsTrashFooterTop');
paged.items.forEach(item => {
const ev = item.event || {};
const title = ev.title?.[DEFAULT_LANG] || ev.title?.en || ev.title?.de || t('events.untitled');
const tr = document.createElement('tr');
const tdTitle = document.createElement('td');
tdTitle.className = 'page-list-cell-title';
tdTitle.textContent = title;
tr.appendChild(tdTitle);
const tdDate = document.createElement('td');
tdDate.className = 'page-list-cell-date';
tdDate.textContent = ev.date || '';
tr.appendChild(tdDate);
const tdDeletedAt = document.createElement('td');
tdDeletedAt.className = 'page-list-cell-date';
if (item.deletedAt) {
const d = new Date(item.deletedAt);
tdDeletedAt.textContent = rjFormatDate(d, 'datetime');
}
tr.appendChild(tdDeletedAt);
const tdActions = document.createElement('td');
tdActions.className = 'page-list-cell-actions';
const restoreBtn = document.createElement('button');
restoreBtn.className = 'btn btn-primary btn-sm';
restoreBtn.textContent = t('btn.restore');
restoreBtn.onclick = async () => {
restoreBtn.disabled = true;
restoreBtn.textContent = '...';
try {
const fd = new FormData();
fd.append('action', 'restore-event');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('id', ev.id);
const r = await fetch('api.php', { method: 'POST', body: fd });
const res = await r.json();
if (res.success) {
showToast(t('toast.event_restored'), 'success');
loadEventsTrash();
} else {
showToast(res.message || 'Error', 'error');
restoreBtn.disabled = false;
restoreBtn.textContent = t('btn.restore');
}
} catch (err) {
showToast(err.message, 'error');
restoreBtn.disabled = false;
restoreBtn.textContent = t('btn.restore');
}
};
tdActions.appendChild(restoreBtn);
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-sm';
delBtn.textContent = t('btn.delete');
delBtn.onclick = () => {
showModal(t('modal.delete_permanently'), t('events.confirm_delete_permanent', {title: title}), async () => {
closeModal();
try {
const fd = new FormData();
fd.append('action', 'delete-event-permanent');
fd.append('csrf_token', CSRF_TOKEN);
fd.append('id', ev.id);
const r = await fetch('api.php', { method: 'POST', body: fd });
const res = await r.json();
if (res.success) {
showToast(t('toast.event_deleted'), 'success');
loadEventsTrash();
} else {
showToast(res.message || 'Error', 'error');
}
} catch (err) {
showToast(err.message, 'error');
}
});
};
tdActions.appendChild(delBtn);
tr.appendChild(tdActions);
tbody.appendChild(tr);
});
}
async function emptyEventsTrash() {
showModal(t('modal.empty_trash'), t('modal.empty_trash_confirm'), async () => {
closeModal();
try {
const fd = new FormData();
fd.append('action', 'empty-events-trash');
fd.append('csrf_token', CSRF_TOKEN);
const r = await fetch('api.php', { method: 'POST', body: fd });
const res = await r.json();
if (res.success) {
showToast(res.message || t('toast.trash_emptied'), 'success');
loadEventsTrash();
} else {
showToast(res.message || 'Error', 'error');
}
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Sidebar toggle (mobile)
document.getElementById('sidebarToggle').addEventListener('click', () => {
document.getElementById('adminSidebar').classList.toggle('open');
});

View File

@ -0,0 +1,206 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
async function loadFormsAdmin() {
try {
const response = await fetch('api.php?action=list-forms');
const result = await response.json();
if (!result.success) {
showToast(result.message || t('forms.load_error'), 'error');
return;
}
formsAdminData = result.data || [];
renderFormsAdminList();
if (formsAdminData.length && !currentFormEditorId) {
await loadFormEditor(formsAdminData[0].id);
} else if (!formsAdminData.length && !currentFormEditorId) {
populateFormEditor(defaultFormDefinition('kontakt'));
}
} catch (error) {
showToast(t('toast.error_loading', {message: error.message}), 'error');
}
}
function renderFormsAdminList() {
const select = document.getElementById('formsAdminSelect');
const meta = document.getElementById('formsAdminMeta');
if (!select) return;
const current = currentFormEditorId;
select.innerHTML = '';
if (!formsAdminData.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = t('forms.empty');
select.appendChild(option);
select.disabled = true;
if (meta) meta.textContent = '';
return;
}
select.disabled = false;
formsAdminData.forEach(form => {
const option = document.createElement('option');
option.value = form.id;
option.textContent = `${form.label || form.id} (${form.id})`;
select.appendChild(option);
});
if (formsAdminData.some(form => form.id === current)) {
select.value = current;
}
const selected = formsAdminData.find(form => form.id === select.value);
if (meta) {
meta.textContent = selected
? `${selected.id} · ${t('forms.field_count', {count: selected.fieldCount || 0})}`
: '';
}
}
async function loadFormEditor(formId) {
try {
const response = await fetch('api.php?action=load-form&form_id=' + encodeURIComponent(formId));
const result = await response.json();
if (!result.success) {
showToast(result.message || t('forms.load_error'), 'error');
return;
}
populateFormEditor(result.data);
} catch (error) {
showToast(t('toast.error_loading', {message: error.message}), 'error');
}
}
function populateFormEditor(form) {
currentFormEditorId = form.id || '';
document.getElementById('formEditorId').value = form.id || '';
document.getElementById('formEditorLabel').value = form.label || '';
document.getElementById('formEditorDescription').value = form.description || '';
document.getElementById('formEditorEnabled').checked = form.enabled !== false;
document.getElementById('formEditorStore').checked = !form.submit || form.submit.store !== false;
document.getElementById('formEditorEmail').checked = !form.submit || form.submit.email !== false;
document.getElementById('formEditorSubject').value = (form.submit && form.submit.subject) || '{form}: {name}';
document.getElementById('formEditorSuccess').value = (form.submit && form.submit.successText) || t('forms.default_success');
renderFormFields(form.fields || []);
renderFormsAdminList();
}
function renderFormFields(fields) {
const tbody = document.getElementById('formFieldsBody');
if (!tbody) return;
tbody.innerHTML = '';
fields.forEach(field => tbody.appendChild(createFormFieldRow(field)));
}
function createFormFieldRow(field) {
const tr = document.createElement('tr');
tr.className = 'forms-field-row';
const optionsText = (field.options || []).map(option => {
if (typeof option === 'string') return option;
return option.value === option.label ? option.value : `${option.value}|${option.label}`;
}).join('\n');
tr.innerHTML = `
<td data-label="${escapeHtml(t('forms.type'))}"><select data-field-prop="type">
${['text', 'email', 'tel', 'textarea', 'select', 'radio', 'checkbox', 'date', 'time', 'heading', 'note', 'hidden'].map(type => `<option value="${type}"${field.type === type ? ' selected' : ''}>${type}</option>`).join('')}
</select></td>
<td data-label="${escapeHtml(t('forms.key'))}"><input type="text" data-field-prop="key" value="${escapeHtml(field.key || '')}"></td>
<td data-label="${escapeHtml(t('forms.label'))}"><input type="text" data-field-prop="label" value="${escapeHtml(field.label || '')}"></td>
<td data-label="${escapeHtml(t('forms.placeholder'))}"><input type="text" data-field-prop="placeholder" value="${escapeHtml(field.placeholder || '')}"></td>
<td data-label="${escapeHtml(t('forms.width'))}"><select data-field-prop="width">
${[3, 4, 6, 8, 12].map(width => `<option value="${width}"${Number(field.width || 12) === width ? ' selected' : ''}>${width}/12</option>`).join('')}
</select></td>
<td class="forms-field-check" data-label="${escapeHtml(t('forms.required'))}"><input type="checkbox" data-field-prop="required"${field.required ? ' checked' : ''}></td>
<td class="forms-field-options" data-label="${escapeHtml(t('forms.options'))}"><textarea data-field-prop="options" rows="2">${escapeHtml(optionsText)}</textarea></td>
<td class="users-table__actions"><button type="button" class="btn-icon btn-icon--danger" title="${escapeHtml(t('btn.delete'))}">${icon('trash', 14, '2')}</button></td>
`;
tr.querySelector('.btn-icon--danger').addEventListener('click', () => tr.remove());
return tr;
}
function parseFormOptions(text) {
return String(text || '').split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const parts = line.split('|');
const value = (parts[0] || '').trim();
const label = (parts[1] || parts[0] || '').trim();
return { value, label };
});
}
function collectFormEditor() {
const fields = Array.from(document.querySelectorAll('#formFieldsBody tr')).map(row => {
const get = prop => row.querySelector(`[data-field-prop="${prop}"]`);
return {
type: get('type').value,
key: get('key').value,
label: get('label').value,
placeholder: get('placeholder').value,
required: get('required').checked,
width: Number(get('width').value) || 12,
options: parseFormOptions(get('options').value)
};
});
return {
id: document.getElementById('formEditorId').value,
label: document.getElementById('formEditorLabel').value,
description: document.getElementById('formEditorDescription').value,
enabled: document.getElementById('formEditorEnabled').checked,
submit: {
store: document.getElementById('formEditorStore').checked,
email: document.getElementById('formEditorEmail').checked,
subject: document.getElementById('formEditorSubject').value,
successText: document.getElementById('formEditorSuccess').value
},
fields
};
}
async function saveFormEditor(event) {
event.preventDefault();
const resultBox = document.getElementById('formsAdminResult');
try {
const formData = new FormData();
formData.append('action', 'save-form');
formData.append('csrf_token', CSRF_TOKEN);
formData.append('form', JSON.stringify(collectFormEditor()));
const response = await fetch('api.php', { method: 'POST', body: formData });
const result = await response.json();
if (!result.success) {
showToast(result.message || t('forms.save_error'), 'error');
return;
}
populateFormEditor(result.data);
await loadFormsAdmin();
if (resultBox) {
resultBox.className = 'settings-test-result settings-test-result--success';
resultBox.textContent = t('forms.saved');
resultBox.style.display = '';
}
showToast(t('forms.saved'), 'success');
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
}
document.getElementById('formsAdminForm')?.addEventListener('submit', saveFormEditor);
document.getElementById('formsAdminSelect')?.addEventListener('change', function() {
if (this.value) loadFormEditor(this.value);
});
document.getElementById('addFormFieldBtn')?.addEventListener('click', function() {
document.getElementById('formFieldsBody')?.appendChild(createFormFieldRow({
type: 'text',
key: 'field_' + (document.querySelectorAll('#formFieldsBody tr').length + 1),
label: t('forms.new_field'),
placeholder: '',
required: false,
width: 12,
options: []
}));
});
document.getElementById('newFormBtn')?.addEventListener('click', function() {
currentFormEditorId = '';
populateFormEditor(defaultFormDefinition('formular-' + (formsAdminData.length + 1)));
});
// Settings sub-tabs
function getActiveSettingsTab() {
var active = document.querySelector('.settings-tab-btn.active');
return active ? active.getAttribute('data-settings-tab') : 'branding';
}

View File

@ -0,0 +1,603 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
<?php if ($isAdminUser): ?>
// ============================================================
// ICON MANAGER
// ============================================================
let iconManagerData = [];
let iconManagerPath = '';
let iconManagerSortMode = 'alpha';
let iconManagerSearchTerm = '';
let iconManagerHighlightedKeys = new Set();
let iconManagerHighlightTimer = null;
const ICONIFY_IMPORT_SETS = {
lucide: { label: 'Lucide', license: 'ISC', licenseUrl: 'https://github.com/lucide-icons/lucide/blob/main/LICENSE' },
tabler: { label: 'Tabler Icons', license: 'MIT', licenseUrl: 'https://github.com/tabler/tabler-icons/blob/master/LICENSE' },
heroicons:{ label: 'Heroicons', license: 'MIT', licenseUrl: 'https://github.com/tailwindlabs/heroicons/blob/master/LICENSE' },
ph: { label: 'Phosphor', license: 'MIT', licenseUrl: 'https://github.com/phosphor-icons/core/blob/main/LICENSE' },
bi: { label: 'Bootstrap Icons',license: 'MIT', licenseUrl: 'https://github.com/twbs/icons/blob/main/LICENSE.md', style: 'fill' },
iconoir: { label: 'Iconoir', license: 'MIT', licenseUrl: 'https://github.com/iconoir-icons/iconoir/blob/main/LICENSE' },
ion: { label: 'Ionicons', license: 'MIT', licenseUrl: 'https://github.com/ionic-team/ionicons/blob/main/LICENSE' },
mynaui: { label: 'Myna UI', license: 'MIT', licenseUrl: 'https://github.com/MynaUI/icons/blob/main/LICENSE' },
mingcute: { label: 'MingCute', license: 'Apache-2.0', licenseUrl: 'https://github.com/mingcute-design/mingcute-icons/blob/main/LICENSE' },
tdesign: { label: 'TDesign Icons', license: 'MIT', licenseUrl: 'https://github.com/Tencent/tdesign-icons/blob/main/LICENSE' }
};
async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 12000) {
const controller = new AbortController();
const timer = setTimeout(function() { controller.abort(); }, timeoutMs);
try {
const response = await fetch(url, Object.assign({}, options, {
signal: controller.signal,
cache: 'no-store',
}));
return await response.json();
} finally {
clearTimeout(timer);
}
}
function cleanSvgAttributes(attrs) {
if (!attrs) return '';
const allowedAttrs = new Set([
'd', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
'width', 'height', 'fill', 'stroke', 'stroke-width', 'stroke-linecap',
'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset',
'fill-rule', 'clip-rule', 'opacity', 'fill-opacity', 'stroke-opacity',
'transform', 'id', 'clip-path', 'mask', 'offset', 'stop-color', 'stop-opacity',
'gradientUnits', 'gradientTransform'
]);
let cleaned = '';
attrs.replace(/\s+([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>\/]+))?/g, function(match, rawName, rawValue) {
const name = rawName.replace(/^xlink:/i, '').trim();
const lower = name.toLowerCase();
if (lower.startsWith('on') || lower === 'style' || lower === 'class') return '';
if (!allowedAttrs.has(name) && !allowedAttrs.has(lower)) return '';
const attrName = allowedAttrs.has(name) ? name : lower;
let value = rawValue || '';
if (value) {
const unquoted = value.replace(/^["']|["']$/g, '').trim();
if ((lower === 'clip-path' || lower === 'mask') && unquoted && !/^url\(#[-_a-zA-Z0-9]+\)$/.test(unquoted)) return '';
if ((lower === 'fill' || lower === 'stroke') && /^url\(/i.test(unquoted) && !/^url\(#[-_a-zA-Z0-9]+\)$/i.test(unquoted)) return '';
value = '="' + escapeHtml(unquoted) + '"';
}
cleaned += ' ' + attrName + value;
return '';
});
return cleaned;
}
function sanitizeIconSvgClient(svg) {
svg = (svg || '').trim()
.replace(/<\?xml[\s\S]*?\?>/gi, '')
.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
.replace(/<\s*\/?\s*svg\b[^>]*>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi, '')
.replace(/<\s*(metadata|desc|style|sodipodi:namedview)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
.replace(/<\s*defs\b[^>]*>\s*(?:<\s*style\b[^>]*>[\s\S]*?<\s*\/\s*style\s*>\s*)*<\s*\/\s*defs\s*>/gi, '')
.replace(/\s+on[a-z]+\s*=\s*(["']).*?\1/gi, '')
.replace(/\s+(href|xlink:href)\s*=\s*(["'])\s*(?!#)[^"']*\2/gi, '');
const allowed = new Set(['path', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'rect', 'g', 'defs', 'clipPath', 'mask', 'linearGradient', 'radialGradient', 'stop', 'title']);
svg = svg.replace(/<\s*(\/?)([a-zA-Z][a-zA-Z0-9:-]*)([^>]*)>/g, function(match, closing, tag, attrs) {
if (!allowed.has(tag)) return '';
if (closing) return `</${tag}>`;
const selfClosing = /\/\s*$/.test(attrs || '');
const cleanedAttrs = cleanSvgAttributes(attrs || '');
return `<${tag}${cleanedAttrs}${selfClosing ? '/' : ''}>`;
});
svg = svg.replace(/^\s*(?:\.[\w-]+\s*\{[^}]*\}\s*)+/gm, '');
svg = removeEmptySvgContainers(svg);
return svg.trim();
}
function normalizeIconViewBoxClient(viewBox) {
const raw = (viewBox || '').trim();
if (!raw) return '0 0 24 24';
const parts = raw.split(/[\s,]+/);
if (parts.length !== 4) return '0 0 24 24';
const numbers = parts.map(Number);
if (numbers.some(function(value) { return !Number.isFinite(value); }) || numbers[2] <= 0 || numbers[3] <= 0) {
return '0 0 24 24';
}
return numbers.map(function(value) {
return Number.isInteger(value) ? String(value) : String(parseFloat(value.toFixed(6)));
}).join(' ');
}
function extractIconViewBoxClient(svg) {
const markup = String(svg || '');
const viewBoxMatch = markup.match(/<\s*svg\b[^>]*\sviewBox\s*=\s*(["'])(.*?)\1/i);
if (viewBoxMatch) return normalizeIconViewBoxClient(viewBoxMatch[2]);
const widthMatch = markup.match(/<\s*svg\b[^>]*\swidth\s*=\s*(["'])([0-9.]+)(?:px)?\1/i);
const heightMatch = markup.match(/<\s*svg\b[^>]*\sheight\s*=\s*(["'])([0-9.]+)(?:px)?\1/i);
if (widthMatch && heightMatch) return normalizeIconViewBoxClient('0 0 ' + widthMatch[2] + ' ' + heightMatch[2]);
return '';
}
function removeEmptySvgContainers(svg) {
let previous = '';
let cleaned = svg || '';
while (cleaned !== previous) {
previous = cleaned;
cleaned = cleaned
.replace(/<defs\b[^>]*>\s*<\/defs>/gi, '')
.replace(/<g\b[^>]*>\s*<\/g>/gi, '')
.replace(/<clipPath\b[^>]*>\s*<\/clipPath>/gi, '')
.replace(/<mask\b[^>]*>\s*<\/mask>/gi, '');
}
return cleaned;
}
function forceIconCurrentColor(svg) {
let normalized = sanitizeIconSvgClient(svg)
.replace(/\s(fill|stroke)\s*=\s*(["'])(?!none\2|currentColor\2|url\(#)[^"']*\2/gi, ' $1="currentColor"')
.replace(/\sstop-color\s*=\s*(["'])(?!currentColor\1|none\1)[^"']*\1/gi, ' stop-color="currentColor"');
normalized = normalized.replace(/<\s*(polygon|circle|ellipse|rect)\b([^>]*)\/?>/gi, function(match, tag, attrs) {
if (/\s(?:fill|stroke)\s*=/i.test(attrs)) return match;
const selfClosing = /\/\s*>$/.test(match);
const cleanedAttrs = (attrs || '').replace(/\/\s*$/, '');
return `<${tag}${cleanedAttrs} fill="currentColor"${selfClosing ? '/>' : '>'}`;
});
return normalized;
}
async function loadIconManager() {
const button = document.getElementById('iconManagerRefreshBtn');
if (button) button.disabled = true;
try {
const result = await fetchJsonWithTimeout('api.php?action=list-icons&csrf_token=' + encodeURIComponent(CSRF_TOKEN) + '&_=' + Date.now());
if (result.success) {
iconManagerData = result.data.icons || [];
iconManagerPath = result.data.path || 'content/settings/iconset.json';
renderIconManager(result.data);
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('icons.load_error', {message: error.message}), 'error');
} finally {
if (button) button.disabled = false;
}
}
function getFilteredIconManagerIcons() {
const term = iconManagerSearchTerm.toLowerCase();
let icons = iconManagerData.slice();
if (term) {
icons = icons.filter(function(iconItem) {
const haystack = [
iconItem.key || '',
iconItem.label || '',
(iconItem.tags || []).join(' '),
iconItem.source || ''
].join(' ').toLowerCase();
return haystack.includes(term);
});
}
icons.sort(function(a, b) {
if (iconManagerSortMode === 'newest' || iconManagerSortMode === 'oldest') {
const aTime = Date.parse(a.createdAt || a.updatedAt || '') || 0;
const bTime = Date.parse(b.createdAt || b.updatedAt || '') || 0;
if (aTime !== bTime) {
return iconManagerSortMode === 'newest' ? bTime - aTime : aTime - bTime;
}
}
return String(a.key || '').localeCompare(String(b.key || ''), undefined, { sensitivity: 'base' });
});
return icons;
}
function iconSvgAttributes(iconItem) {
return (iconItem.style || 'stroke') === 'fill'
? 'fill="currentColor"'
: 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
}
function iconSvgMarkup(iconItem) {
const svg = iconItem.svg || '';
return (iconItem.style || 'stroke') === 'fill'
? svg.replace(/\s+stroke\s*=\s*(["'])currentColor\1/gi, '')
: svg;
}
function highlightIconManagerCard(key) {
if (key) iconManagerHighlightedKeys.add(key);
if (iconManagerHighlightTimer) clearTimeout(iconManagerHighlightTimer);
if (iconManagerHighlightedKeys.size) {
iconManagerHighlightTimer = setTimeout(function() {
iconManagerHighlightedKeys.clear();
renderIconManager();
}, 60000);
}
}
function renderIconManager(data = null) {
const grid = document.getElementById('iconManagerGrid');
const empty = document.getElementById('iconManagerEmpty');
const path = document.getElementById('iconManagerPath');
if (!grid) return;
if (data) {
iconManagerData = data.icons || [];
iconManagerPath = data.path || iconManagerPath;
}
grid.innerHTML = '';
// The site-relative path only: a server path says nothing an editor needs and reveals the install location
if (path) path.textContent = t('icons.path', {path: (iconManagerPath || 'content/settings/iconset.json').replace(/^.*?(?=content\/)/, '')});
const icons = getFilteredIconManagerIcons();
if (!icons.length) {
if (empty) empty.style.display = '';
renderAdminListFooter('iconManagerFooter', 'icons', 0, getDashboardPageSize('icons'), renderIconManager, 'iconManagerFooterTop');
return;
}
if (empty) empty.style.display = 'none';
const pageSize = getDashboardPageSize('icons');
const paged = pageSlice(icons, 'icons', pageSize);
renderAdminListFooter('iconManagerFooter', 'icons', icons.length, pageSize, renderIconManager, 'iconManagerFooterTop');
paged.items.forEach(function(iconItem) {
const card = document.createElement('article');
card.className = 'icon-manager-card';
if (iconManagerHighlightedKeys.has(iconItem.key)) {
card.classList.add('icon-manager-card--highlight');
}
const sourceLabel = iconItem.source === 'custom' ? t('icons.source_custom') : t('icons.source_core');
card.title = sourceLabel;
card.dataset.iconKey = iconItem.key || '';
card.dataset.iconTags = (iconItem.tags || []).join(' ');
card.innerHTML = `
<div class="icon-manager-card__preview">
<svg viewBox="${escapeHtml(iconItem.viewBox || '0 0 24 24')}" ${iconSvgAttributes(iconItem)}>${iconSvgMarkup(iconItem)}</svg>
</div>
<div class="icon-manager-card__body">
<strong>${escapeHtml(iconItem.key)}</strong>
<span>${escapeHtml(iconItem.label || '')}</span>
</div>
<div class="icon-manager-card__actions">
<button type="button" class="icon-manager-action-btn" data-icon-edit="${escapeHtml(iconItem.key)}" title="${t('pages.edit')}" aria-label="${t('pages.edit')}">${icon('edit', 14, '2')}</button>
<button type="button" class="icon-manager-action-btn icon-manager-action-btn--danger" data-icon-delete="${escapeHtml(iconItem.key)}" title="${t('btn.delete')}" aria-label="${t('btn.delete')}"${iconItem.canDelete ? '' : ' disabled'}>${icon('trash', 14, '2')}</button>
</div>
`;
grid.appendChild(card);
});
grid.querySelectorAll('[data-icon-edit]').forEach(function(btn) {
btn.addEventListener('click', function() {
const iconItem = iconManagerData.find(function(item) { return item.key === btn.dataset.iconEdit; });
if (iconItem) openIconManagerModal(iconItem);
});
});
grid.querySelectorAll('[data-icon-delete]').forEach(function(btn) {
btn.addEventListener('click', function() {
const iconItem = iconManagerData.find(function(item) { return item.key === btn.dataset.iconDelete; });
if (iconItem) deleteIconManagerIcon(iconItem);
});
});
}
function openIconManagerModal(iconItem) {
const isEdit = !!iconItem;
document.getElementById('iconManagerModalTitle').textContent = isEdit ? t('icons.edit_icon') : t('icons.add_icon');
document.getElementById('iconManagerOldKey').value = isEdit ? iconItem.key : '';
document.getElementById('iconManagerKey').value = isEdit ? iconItem.key : '';
document.getElementById('iconManagerLabel').value = isEdit ? (iconItem.label || '') : '';
document.getElementById('iconManagerTags').value = isEdit ? (iconItem.tags || []).join(', ') : '';
document.getElementById('iconManagerViewBox').value = isEdit ? (iconItem.viewBox || '0 0 24 24') : '0 0 24 24';
document.getElementById('iconManagerSvg').value = isEdit ? (iconItem.svg || '') : '';
document.getElementById('iconRenameWarning').hidden = true;
updateIconManagerPreview();
closeAllComboboxes();
document.getElementById('iconManagerModalOverlay').style.display = 'flex';
}
function closeIconManagerModal() {
document.getElementById('iconManagerModalOverlay').style.display = 'none';
}
// Custom icon-set picker (replaces native <select> to avoid modal overlay clipping)
(function() {
var picker = document.getElementById('iconifyImportSetPicker');
if (!picker) return;
var btn = document.getElementById('iconifyImportSetBtn');
var label = document.getElementById('iconifyImportSetLabel2');
var list = document.getElementById('iconifyImportSetList');
var hidden = document.getElementById('iconifyImportSet');
function openPicker() {
var rect = btn.getBoundingClientRect();
list.style.top = (rect.bottom + 4) + 'px';
list.style.left = rect.left + 'px';
list.style.width = rect.width + 'px';
list.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
function closePicker() {
list.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function(e) {
e.stopPropagation();
list.hidden ? openPicker() : closePicker();
});
list.addEventListener('click', function(e) {
var opt = e.target.closest('[role="option"]');
if (!opt) return;
list.querySelectorAll('[role="option"]').forEach(function(o) { o.setAttribute('aria-selected', 'false'); });
opt.setAttribute('aria-selected', 'true');
hidden.value = opt.dataset.value;
label.textContent = opt.textContent;
closePicker();
updateIconifyImportLicense();
});
document.addEventListener('click', function(e) {
if (!picker.contains(e.target)) closePicker();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !list.hidden) closePicker();
});
})();
function openIconifyImportModal() {
const overlay = document.getElementById('iconifyImportModalOverlay');
if (!overlay) return;
closeAllComboboxes();
// Also close icon-set picker if open
var setList = document.getElementById('iconifyImportSetList');
if (setList) setList.hidden = true;
overlay.style.display = 'flex';
updateIconifyImportLicense();
setTimeout(function() {
document.getElementById('iconifyImportQuery')?.focus();
}, 50);
}
function closeIconifyImportModal() {
document.getElementById('iconifyImportModalOverlay').style.display = 'none';
var setList = document.getElementById('iconifyImportSetList');
if (setList) setList.hidden = true;
}
function updateIconifyImportLicense() {
const prefix = document.getElementById('iconifyImportSet')?.value || 'lucide';
const setInfo = ICONIFY_IMPORT_SETS[prefix] || ICONIFY_IMPORT_SETS.lucide;
const license = document.getElementById('iconifyImportLicense');
if (license) {
license.innerHTML = escapeHtml(t('icons.import_license_hint', {
set: setInfo.label,
license: setInfo.license
}));
}
}
async function searchIconifyImport() {
const prefix = document.getElementById('iconifyImportSet')?.value || 'lucide';
const query = document.getElementById('iconifyImportQuery')?.value.trim() || '';
const results = document.getElementById('iconifyImportResults');
const empty = document.getElementById('iconifyImportEmpty');
const button = document.getElementById('iconifyImportSearchBtn');
if (!results || query.length < 2) {
showToast(t('icons.import_query_short'), 'error');
return;
}
results.innerHTML = '<p class="form-hint">' + escapeHtml(t('icons.import_loading')) + '</p>';
if (empty) empty.style.display = 'none';
if (button) button.disabled = true;
try {
const url = 'api.php?action=iconify-search&csrf_token=' + encodeURIComponent(CSRF_TOKEN)
+ '&prefix=' + encodeURIComponent(prefix)
+ '&query=' + encodeURIComponent(query);
const result = await fetchJsonWithTimeout(url, {}, 15000);
if (!result.success) {
showToast(result.message, 'error');
results.innerHTML = '';
return;
}
renderIconifyImportResults(result.data.icons || []);
} catch (error) {
showToast(t('icons.import_error', {message: error.message}), 'error');
results.innerHTML = '';
} finally {
if (button) button.disabled = false;
}
}
function renderIconifyImportResults(icons) {
const results = document.getElementById('iconifyImportResults');
const empty = document.getElementById('iconifyImportEmpty');
if (!results) return;
results.innerHTML = '';
if (!icons.length) {
if (empty) empty.style.display = '';
return;
}
if (empty) empty.style.display = 'none';
icons.forEach(function(iconItem) {
const card = document.createElement('article');
card.className = 'iconify-import-card';
card.innerHTML = `
<div class="iconify-import-card__preview">
<svg viewBox="${escapeHtml(iconItem.viewBox || '0 0 24 24')}" ${iconSvgAttributes(iconItem)}>${iconSvgMarkup(iconItem)}</svg>
</div>
<div class="iconify-import-card__body">
<strong>${escapeHtml(iconItem.name || '')}</strong>
<span>${escapeHtml(iconItem.setLabel || iconItem.prefix || '')} · ${escapeHtml(iconItem.license || '')}</span>
<code>${escapeHtml(iconItem.key || '')}</code>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-iconify-import="${escapeHtml(iconItem.full || '')}" data-iconify-key="${escapeHtml(iconItem.key || '')}">${iconManagerData.some(item => item.key === iconItem.key) ? t('icons.imported_short') : t('icons.import')}</button>
`;
if (iconManagerData.some(item => item.key === iconItem.key)) {
card.querySelector('[data-iconify-import]').disabled = true;
card.classList.add('iconify-import-card--imported');
}
results.appendChild(card);
});
results.querySelectorAll('[data-iconify-import]').forEach(function(button) {
button.addEventListener('click', function() {
importIconifyIcon(button.dataset.iconifyImport, button);
});
});
}
async function importIconifyIcon(fullName, button) {
const formData = new FormData();
formData.append('action', 'iconify-import');
formData.append('csrf_token', CSRF_TOKEN);
formData.append('icon', fullName);
if (button) button.disabled = true;
try {
const result = await fetchJsonWithTimeout('api.php', { method: 'POST', body: formData }, 15000);
if (!result.success) {
showToast(result.message, 'error');
return;
}
iconManagerSortMode = 'newest';
const sortInput = document.getElementById('iconManagerSort');
if (sortInput) sortInput.value = 'newest';
const importedKey = String(fullName || '').replace(':', '-');
highlightIconManagerCard(importedKey);
renderIconManager(result.data);
showToast(t('icons.imported'), 'success');
if (button) {
button.textContent = t('icons.imported_short');
button.disabled = true;
button.closest('.iconify-import-card')?.classList.add('iconify-import-card--imported');
}
} catch (error) {
showToast(t('icons.import_error', {message: error.message}), 'error');
} finally {
if (button && button.textContent !== t('icons.imported_short')) button.disabled = false;
}
}
function updateIconManagerPreview() {
const preview = document.getElementById('iconManagerPreview');
const svgInput = document.getElementById('iconManagerSvg');
const viewBoxInput = document.getElementById('iconManagerViewBox');
if (preview) {
preview.setAttribute('viewBox', normalizeIconViewBoxClient(viewBoxInput?.value || ''));
preview.innerHTML = sanitizeIconSvgClient(svgInput.value);
}
const oldKey = document.getElementById('iconManagerOldKey').value;
const newKey = document.getElementById('iconManagerKey').value;
const warning = document.getElementById('iconRenameWarning');
if (warning) warning.hidden = !(oldKey && newKey && oldKey !== newKey);
}
document.getElementById('iconManagerSvg')?.addEventListener('input', function() {
const viewBoxInput = document.getElementById('iconManagerViewBox');
const extractedViewBox = extractIconViewBoxClient(this.value);
if (extractedViewBox && viewBoxInput && (!viewBoxInput.value.trim() || normalizeIconViewBoxClient(viewBoxInput.value) === '0 0 24 24')) {
viewBoxInput.value = extractedViewBox;
}
updateIconManagerPreview();
});
document.getElementById('iconManagerViewBox')?.addEventListener('input', updateIconManagerPreview);
document.getElementById('iconManagerKey')?.addEventListener('input', updateIconManagerPreview);
document.getElementById('iconManagerCleanupBtn')?.addEventListener('click', function() {
const input = document.getElementById('iconManagerSvg');
const extractedViewBox = extractIconViewBoxClient(input.value);
if (extractedViewBox) document.getElementById('iconManagerViewBox').value = extractedViewBox;
input.value = sanitizeIconSvgClient(input.value);
updateIconManagerPreview();
showToast(t('icons.cleanup_done'), 'success');
});
document.getElementById('iconManagerCurrentColorBtn')?.addEventListener('click', function() {
const input = document.getElementById('iconManagerSvg');
input.value = forceIconCurrentColor(input.value);
updateIconManagerPreview();
showToast(t('icons.current_color_done'), 'success');
});
document.getElementById('iconManagerForm')?.addEventListener('submit', async function(e) {
e.preventDefault();
const oldKey = document.getElementById('iconManagerOldKey').value;
const newKey = document.getElementById('iconManagerKey').value.trim();
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(newKey)) {
showToast('Invalid icon key. Use lowercase letters, numbers, hyphens, and underscores.', 'error');
return;
}
const formData = new FormData();
formData.append('action', 'save-icon');
formData.append('csrf_token', CSRF_TOKEN);
formData.append('old_key', oldKey);
formData.append('key', newKey);
formData.append('label', document.getElementById('iconManagerLabel').value);
formData.append('tags', document.getElementById('iconManagerTags').value);
formData.append('viewBox', document.getElementById('iconManagerViewBox').value);
formData.append('svg', document.getElementById('iconManagerSvg').value);
try {
const result = await fetchJsonWithTimeout('api.php', { method: 'POST', body: formData }, 15000);
if (result.success) {
closeIconManagerModal();
iconManagerSortMode = 'newest';
const sortInput = document.getElementById('iconManagerSort');
if (sortInput) sortInput.value = 'newest';
highlightIconManagerCard(newKey);
renderIconManager(result.data);
showToast(t('icons.saved'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('icons.save_error', {message: error.message}), 'error');
}
});
document.getElementById('iconifyImportSet')?.addEventListener('change', function() {
updateIconifyImportLicense();
const query = document.getElementById('iconifyImportQuery')?.value.trim() || '';
if (query.length >= 2) searchIconifyImport();
});
document.getElementById('iconManagerSearch')?.addEventListener('input', function() {
iconManagerSearchTerm = this.value.trim();
dashboardListPages.icons = 1;
renderIconManager();
});
document.getElementById('iconManagerSort')?.addEventListener('change', function() {
iconManagerSortMode = this.value || 'alpha';
dashboardListPages.icons = 1;
renderIconManager();
});
document.getElementById('iconifyImportQuery')?.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
searchIconifyImport();
}
});
document.getElementById('iconifyImportSearchBtn')?.addEventListener('click', searchIconifyImport);
function deleteIconManagerIcon(iconItem) {
showModal(t('icons.delete_icon'), t('icons.delete_confirm', {key: iconItem.key}), async function() {
closeModal();
const formData = new FormData();
formData.append('action', 'delete-icon');
formData.append('csrf_token', CSRF_TOKEN);
formData.append('key', iconItem.key);
try {
const result = await fetchJsonWithTimeout('api.php', { method: 'POST', body: formData }, 15000);
if (result.success) {
renderIconManager(result.data);
showToast(t('icons.deleted'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('icons.delete_error', {message: error.message}), 'error');
}
});
}
<?php endif; ?>

View File

@ -0,0 +1,578 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// ============================================================
// IMAGE MANAGER — thin wrappers around NbImageManager (js/image-manager.js)
// ============================================================
// Initialize the shared image manager component with dashboard dependencies.
// (Deferred to end of script where CSRF_TOKEN and t() are defined.)
window.addEventListener('DOMContentLoaded', function() {
initDashboardImageManager();
});
function initDashboardImageManager() {
if (!window.NbImageManager) return;
NbImageManager.init({
apiUrl: 'api.php',
csrfToken: CSRF_TOKEN,
t: function(key, params) {
let s = RJ_LANG[key] || key;
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.replace('{' + k + '}', v);
}
}
return s;
},
showToast: function(msg, type) {
if (typeof showToast === 'function') showToast(msg, type);
},
showConfirm: null,
canGenerateImages: function() {
return dashboardAiImageUsable === true;
},
openImageGenerator: function(prompt, aspectRatio) {
openAiImageGenerator(prompt || '', aspectRatio || 'auto');
},
itemsPerPage: clampMediaPageSize(currentSettings?.dashboard?.mediaItemsPerPage)
});
}
function browseImageForField(inputEl, previewEl) {
NbImageManager.open(function(path) {
inputEl.value = path;
inputEl.dispatchEvent(new Event('input'));
if (previewEl) {
const img = previewEl.querySelector('img');
if (img) { img.src = path.startsWith('/') ? '..' + path : path; img.style.display = ''; }
}
markDirty();
}, inputEl ? inputEl.value : null, { types: ['image'], type: 'image' });
}
// Backward-compat globals (in case any onclick attribute still references them)
window.openImageManager = function() { NbImageManager.open(null, null, { types: ['image', 'audio', 'video', 'document'] }); };
window.closeImageManager = function() { NbImageManager.close(); };
function normalizeOgImageInputPath(path) {
path = (path || '').trim();
if (path.startsWith('../assets/images/')) {
return '/assets/images/' + path.substring('../assets/images/'.length);
}
if (path.startsWith('assets/images/')) {
return '/' + path;
}
return path;
}
function isSupportedOgImagePath(path) {
path = normalizeOgImageInputPath(path);
if (!path) return true;
var cleanPath = path.split('?')[0].split('#')[0].toLowerCase();
return /^\/assets\/images\/.+\.(jpe?g|png)$/.test(cleanPath);
}
function setOgImageInputValue(input, path) {
var normalized = normalizeOgImageInputPath(path);
if (!isSupportedOgImagePath(normalized)) {
showToast(t('editor.seo_og_image_format'), 'error');
return false;
}
input.value = normalized;
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
window.browseSeoOgImage = function() {
const input = document.getElementById('seoOgImage');
if (!input) return;
NbImageManager.open(function(path) {
if (setOgImageInputValue(input, path)) {
markDirty();
}
}, input.value || null, { types: ['image'], type: 'image' });
};
window.browseSectionMedia = function(btn, type) {
type = type || 'image';
const input = btn.parentElement.querySelector('.section-field');
const preview = btn.closest('.form-group').querySelector('.ce-image-preview');
NbImageManager.open(function(path) {
if (path && input) {
input.value = path;
input.dispatchEvent(new Event('input', { bubbles: true }));
if (type === 'image' && preview) {
const src = path.startsWith('/') ? '..' + path : path;
preview.innerHTML = '<img src="' + escapeHtml(src) + '" alt="preview" onerror="this.style.display=\'none\'">';
} else if (type === 'image') {
const previewDiv = document.createElement('div');
previewDiv.className = 'ce-image-preview';
const src = path.startsWith('/') ? '..' + path : path;
previewDiv.innerHTML = '<img src="' + escapeHtml(src) + '" alt="preview">';
input.parentElement.before(previewDiv);
}
markDirty();
}
}, input ? input.value : null, { types: [type], type: type });
};
window.browseSectionImage = function(btn) {
window.browseSectionMedia(btn, 'image');
};
// Track unsaved changes
let isDirty = false;
function markDirty() {
isDirty = true;
}
// Undo/Redo system — snapshot-based
const MAX_UNDO = 50;
let undoStack = [];
let redoStack = [];
function pushUndoSnapshot() {
collectAllContent();
undoStack.push(JSON.stringify(currentContent));
if (undoStack.length > MAX_UNDO) undoStack.shift();
redoStack = [];
updateUndoRedoButtons();
}
function editorUndo() {
if (undoStack.length === 0) return;
collectAllContent();
redoStack.push(JSON.stringify(currentContent));
const snapshot = undoStack.pop();
currentContent = JSON.parse(snapshot);
const openPaths = getOpenGroupPaths();
renderEditor();
restoreOpenGroupPaths(openPaths);
markDirty();
updateUndoRedoButtons();
}
function editorRedo() {
if (redoStack.length === 0) return;
collectAllContent();
undoStack.push(JSON.stringify(currentContent));
const snapshot = redoStack.pop();
currentContent = JSON.parse(snapshot);
const openPaths = getOpenGroupPaths();
renderEditor();
restoreOpenGroupPaths(openPaths);
markDirty();
updateUndoRedoButtons();
}
function updateUndoRedoButtons() {
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
}
function clearUndoHistory() {
undoStack = [];
redoStack = [];
updateUndoRedoButtons();
}
function isDashboardElementVisible(el) {
if (!el || el.hidden) return false;
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function submitDashboardForm(form) {
if (!form) return false;
const submitter = form.querySelector('button[type="submit"]:not([disabled]), input[type="submit"]:not([disabled])');
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(submitter || undefined);
} else {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
return true;
}
function triggerDashboardSaveShortcut() {
const pageEditor = document.getElementById('editorContainer');
if (currentContent && isDashboardElementVisible(pageEditor)) {
saveContent();
return true;
}
const newsEditor = document.getElementById('newsEditorContainer');
if (isDashboardElementVisible(newsEditor)) {
savePost();
return true;
}
const eventEditor = document.getElementById('eventsEditorView');
if (isDashboardElementVisible(eventEditor)) {
saveCurrentEvent();
return true;
}
const settingsTab = document.getElementById('settingsTab');
if (isDashboardElementVisible(settingsTab)) {
const activePanel = settingsTab.querySelector('.settings-panel.active');
if (!activePanel) return false;
if (activePanel.id === 'settingsPanel-menus') {
const menuSaveBtn = document.getElementById('saveMenuOrderBtn');
if (menuSaveBtn && !menuSaveBtn.disabled) {
menuSaveBtn.click();
return true;
}
return false;
}
return submitDashboardForm(activePanel.querySelector('form.settings-form'));
}
const iconsTab = document.getElementById('iconsTab');
if (isDashboardElementVisible(iconsTab)) {
return submitDashboardForm(document.getElementById('iconManagerForm'));
}
return false;
}
// Keyboard shortcuts for save and undo/redo
document.addEventListener('keydown', function(e) {
const tag = document.activeElement?.tagName;
const isTyping = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || document.activeElement?.isContentEditable;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
triggerDashboardSaveShortcut();
return;
}
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
if (!currentContent) return;
// Ignore when typing in input/textarea
if (isTyping) return;
e.preventDefault();
if (e.shiftKey) {
editorRedo();
} else {
editorUndo();
}
return;
}
if (!currentContent || isTyping) return;
if (e.key === '/') {
const search = document.getElementById('sectionSearchInput');
if (search) {
e.preventDefault();
search.focus();
}
return;
}
if (e.key === 'j' || e.key === 'k') {
e.preventDefault();
const direction = e.key === 'j' ? 1 : -1;
const next = Math.max(0, Math.min(activeSectionIndex + direction, (currentContent.sections || []).length - 1));
scrollToSection(next);
return;
}
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
const direction = e.key === 'ArrowDown' ? 1 : -1;
reorderSection(activeSectionIndex, activeSectionIndex + direction);
}
});
// Array manipulation
function getNestedValue(obj, path) {
return path.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : undefined, obj);
}
function setNestedValue(obj, path, value) {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (current[k] === undefined) {
current[k] = isNaN(keys[i + 1]) ? {} : [];
}
current = current[k];
}
current[keys[keys.length - 1]] = value;
}
function addArrayItem(basePath) {
pushUndoSnapshot();
const openPaths = getOpenGroupPaths();
const arr = getNestedValue(currentContent, basePath);
if (!Array.isArray(arr)) return;
// Clone the structure of the first item or create an empty string
if (arr.length > 0 && typeof arr[0] === 'object' && arr[0] !== null) {
const template = {};
for (const k of Object.keys(arr[0])) {
template[k] = '';
}
arr.push(template);
} else {
arr.push('');
}
// Auto-open the new item
openPaths.add(basePath + '.' + (arr.length - 1));
renderEditor();
restoreOpenGroupPaths(openPaths);
markDirty();
}
function removeArrayItem(basePath, index) {
pushUndoSnapshot();
const openPaths = getOpenGroupPaths();
const arr = getNestedValue(currentContent, basePath);
if (!Array.isArray(arr)) return;
arr.splice(index, 1);
renderEditor();
restoreOpenGroupPaths(openPaths);
markDirty();
}
function moveArrayItem(basePath, index, direction) {
pushUndoSnapshot();
const openPaths = getOpenGroupPaths();
const arr = getNestedValue(currentContent, basePath);
if (!Array.isArray(arr)) return;
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= arr.length) return;
const temp = arr[index];
arr[index] = arr[newIndex];
arr[newIndex] = temp;
// Swap the open state of moved items
const pathA = basePath + '.' + index;
const pathB = basePath + '.' + newIndex;
const hadA = openPaths.has(pathA);
const hadB = openPaths.has(pathB);
openPaths.delete(pathA);
openPaths.delete(pathB);
if (hadA) openPaths.add(pathB);
if (hadB) openPaths.add(pathA);
renderEditor();
restoreOpenGroupPaths(openPaths);
markDirty();
}
// Collect all content from the form back into currentContent
function collectAllContent() {
// Collect page settings (title, description, nav, breadcrumb)
collectPageSettings();
// Collect generic fields
document.querySelectorAll('[data-path]').forEach(el => {
const path = el.dataset.path;
let value;
if (el.dataset.type === 'boolean') {
value = el.checked;
} else if (el.dataset.type === 'number') {
value = Number(el.value);
} else {
value = el.value;
}
setNestedValue(currentContent, path, value);
});
// Collect sections (legacy)
collectSectionData();
}
// Add section element (registry-driven)
function addSectionElement(section, index, container) {
if (!container) container = document.getElementById('sectionsLegacyContainer');
const div = document.createElement('div');
div.className = 'section-item is-open';
div.id = `section-${index}`;
div.dataset.index = index;
div.dataset.type = section.type;
div.dataset.search = getSectionSearchText(section, index);
div.dataset.editorSectionId = div.id;
div.dataset.editorSectionKind = 'section';
div.setAttribute('draggable', 'true');
const def = window.BlockTypeRegistry?.[section.type];
const typeLabel = getSectionTypeLabel(section);
const preview = getSectionPreview(section);
const fullTitle = getSectionHeading(section, index);
const issues = getSectionIssues(section, index, currentContent.sections || []);
const issueLabel = issues.length === 1
? t('editor.section_issue', {count: issues.length})
: t('editor.section_issues', {count: issues.length});
// Build form fields from registry
let content = '';
if (def && def.fields) {
for (const field of def.fields) {
const val = escapeHtml(section[field.key] ?? '');
const fieldClass = 'section-field-group section-field-group--' + String(field.key || 'field').replace(/[^a-z0-9_-]/gi, '-').toLowerCase();
switch (field.type) {
case 'input':
case 'url':
case 'number':
content += `<div class="form-group ${fieldClass}">
<label>${field.label}</label>
<input type="${field.type === 'input' ? 'text' : field.type}" class="section-field" data-key="${field.key}" value="${val}" placeholder="${field.label}...">
${field.hint ? `<small style="color: #666;">${field.hint}</small>` : ''}
</div>`;
break;
case 'textarea':
content += `<div class="form-group ${fieldClass}">
<label>${field.label}</label>
<textarea class="section-field" data-key="${field.key}" placeholder="${field.label}...">${val}</textarea>
</div>`;
break;
case 'wysiwyg':
content += `<div class="form-group html-editor ${fieldClass}">
<label>${field.label} (HTML)</label>
<textarea class="section-field" data-key="${field.key}">${val}</textarea>
</div>`;
break;
case 'select':
const opts = (field.options || []).map(o =>
`<option value="${o.value}"${section[field.key] === o.value ? ' selected' : ''}>${o.label}</option>`
).join('');
content += `<div class="form-group ${fieldClass}">
<label>${field.label}</label>
<select class="section-field" data-key="${field.key}">${opts}</select>
</div>`;
break;
case 'checkbox':
content += `<div class="form-group ${fieldClass}">
<label><input type="checkbox" class="section-field" data-key="${field.key}"${section[field.key] ? ' checked' : ''}> ${field.label}</label>
</div>`;
break;
case 'image':
const imgSrc = val ? (val.startsWith('/') ? '..' + val : val) : '';
content += `<div class="form-group ${fieldClass}">
<label>${field.label}</label>
${imgSrc ? `<div class="ce-image-preview"><img src="${escapeHtml(imgSrc)}" alt="preview" onerror="this.style.display='none'"></div>` : ''}
<div class="ce-image-input-row">
<input type="text" class="section-field ce-input" data-key="${field.key}" value="${val}" placeholder="Path to image...">
<button type="button" class="btn btn-secondary btn-sm" onclick="browseSectionImage(this)">${t('btn.browse')}</button>
</div>
</div>`;
break;
case 'audio':
content += `<div class="form-group ${fieldClass}">
<label>${field.label}</label>
<div class="ce-image-input-row">
<input type="text" class="section-field ce-input" data-key="${field.key}" value="${val}" placeholder="Path to audio file...">
<button type="button" class="btn btn-secondary btn-sm" onclick="browseSectionMedia(this, 'audio')">${t('btn.browse')}</button>
</div>
</div>`;
break;
}
}
}
div.innerHTML = `
<div class="section-header">
<div class="section-heading">
<button type="button" class="section-drag-handle" draggable="true" title="${t('editor.drag_section')}" aria-label="${t('editor.drag_section')}">⋮⋮</button>
<button type="button" class="section-toggle-btn" onclick="toggleSectionOpen(${index})" aria-expanded="true" title="${t('editor.collapse_section')}">▾</button>
<span class="section-index-label">${index + 1}</span>
<span class="section-type ${section.type}">${typeLabel}</span>
<span class="section-title-preview" title="${escapeHtml(fullTitle)}">${preview ? escapeHtml(preview) : 'Section ' + (index + 1)}</span>
${issues.length ? `<span class="section-issue-badge" title="${escapeHtml(issues.join('\n'))}">${escapeHtml(issueLabel)}</span>` : ''}
</div>
<div class="section-actions">
<button class="btn btn-sm btn-secondary" onclick="moveSection(${index}, -1)">&#8593;</button>
<button class="btn btn-sm btn-secondary" onclick="moveSection(${index}, 1)">&#8595;</button>
<button class="btn btn-sm btn-danger" onclick="deleteSection(${index})">${icon('trash', 14)}</button>
</div>
</div>
<div class="section-fields">${content}</div>
`;
container.appendChild(div);
div.addEventListener('dragstart', e => {
if (!e.target.closest('.section-drag-handle') && !e.target.closest('.section-header')) {
e.preventDefault();
return;
}
sectionDragIndex = index;
e.dataTransfer.effectAllowed = 'move';
div.classList.add('is-dragging');
});
div.addEventListener('dragend', () => {
sectionDragIndex = null;
document.querySelectorAll('.is-dragging, .is-drop-target').forEach(el => el.classList.remove('is-dragging', 'is-drop-target'));
});
div.addEventListener('dragover', e => {
e.preventDefault();
div.classList.add('is-drop-target');
});
div.addEventListener('dragleave', () => div.classList.remove('is-drop-target'));
div.addEventListener('drop', e => {
e.preventDefault();
div.classList.remove('is-drop-target');
reorderSection(sectionDragIndex, index);
});
sectionCounter++;
}
// Add new section (registry-driven)
function addSection(type, insertIndex) {
pushUndoSnapshot();
if (!currentContent) {
currentContent = { sections: [] };
}
if (!currentContent.sections) {
currentContent.sections = [];
}
const def = window.BlockTypeRegistry?.[type];
const defaults = def?.defaults ? JSON.parse(JSON.stringify(def.defaults)) : {};
const newSection = {
id: 'section_' + Date.now(),
type: type,
...defaults
};
const targetIndex = Number.isInteger(insertIndex)
? Math.max(0, Math.min(insertIndex, currentContent.sections.length))
: currentContent.sections.length;
currentContent.sections.splice(targetIndex, 0, newSection);
renderEditor();
setActiveSection(targetIndex);
requestAnimationFrame(() => scrollToSection(targetIndex));
markDirty();
}
// Move section
function moveSection(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= currentContent.sections.length) return;
reorderSection(index, newIndex);
}
// Delete section
function deleteSection(index) {
pushUndoSnapshot();
currentContent.sections.splice(index, 1);
renderEditor();
markDirty();
}
// Collect form data (registry-driven)
function collectSectionData() {
const sectionElements = document.querySelectorAll('.section-item');
sectionElements.forEach((el, index) => {
const section = currentContent.sections[index];
if (!section) return;
// Read all fields with data-key attributes
el.querySelectorAll('.section-field').forEach(fieldEl => {
const key = fieldEl.dataset.key;
if (!key) return;
if (fieldEl.type === 'checkbox') {
if (fieldEl.checked) {
section[key] = fieldEl.checked;
} else {
delete section[key];
}
} else {
section[key] = fieldEl.value || '';
}
});
});
}

View File

@ -0,0 +1,545 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// ============================================================
// MAIL MANAGEMENT
// ============================================================
let mailsData = [];
let mailFormsData = [];
let mailFormFilter = '';
let mailSortField = 'received';
let mailSortDir = 'desc';
async function loadMails() {
try {
if (!currentSettings) {
await loadSettings();
}
const response = await fetch('api.php?action=load-mails');
const result = await response.json();
if (result.success) {
if (Array.isArray(result.data)) {
mailsData = result.data;
mailFormsData = [];
} else {
mailsData = result.data.mails || [];
mailFormsData = result.data.forms || [];
}
populateMailFormFilter();
renderMails();
updateMailBadge();
updateMailConfigBanner();
updateMailStorageBanner();
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_loading', {message: error.message}), 'error');
}
}
function renderMails() {
const tbody = document.getElementById('mailsList');
if (!tbody) return;
tbody.innerHTML = '';
const visibleMails = getFilteredMails();
if (!visibleMails || visibleMails.length === 0) {
rjRenderEmptyRow(tbody, 6, t('mails.no_messages'));
renderAdminListFooter('mailsListFooter', 'mails', 0, getDashboardPageSize(), renderMails, 'mailsListFooterTop');
updateMailBulkActions();
updateMailSortIndicators();
return;
}
const sortedMails = getSortedMails(visibleMails);
const pageSize = getDashboardPageSize();
const paged = pageSlice(sortedMails, 'mails', pageSize);
renderAdminListFooter('mailsListFooter', 'mails', sortedMails.length, pageSize, renderMails, 'mailsListFooterTop');
paged.items.forEach(mail => {
const date = new Date(mail.timestamp);
const dateStr = rjFormatDate(date, 'date');
const timeStr = rjFormatDate(date, 'time');
const isUnread = !mail.read;
const isStarred = !!mail.starred;
const formLabel = mail.formLabel || mail.formId || t('forms.contact_default');
const tr = document.createElement('tr');
tr.className = 'page-list-row';
if (isUnread) tr.classList.add('mail-row-unread');
const tdRead = document.createElement('td');
tdRead.className = 'mail-cell-flag';
const readBtn = document.createElement('button');
readBtn.type = 'button';
readBtn.className = 'mail-flag-btn mail-read-toggle' + (isUnread ? ' is-active' : '');
readBtn.title = isUnread ? t('mails.mark_read') : t('mails.mark_unread');
readBtn.setAttribute('aria-label', readBtn.title);
readBtn.innerHTML = `<span class="mail-read-dot" aria-hidden="true"></span>`;
readBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
setMailFlags(mail.id, { read: isUnread });
};
tdRead.appendChild(readBtn);
const tdStar = document.createElement('td');
tdStar.className = 'mail-cell-flag';
const starBtn = document.createElement('button');
starBtn.type = 'button';
starBtn.className = 'mail-flag-btn mail-star-toggle' + (isStarred ? ' is-active' : '');
starBtn.title = isStarred ? t('mails.unstar') : t('mails.star');
starBtn.setAttribute('aria-label', starBtn.title);
starBtn.textContent = isStarred ? '★' : '☆';
starBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
setMailFlags(mail.id, { starred: !isStarred });
};
tdStar.appendChild(starBtn);
const tdForm = document.createElement('td');
tdForm.className = 'mail-cell-form';
tdForm.textContent = formLabel;
const tdFrom = document.createElement('td');
tdFrom.className = 'page-list-cell-title';
const fromLink = document.createElement('a');
fromLink.href = '#';
fromLink.className = 'page-list-title-link';
fromLink.textContent = mail.name;
fromLink.onclick = (e) => { e.preventDefault(); openMailDetail(mail.id); };
tdFrom.appendChild(fromLink);
const actions = document.createElement('div');
actions.className = 'page-list-row-actions';
const viewLink = document.createElement('a');
viewLink.href = '#';
viewLink.className = 'page-list-row-action';
viewLink.innerHTML = icon('eye', 12, '2') + ' ' + t('pages.view');
viewLink.onclick = (e) => { e.preventDefault(); openMailDetail(mail.id); };
actions.appendChild(viewLink);
const sep = document.createElement('span');
sep.className = 'page-list-row-action-sep';
sep.textContent = '|';
actions.appendChild(sep);
const trashLink = document.createElement('a');
trashLink.href = '#';
trashLink.className = 'page-list-row-action page-list-row-action--danger';
trashLink.innerHTML = icon('trash', 12, '2') + ' ' + t('btn.delete');
trashLink.onclick = (e) => { e.preventDefault(); deleteMail(mail.id); };
actions.appendChild(trashLink);
tdFrom.appendChild(actions);
const tdSubject = document.createElement('td');
tdSubject.textContent = mail.occasion || '';
const tdDate = document.createElement('td');
tdDate.className = 'page-list-cell-date';
tdDate.textContent = `${dateStr} ${timeStr}`;
tr.appendChild(tdRead);
tr.appendChild(tdStar);
tr.appendChild(tdForm);
tr.appendChild(tdFrom);
tr.appendChild(tdSubject);
tr.appendChild(tdDate);
tbody.appendChild(tr);
});
updateMailBulkActions();
updateMailSortIndicators();
}
function populateMailFormFilter() {
const select = document.getElementById('mailFormFilter');
if (!select) return;
const current = mailFormFilter;
const byId = new Map();
(mailFormsData || []).forEach(form => {
if (form.id) byId.set(form.id, form.label || form.id);
});
(mailsData || []).forEach(mail => {
const id = mail.formId || 'contact';
if (!byId.has(id)) byId.set(id, mail.formLabel || id);
});
select.innerHTML = `<option value="">${escapeHtml(t('mails.all_forms'))}</option>`;
Array.from(byId.entries())
.sort((a, b) => String(a[1]).localeCompare(String(b[1]), undefined, { sensitivity: 'base' }))
.forEach(([id, label]) => {
const option = document.createElement('option');
option.value = id;
option.textContent = label;
select.appendChild(option);
});
select.value = byId.has(current) ? current : '';
mailFormFilter = select.value;
}
function setMailFormFilter(value) {
mailFormFilter = value || '';
dashboardListPages.mails = 1;
renderMails();
}
function getFilteredMails() {
const list = Array.isArray(mailsData) ? mailsData : [];
if (!mailFormFilter) return list;
return list.filter(mail => (mail.formId || 'contact') === mailFormFilter);
}
function getSortedMails(list) {
return [...(list || mailsData)].sort((a, b) => {
let cmp = 0;
if (mailSortField === 'read') {
cmp = Number(!!a.read) - Number(!!b.read);
} else if (mailSortField === 'starred') {
cmp = Number(!!a.starred) - Number(!!b.starred);
} else if (mailSortField === 'form') {
cmp = String(a.formLabel || a.formId || '').localeCompare(String(b.formLabel || b.formId || ''), undefined, { sensitivity: 'base' });
} else if (mailSortField === 'from') {
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' });
} else if (mailSortField === 'subject') {
cmp = String(a.occasion || '').localeCompare(String(b.occasion || ''), undefined, { sensitivity: 'base' });
} else {
cmp = new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime();
}
return mailSortDir === 'asc' ? cmp : -cmp;
});
}
function sortMails(field) {
if (mailSortField === field) {
mailSortDir = mailSortDir === 'asc' ? 'desc' : 'asc';
} else {
mailSortField = field;
mailSortDir = field === 'read' || field === 'from' || field === 'subject' || field === 'form' ? 'asc' : 'desc';
}
dashboardListPages.mails = 1;
renderMails();
}
function updateMailSortIndicators() {
document.querySelectorAll('#mailsListTable .page-list-sortable').forEach(th => {
const iconEl = th.querySelector('.page-list-sort-icon');
if (!iconEl) return;
if (th.dataset.mailSort === mailSortField) {
th.classList.add('sorted');
iconEl.textContent = mailSortDir === 'asc' ? '▲' : '▼';
} else {
th.classList.remove('sorted');
iconEl.textContent = '';
}
});
}
function updateMailBadge() {
const unreadCount = mailsData.filter(m => !m.read).length;
const badge = document.getElementById('mailBadge');
if (!badge) return;
if (unreadCount > 0) {
badge.textContent = unreadCount;
badge.classList.remove('mail-badge--hidden');
} else {
badge.classList.add('mail-badge--hidden');
}
}
function updateMailBulkActions() {
const btn = document.getElementById('deleteReadMailsBtn');
if (!btn) return;
const readCount = (mailsData || []).filter(m => m.read).length;
btn.disabled = readCount === 0;
btn.textContent = readCount > 0
? t('mails.delete_read_count', {count: readCount})
: t('mails.delete_read');
}
function updateMailConfigBanner() {
const banner = document.getElementById('mailConfigBanner');
if (!banner) return;
const title = document.getElementById('mailConfigBannerTitle');
const text = document.getElementById('mailConfigBannerText');
const email = (currentSettings && currentSettings.email) || {};
const method = email.method || 'inactive';
let titleText = '';
let bodyText = '';
if (method === 'inactive') {
titleText = t('mails.email_inactive_title');
bodyText = t('mails.email_inactive_text');
} else if (!email.recipientEmail) {
titleText = t('mails.email_recipient_missing_title');
bodyText = t('mails.email_recipient_missing_text');
}
if (!titleText) {
banner.hidden = true;
return;
}
title.textContent = titleText;
text.textContent = bodyText;
banner.hidden = false;
}
// This list only shows messages stored locally. Warn when no form that is switched on stores them.
function updateMailStorageBanner() {
const banner = document.getElementById('mailStorageBanner');
if (!banner) return;
const forms = mailFormsData || [];
const enabled = forms.filter(form => form.enabled);
let key = '';
if (!enabled.length) key = 'forms_disabled';
else if (!enabled.some(form => form.store)) key = 'storage_off';
if (!key) {
banner.hidden = true;
return;
}
document.getElementById('mailStorageBannerTitle').textContent = t('mails.' + key + '_title');
document.getElementById('mailStorageBannerText').textContent = t('mails.' + key + '_text');
banner.hidden = false;
}
function openFormsSettings() {
switchTab('settings');
const formsTab = document.querySelector('[data-settings-tab="forms"]');
if (formsTab) formsTab.click();
}
function openEmailSettings() {
switchTab('settings');
const emailTab = document.querySelector('[data-settings-tab="email"]');
if (emailTab) emailTab.click();
}
async function loadUnreadCount() {
try {
const response = await fetch('api.php?action=unread-mail-count');
const result = await response.json();
if (result.success) {
const badge = document.getElementById('mailBadge');
if (!badge) return;
if (result.data.count > 0) {
badge.textContent = result.data.count;
badge.classList.remove('mail-badge--hidden');
}
}
} catch (error) {
// Badge is non-critical; ignore transient fetch aborts during navigation/tests.
}
}
function openMailDetail(mailId) {
const mail = mailsData.find(m => m.id === mailId);
if (!mail) return;
const date = new Date(mail.timestamp);
const dateStr = rjFormatDate(date, 'date');
const timeStr = rjFormatDate(date, 'time');
const formLabel = mail.formLabel || mail.formId || t('forms.contact_default');
const fields = Array.isArray(mail.fields) ? mail.fields.filter(field => field && field.value !== '') : [];
const fieldsHtml = fields.length ? `
<div class="mail-detail-row mail-detail-fields">
<label>${t('mails.form_fields')}</label>
<div class="mail-fields-list">
${fields.map(field => `
<div class="mail-field-item">
<span>${escapeHtml(field.label || field.key || '')}</span>
<strong>${escapeHtml(field.value || '').replace(/\n/g, '<br>')}</strong>
</div>
`).join('')}
</div>
</div>
` : '';
document.getElementById('mailDetailTitle').textContent = mail.occasion;
document.getElementById('mailDetailContent').innerHTML = `
<div class="mail-detail-row">
<label>${t('mails.form')}</label>
<span>${escapeHtml(formLabel)}</span>
</div>
<div class="mail-detail-row">
<label>${t('mails.date')}</label>
<span>${dateStr} ${timeStr}</span>
</div>
<div class="mail-detail-row">
<label>${t('mails.name')}</label>
<span>${escapeHtml(mail.name)}</span>
</div>
<div class="mail-detail-row">
<label>${t('mails.email')}</label>
<span><a href="mailto:${escapeHtml(mail.email)}">${escapeHtml(mail.email)}</a></span>
</div>
${mail.phone ? `
<div class="mail-detail-row">
<label>${t('mails.phone')}</label>
<span><a href="tel:${escapeHtml(mail.phone)}">${escapeHtml(mail.phone)}</a></span>
</div>
` : ''}
${mail.date ? `
<div class="mail-detail-row">
<label>${t('mails.preferred_date')}</label>
<span>${rjFormatDate(mail.date, 'date')}</span>
</div>
` : ''}
<div class="mail-detail-row mail-detail-message">
<label>${t('mails.message')}</label>
<div class="mail-message-text">${escapeHtml(mail.message).replace(/\n/g, '<br>')}</div>
</div>
${fieldsHtml}
`;
document.getElementById('mailDeleteBtn').onclick = () => deleteMail(mailId);
document.getElementById('mailsListView').style.display = 'none';
document.getElementById('mailDetailView').style.display = '';
if (!mail.read) {
markMailRead(mailId);
}
}
function closeMailDetail() {
document.getElementById('mailDetailView').style.display = 'none';
document.getElementById('mailsListView').style.display = '';
}
async function markMailRead(mailId) {
return setMailFlags(mailId, { read: true }, { silent: true });
}
async function setMailFlags(mailId, flags, options = {}) {
try {
const formData = new FormData();
formData.append('action', 'update-mail-flags');
formData.append('mail_id', mailId);
formData.append('csrf_token', CSRF_TOKEN);
Object.entries(flags).forEach(([key, value]) => {
formData.append(key, value ? '1' : '0');
});
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
const mail = mailsData.find(m => m.id === mailId);
if (mail) {
Object.assign(mail, flags);
}
renderMails();
updateMailBadge();
} else if (!options.silent) {
showToast(result.message, 'error');
}
} catch (error) {
if (options.silent) {
console.error('Error updating mail flags:', error);
} else {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
}
}
async function markAllMailsRead() {
try {
const formData = new FormData();
formData.append('action', 'mark-all-mails-read');
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
mailsData.forEach(m => m.read = true);
renderMails();
updateMailBadge();
showToast(t('toast.all_read'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
}
function deleteReadMails() {
const readCount = (mailsData || []).filter(m => m.read).length;
if (readCount === 0) return;
showModal(t('modal.delete_read_messages'), t('modal.delete_read_messages_confirm', {count: readCount}), async () => {
try {
const formData = new FormData();
formData.append('action', 'delete-read-mails');
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
mailsData = mailsData.filter(m => !m.read);
renderMails();
updateMailBadge();
closeModal();
showToast(t('toast.read_messages_deleted', {count: result.data?.deleted || readCount}), 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
});
}
function deleteMail(mailId) {
showModal(t('modal.delete_message'), t('modal.delete_message_confirm'), async () => {
try {
const formData = new FormData();
formData.append('action', 'delete-mail');
formData.append('mail_id', mailId);
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
mailsData = mailsData.filter(m => m.id !== mailId);
renderMails();
updateMailBadge();
closeMailDetail();
closeModal();
showToast(t('toast.message_deleted'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
});
}
// Load badge on startup
loadUnreadCount();

View File

@ -0,0 +1,329 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
const VALID_DASHBOARD_TABS = <?php echo json_encode($validDashboardTabs, JSON_UNESCAPED_UNICODE); ?>;
const DASHBOARD_HASH_ALIASES = { dashboard: 'home', ai: 'home', pages: 'content', messages: 'mails' };
let dashboardRouteApplying = false;
let settingsLoaded = false;
let currentSettings = null;
let currentAiSettings = null;
let aiChatMessages = [];
let dashboardAiImageUsable = false;
let currentDashboardAnalyticsRange = { period: 'days', count: 30 };
let formsAdminLoaded = false;
function dashboardHashFor(tab, subtab) {
const publicTab = tab === 'home' ? 'dashboard' : (tab === 'content' ? 'pages' : (tab === 'mails' ? 'messages' : tab));
return '#' + publicTab + (subtab ? '/' + subtab : '');
}
// Topbar breadcrumb: ["Einstellungen", "Design"] → Einstellungen › Design
// Collapsible sidebar: icons only, remembered per browser
(function() {
var btn = document.getElementById('sidebarCollapseBtn');
if (!btn || !document.documentElement) return;
var KEY = 'raijuSidebarCollapsed';
var collapsed = false;
try { collapsed = window.localStorage.getItem(KEY) === '1'; } catch (e) {}
function applySidebarCollapsed(state) {
var root = document.documentElement;
if (state) root.setAttribute('data-sidebar', 'collapsed');
else root.removeAttribute('data-sidebar');
btn.setAttribute('aria-pressed', state ? 'true' : 'false');
var label = typeof t === 'function' ? t(state ? 'nav.expand_sidebar' : 'nav.collapse_sidebar') : '';
var labelSpan = btn.querySelector('span');
if (label && labelSpan) labelSpan.textContent = label;
if (label) btn.title = label;
// Icon-only items explain themselves on hover
document.querySelectorAll('.sidebar-nav-item').forEach(function(item) {
if (item === btn) return;
var text = item.querySelector(':scope > span:not(.mail-badge)');
if (!text) return;
if (state) item.setAttribute('title', text.textContent.trim());
else item.removeAttribute('title');
});
}
applySidebarCollapsed(collapsed);
btn.addEventListener('click', function() {
collapsed = !collapsed;
try { window.localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch (e) {}
applySidebarCollapsed(collapsed);
});
})();
function rjSetTopbarTitle(parts) {
const el = document.getElementById('topbarTitle');
if (!el) return;
const list = (Array.isArray(parts) ? parts : [parts]).filter(Boolean);
el.textContent = '';
list.forEach((text, i) => {
if (i > 0) {
const sep = document.createElement('span');
sep.className = 'topbar-crumb-sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '›';
el.appendChild(sep);
}
const span = document.createElement('span');
span.className = i < list.length - 1 ? 'topbar-crumb' : 'topbar-crumb-current';
span.textContent = text;
el.appendChild(span);
});
}
window.rjSetTopbarTitle = rjSetTopbarTitle;
function parseDashboardHash() {
const raw = (window.location.hash || '').replace(/^#/, '');
if (!raw) return { tab: 'home', subtab: '' };
const parts = raw.split('/').filter(Boolean);
const first = parts[0] || 'home';
const tab = DASHBOARD_HASH_ALIASES[first] || first;
return {
tab: VALID_DASHBOARD_TABS.indexOf(tab) !== -1 ? tab : 'home',
subtab: parts[1] || ''
};
}
function updateDashboardHash(tab, subtab, replace) {
if (dashboardRouteApplying) return;
const next = DASHBOARD_PATH + dashboardHashFor(tab, subtab);
if (window.location.pathname + window.location.hash === next) return;
history[replace ? 'replaceState' : 'pushState']({ view: 'dashboard', tab: tab, subtab: subtab || '' }, '', next);
}
async function applyDashboardRoute(replace) {
dashboardRouteApplying = true;
const params = new URLSearchParams(window.location.search);
const pageParam = params.get('page');
const tabParam = params.get('tab');
const postParam = params.get('post');
const route = parseDashboardHash();
const hashParts = (window.location.hash || '').replace(/^#/, '').split('/').filter(Boolean);
const needsPageList = hashParts[0] === 'page'
|| !!pageParam
|| tabParam === 'news'
|| route.tab === 'content';
if (needsPageList) {
try {
const response = await fetch('api.php?action=list-pages&_=' + Date.now());
const result = await response.json();
if (result.success) {
applyPageList(result.data);
}
} catch (e) {
console.error('Error loading page list:', e);
}
}
let canonicalTab = route.tab || 'home';
let canonicalSubtab = route.subtab || '';
let canonicalUrl = null;
if (hashParts[0] === 'page' && hashParts[1] && hashParts[1].includes('_')) {
const page = hashParts[1];
const lang = page.substring(0, page.indexOf('_'));
const slug = page.substring(page.indexOf('_') + 1);
switchTab('content', { replace: true });
canonicalTab = 'content';
canonicalSubtab = '';
const langSelect = document.getElementById('langSelect');
if (langSelect) {
langSelect.value = lang;
updatePageSelect(slug);
const pageSelect = document.getElementById('pageSelect');
if (pageSelect) {
pageSelect.value = slug;
await loadContent(false);
}
}
canonicalUrl = DASHBOARD_PATH + '#page/' + page;
} else if (route.tab !== 'home' || route.subtab) {
switchTab(route.tab, { settingsTab: route.subtab, replace: !!replace });
canonicalTab = route.tab;
canonicalSubtab = route.subtab;
} else if (tabParam === 'news' && isDashboardModuleEnabled('news')) {
switchTab('news');
canonicalTab = 'news';
canonicalSubtab = '';
if (postParam) {
// Wait for news to load, then open the post editor
await loadNews();
newsLoaded = true;
editPost(postParam);
}
} else if (pageParam && pageParam.includes('_')) {
const lang = pageParam.substring(0, pageParam.indexOf('_'));
const slug = pageParam.substring(pageParam.indexOf('_') + 1);
switchTab('content', { replace: true });
canonicalTab = 'content';
canonicalSubtab = '';
const langSelect = document.getElementById('langSelect');
if (langSelect) {
langSelect.value = lang;
updatePageSelect(slug);
const pageSelect = document.getElementById('pageSelect');
if (pageSelect) {
pageSelect.value = slug;
await loadContent(false);
}
}
canonicalUrl = DASHBOARD_PATH + '#page/' + pageParam;
} else {
switchTab('home', { replace: !!replace });
canonicalTab = 'home';
canonicalSubtab = '';
}
dashboardRouteApplying = false;
if (canonicalUrl) {
history.replaceState({ view: 'editor', page: hashParts[1] || pageParam }, '', canonicalUrl);
} else if (window.location.pathname.endsWith('/dashboard.php') || replace) {
updateDashboardHash(canonicalTab, canonicalSubtab, true);
}
}
// Auto-load page/hash route, otherwise show page list.
// Deferred by one microtask: every fragment shares this script block, and a
// route into News or Events reads flags (newsLoaded, eventsLoaded) that are
// declared further down. Running synchronously here hit their temporal dead
// zone and left those tabs empty after a direct #news / #events load.
Promise.resolve().then(() => applyDashboardRoute(true));
// Browser back/forward navigation
window.addEventListener('popstate', async (e) => {
if (e.state && e.state.view === 'editor' && e.state.page) {
const lang = e.state.page.substring(0, e.state.page.indexOf('_'));
const slug = e.state.page.substring(e.state.page.indexOf('_') + 1);
document.getElementById('langSelect').value = lang;
updatePageSelect(slug);
document.getElementById('pageSelect').value = slug;
loadContent(false);
} else {
await applyDashboardRoute(true);
}
});
window.addEventListener('hashchange', function() {
applyDashboardRoute(true);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
closeMailDetail();
}
});
// ============================================================
// TAB NAVIGATION
// ============================================================
function dismissFrontendEditBanner() {
const banner = document.getElementById('frontendEditBanner');
if (banner) banner.remove();
}
function switchTab(tab, options) {
options = options || {};
if (!isDashboardModuleEnabled(tab)) {
tab = 'home';
options = Object.assign({}, options, { replace: true });
}
if (tab !== 'content') {
dismissFrontendEditBanner();
}
const systemTab = document.getElementById('systemTab');
if (systemTab) systemTab.style.display = tab === 'system' ? 'block' : 'none';
if (tab === 'system') loadSystemStatus();
document.getElementById('homeTab').style.display = tab === 'home' ? 'block' : 'none';
document.getElementById('contentTab').style.display = tab === 'content' ? 'block' : 'none';
document.getElementById('newsTab').style.display = tab === 'news' ? 'block' : 'none';
document.getElementById('eventsTab').style.display = tab === 'events' ? 'block' : 'none';
document.getElementById('mailsTab').style.display = tab === 'mails' ? 'block' : 'none';
const mediaTab = document.getElementById('mediaTab');
if (mediaTab) mediaTab.style.display = tab === 'media' ? 'block' : 'none';
const iconsTab = document.getElementById('iconsTab');
if (iconsTab) iconsTab.style.display = tab === 'icons' ? 'block' : 'none';
document.getElementById('settingsTab').style.display = tab === 'settings' ? 'block' : 'none';
document.getElementById('backupTab').style.display = tab === 'backup' ? 'block' : 'none';
if (typeof syncAssistantEmbed === 'function') syncAssistantEmbed();
// Show/hide topbar selectors — only when editing a page (not on page list)
const topbarSelectors = document.getElementById('topbarSelectors');
if (topbarSelectors) {
if (tab === 'content' && currentPage) {
topbarSelectors.style.display = 'flex';
} else {
topbarSelectors.style.display = 'none';
}
}
// When switching to content tab, always show page list
if (tab === 'content') {
showPageList();
}
// Update sidebar active state
document.querySelectorAll('.sidebar-nav-item[data-tab]').forEach(btn => btn.classList.remove('active'));
const activeNavItem = document.querySelector(`.sidebar-nav-item[data-tab="${tab}"]`);
if (activeNavItem) activeNavItem.classList.add('active');
// Update topbar title
const titles = { system: t('system.title'), home: t('dashboard_home.title'), content: currentPage ? t('editor.title') : t('pages.title'), news: t('news.title'), mails: t('mails.title'), events: t('events.title'), media: t('nav.media_library'), icons: t('icons.title'), settings: t('settings.title'), backup: t('settings.backup') };
rjSetTopbarTitle([titles[tab] || 'Dashboard']);
// Close sidebar on mobile after tab switch
document.getElementById('adminSidebar').classList.remove('open');
if (tab === 'mails') {
// Always start on the list view when switching into Mails
const mailsList = document.getElementById('mailsListView');
const mailDetail = document.getElementById('mailDetailView');
if (mailsList) mailsList.style.display = '';
if (mailDetail) mailDetail.style.display = 'none';
loadMails();
}
if (tab === 'media' && window.NbImageManager) {
initDashboardImageManager();
const mount = document.getElementById('mediaLibraryMount');
if (mount) {
NbImageManager.mount(mount, { types: ['image', 'audio', 'video', 'document'] });
}
}
if (tab === 'home') {
loadDashboardOverview();
}
if (tab === 'news') {
showNewsList();
if (!newsLoaded) {
newsLoaded = true;
loadNews();
}
}
if (tab === 'events') {
// Always start on the list view when switching into Events
const listView = document.getElementById('eventsListView');
const editorView = document.getElementById('eventsEditorView');
const trashView = document.getElementById('eventsTrashView');
if (listView) listView.style.display = '';
if (editorView) editorView.style.display = 'none';
if (trashView) trashView.style.display = 'none';
currentEventIndex = null;
if (!eventsLoaded) {
eventsLoaded = true;
loadEventsEditor();
}
}
if (tab === 'icons' && typeof loadIconManager === 'function') {
loadIconManager();
}
if (tab === 'settings') {
activateSettingsTab(options.settingsTab || getActiveSettingsTab(), { silent: true });
}
if (tab === 'settings' && !settingsLoaded) {
loadSettings();
}
updateDashboardHash(tab, tab === 'settings' ? (options.settingsTab || getActiveSettingsTab()) : '', !!options.replace);
}

View File

@ -0,0 +1,593 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// ============================================================
// NEWS / BLOG MANAGEMENT
// ============================================================
let newsLoaded = false;
let newsData = [];
let editingPostId = null;
let newsHtmlMode = false;
async function loadNews() {
try {
const response = await fetch('api.php?action=load-news');
const result = await response.json();
if (result.success) {
newsData = result.data;
renderNewsList();
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_loading_news', {message: error.message}), 'error');
}
}
function renderNewsList() {
const tbody = document.getElementById('newsListBody');
const defaultLang = '<?php echo SITE_LANG_DEFAULT; ?>';
const otherLangs = <?php $defLang = SITE_LANG_DEFAULT; echo json_encode(array_values(array_filter(array_keys($siteLanguages), function($c) use ($defLang) { return $c !== $defLang; }))); ?>;
const colCount = 2 + otherLangs.length;
if (newsData.length === 0) {
tbody.innerHTML = '';
rjRenderEmptyRow(tbody, colCount, t('news.no_posts'), { label: t('news.new_post'), onClick: addNewPost });
renderAdminListFooter('newsListFooter', 'news', 0, getDashboardPageSize(), renderNewsList, 'newsListFooterTop');
return;
}
// Group posts by slug — primary language row is the "main" entry
const slugGroups = {};
newsData.forEach(post => {
const slug = post.slug || post.id;
if (!slugGroups[slug]) slugGroups[slug] = {};
const lang = post.lang || defaultLang;
slugGroups[slug][lang] = post;
});
// Sort groups by date descending (use primary lang post date, or any available)
const sortedSlugs = Object.keys(slugGroups).sort((a, b) => {
const aPost = slugGroups[a][defaultLang] || Object.values(slugGroups[a])[0];
const bPost = slugGroups[b][defaultLang] || Object.values(slugGroups[b])[0];
return (bPost.date || '').localeCompare(aPost.date || '');
});
tbody.innerHTML = '';
const pageSize = getDashboardPageSize();
const paged = pageSlice(sortedSlugs, 'news', pageSize);
renderAdminListFooter('newsListFooter', 'news', sortedSlugs.length, pageSize, renderNewsList, 'newsListFooterTop');
paged.items.forEach(slug => {
const group = slugGroups[slug];
// Primary post = default lang or first available
const post = group[defaultLang] || Object.values(group)[0];
const postLang = post.lang || defaultLang;
const tr = document.createElement('tr');
tr.className = 'page-list-row';
// Title cell
const tdTitle = document.createElement('td');
tdTitle.className = 'page-list-cell-title';
const slugSpan = document.createElement('span');
slugSpan.className = 'page-list-slug';
slugSpan.textContent = '/news/' + slug;
tdTitle.appendChild(slugSpan);
const titleLink = document.createElement('a');
titleLink.href = '#';
titleLink.className = 'page-list-title-link';
titleLink.textContent = post.title || t('news.untitled');
titleLink.onclick = (e) => { e.preventDefault(); editPost(post.id); };
tdTitle.appendChild(titleLink);
// Status badge
if (post.hidden) {
const badge = document.createElement('span');
badge.className = 'news-status news-draft';
badge.textContent = t('news.draft');
tdTitle.appendChild(badge);
}
// Hover actions
const actions = document.createElement('div');
actions.className = 'page-list-row-actions';
const editLink = document.createElement('a');
editLink.href = '#';
editLink.className = 'page-list-row-action';
editLink.innerHTML = icon('edit', 12, '2') + ' ' + t('news.edit');
editLink.onclick = (e) => { e.preventDefault(); editPost(post.id); };
actions.appendChild(editLink);
const sep1 = document.createElement('span');
sep1.className = 'page-list-row-action-sep';
sep1.textContent = '|';
actions.appendChild(sep1);
const langPrefix = postLang === defaultLang ? '' : postLang + '/';
const viewLink = document.createElement('a');
viewLink.href = '../' + langPrefix + 'news/' + slug;
viewLink.target = '_blank';
viewLink.className = 'page-list-row-action';
viewLink.innerHTML = icon('eye', 12, '2') + ' ' + t('news.view');
actions.appendChild(viewLink);
const sep2 = document.createElement('span');
sep2.className = 'page-list-row-action-sep';
sep2.textContent = '|';
actions.appendChild(sep2);
const toggleLink = document.createElement('a');
toggleLink.href = '#';
toggleLink.className = 'page-list-row-action';
toggleLink.innerHTML = post.hidden
? icon('eye', 12, '2') + ' ' + t('news.publish')
: icon('eye-off', 12, '2') + ' ' + t('news.unpublish');
toggleLink.onclick = (e) => { e.preventDefault(); toggleNewsStatus(post.id); };
actions.appendChild(toggleLink);
const sep3 = document.createElement('span');
sep3.className = 'page-list-row-action-sep';
sep3.textContent = '|';
actions.appendChild(sep3);
const deleteLink = document.createElement('a');
deleteLink.href = '#';
deleteLink.className = 'page-list-row-action page-list-row-action--danger';
deleteLink.innerHTML = icon('trash', 12, '2') + ' ' + t('btn.delete');
deleteLink.onclick = (e) => { e.preventDefault(); deletePost(post.id); };
actions.appendChild(deleteLink);
tdTitle.appendChild(actions);
tr.appendChild(tdTitle);
// Date cell
const tdDate = document.createElement('td');
tdDate.className = 'page-list-cell-date';
if (post.date) {
tdDate.textContent = rjFormatDate(post.date, 'date');
} else {
tdDate.textContent = '—';
}
tr.appendChild(tdDate);
// Other language columns
otherLangs.forEach(lang => {
const td = document.createElement('td');
td.className = 'page-list-cell-lang';
const langPost = group[lang];
if (langPost) {
const editBtn = document.createElement('a');
editBtn.href = '#';
editBtn.className = 'btn btn-secondary btn-sm page-list-lang-btn';
editBtn.innerHTML = icon('edit', 12) + ' ' + t('news.edit');
editBtn.onclick = (e) => { e.preventDefault(); editPost(langPost.id); };
td.appendChild(editBtn);
} else {
const createLink = document.createElement('a');
createLink.href = '#';
createLink.className = 'page-list-create-link';
createLink.textContent = t('pages.create');
createLink.onclick = (e) => {
e.preventDefault();
createNewsTranslation(post, lang, createLink);
};
td.appendChild(createLink);
}
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
function showNewsList() {
document.getElementById('newsListContainer').style.display = 'block';
document.getElementById('newsEditorContainer').style.display = 'none';
}
function addNewPost() {
const lang = '<?php echo SITE_LANG_DEFAULT; ?>';
editingPostId = null;
showPostEditor({
id: '',
lang: lang,
title: '',
slug: '',
date: new Date().toISOString().split('T')[0],
author: '',
excerpt: '',
image: '',
content: '',
hidden: false
});
}
function editPost(postId) {
const post = newsData.find(p => p.id === postId);
if (!post) return;
editingPostId = postId;
showPostEditor(post);
}
function showPostEditor(post) {
const isNew = !editingPostId;
newsHtmlMode = false;
// Hide list, show editor
document.getElementById('newsListContainer').style.display = 'none';
document.getElementById('newsEditorContainer').style.display = 'block';
// Update title
const editorTitle = document.getElementById('newsEditorTitle');
if (editorTitle) editorTitle.textContent = isNew ? t('news.new_post') : (post.title || t('news.edit'));
// Build language options
const languages = <?php echo json_encode($siteLanguages); ?>;
const langOpts = Object.entries(languages).map(([code, name]) =>
`<option value="${code}"${code === (post.lang || '<?php echo SITE_LANG_DEFAULT; ?>') ? ' selected' : ''}>${escapeHtml(name)}</option>`
).join('');
const container = document.getElementById('newsEditorForm');
container.innerHTML = `
<div class="news-editor">
<div class="editor-form-grid editor-form-grid--news">
<div class="editor-form-row editor-form-row--span-2">
<label for="newsTitle">${t('news.post_title')}</label>
<input type="text" id="newsTitle" class="editor-input" value="${escapeHtml(post.title)}" placeholder="${t('news.post_title')}">
</div>
<div class="editor-form-row-half editor-form-row--span-2">
<div class="editor-form-row">
<label for="newsSlug">${t('news.post_slug')}</label>
<input type="text" id="newsSlug" class="editor-input" value="${escapeHtml(post.slug)}" placeholder="my-post-slug">
</div>
<div class="editor-form-row">
<label for="newsDate">${t('news.post_date')}</label>
<input type="date" id="newsDate" class="editor-input" value="${escapeHtml(post.date)}">
</div>
</div>
<div class="editor-form-row-half editor-form-row--span-2">
<div class="editor-form-row">
<label for="newsAuthor">${t('news.post_author')}</label>
<input type="text" id="newsAuthor" class="editor-input" value="${escapeHtml(post.author)}">
</div>
<div class="editor-form-row">
<label for="newsLang">${t('news.post_language')}</label>
<select id="newsLang" class="editor-input">${langOpts}</select>
</div>
</div>
<div class="editor-form-row editor-form-row--cover">
<label for="newsImage">${t('news.post_image')}</label>
<div class="ce-image-input-row">
<input type="text" id="newsImage" class="editor-input" value="${escapeHtml(post.image)}" placeholder="/assets/images/cover.jpg">
<button type="button" class="btn btn-secondary btn-sm" onclick="browseNewsImage()">${t('btn.browse')}</button>
</div>
${AI_FEATURES_ENABLED ? `<p class="ai-field-hint">${t('ai.image_field_hint')} <button type="button" class="btn btn-secondary btn-sm" onclick="openAiImageGenerator(getNewsImagePrompt(), '16:9')">${t('ai.open_image_generator')}</button></p>` : ''}
<div class="ce-image-preview" id="newsImagePreview">
<img src="${post.image ? (post.image.startsWith('/') ? '..' + escapeHtml(post.image) : escapeHtml(post.image)) : ''}" alt="" style="${post.image ? '' : 'display:none;'}">
</div>
</div>
<div class="editor-form-row editor-form-row--full">
<label for="newsExcerpt">${t('news.post_excerpt')}</label>
<textarea id="newsExcerpt" class="editor-textarea" rows="3">${escapeHtml(post.excerpt)}</textarea>
</div>
<div class="editor-form-row editor-form-row--full">
<label>${t('news.post_content')}</label>
<div class="news-wysiwyg-toolbar">
<button type="button" onclick="newsExecCmd('bold')" title="Bold"><b>B</b></button>
<button type="button" onclick="newsExecCmd('italic')" title="Italic"><i>I</i></button>
<button type="button" onclick="newsInsertLink()" title="Link">🔗</button>
<button type="button" onclick="newsInsertHeading()" title="Heading">H3</button>
<button type="button" onclick="newsExecCmd('insertUnorderedList')" title="List">☰</button>
<button type="button" onclick="newsCleanHtml()" title="Clean formatting">✕</button>
<span class="news-toolbar-sep"></span>
<label class="news-html-toggle">
<input type="checkbox" id="newsHtmlToggle" onchange="newsToggleHtml()">
<span>HTML</span>
</label>
</div>
<div id="newsContentWysiwyg" class="news-wysiwyg-editor" contenteditable="true"></div>
<textarea id="newsContentHtml" class="editor-textarea editor-textarea-large" style="display:none;" rows="16"></textarea>
</div>
<div class="editor-form-row">
<label class="editor-checkbox-label">
<input type="checkbox" id="newsHidden" ${post.hidden ? 'checked' : ''}>
${t('news.draft')}
</label>
</div>
</div>
<div class="editor-form-actions">
<button class="btn btn-secondary" onclick="cancelPostEditor()">${t('btn.cancel')}</button>
<button class="btn btn-primary" onclick="savePost()">${t('btn.save')}</button>
</div>
</div>
`;
// Set WYSIWYG content (not escaped — it's HTML)
document.getElementById('newsContentWysiwyg').innerHTML = post.content || '';
// Update view + trash buttons (after form is built, so newsSlug/newsLang exist)
updateNewsEditorButtons();
// Auto-generate slug from title for new posts
document.getElementById('newsTitle').addEventListener('input', function() {
if (!editingPostId) {
const slug = this.value.toLowerCase().trim()
.replace(/[äöüß]/g, m => ({ä:'ae',ö:'oe',ü:'ue',ß:'ss'})[m])
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
document.getElementById('newsSlug').value = slug;
}
});
}
// WYSIWYG helpers for news editor
function newsExecCmd(cmd) {
document.execCommand(cmd, false, null);
document.getElementById('newsContentWysiwyg').focus();
}
function newsInsertLink() {
showModal(
t('insert_link'),
'<label class="modal-field-label" for="newsLinkUrlInput">URL</label>' +
'<input class="form-control" id="newsLinkUrlInput" type="url" value="https://" placeholder="https://...">',
function() {
const input = document.getElementById('newsLinkUrlInput');
const url = input ? input.value.trim() : '';
if (!url) return;
closeModal();
document.getElementById('newsContentWysiwyg').focus();
document.execCommand('createLink', false, url);
},
{
html: true,
confirmText: t('btn.insert'),
confirmClass: 'btn btn-primary',
focusSelector: '#newsLinkUrlInput'
}
);
setTimeout(function() {
const input = document.getElementById('newsLinkUrlInput');
if (input) {
input.addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
document.getElementById('modalConfirm').click();
}
});
input.select();
}
}, 0);
}
function newsInsertHeading() {
document.execCommand('formatBlock', false, 'h3');
document.getElementById('newsContentWysiwyg').focus();
}
function newsCleanHtml() {
document.execCommand('removeFormat', false, null);
document.getElementById('newsContentWysiwyg').focus();
}
function newsToggleHtml() {
const wysiwyg = document.getElementById('newsContentWysiwyg');
const html = document.getElementById('newsContentHtml');
newsHtmlMode = !newsHtmlMode;
if (newsHtmlMode) {
html.value = wysiwyg.innerHTML;
wysiwyg.style.display = 'none';
html.style.display = 'block';
html.focus();
} else {
wysiwyg.innerHTML = html.value;
html.style.display = 'none';
wysiwyg.style.display = 'block';
wysiwyg.focus();
}
}
function updateNewsEditorButtons() {
const viewBtn = document.getElementById('newsViewBtn');
const trashBtn = document.getElementById('newsTrashBtn');
if (editingPostId) {
const defLang = '<?php echo SITE_LANG_DEFAULT; ?>';
const lang = document.getElementById('newsLang')?.value || defLang;
const slug = document.getElementById('newsSlug')?.value || editingPostId;
const prefix = (lang === defLang) ? '../' : '../' + lang + '/';
if (viewBtn) { viewBtn.href = prefix + 'news/' + slug; viewBtn.style.display = ''; }
if (trashBtn) trashBtn.style.display = '';
} else {
if (viewBtn) viewBtn.style.display = 'none';
if (trashBtn) trashBtn.style.display = 'none';
}
}
function deleteCurrentPost() {
if (!editingPostId) return;
const title = document.getElementById('newsTitle')?.value || editingPostId;
showModal(
t('modal.delete_post'),
t('modal.delete_post_confirm', { title }),
async function() {
closeModal();
try {
const formData = new FormData();
formData.append('action', 'delete-news');
formData.append('slug', editingPostId);
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
showToast(t('toast.news_deleted'), 'success');
editingPostId = null;
showNewsList();
loadNews();
} else {
showToast(result.message, 'error');
}
} catch (e) {
showToast(t('toast.error_generic', {message: e.message}), 'error');
}
}
);
}
function getNewsContent() {
if (newsHtmlMode) {
return document.getElementById('newsContentHtml').value;
}
return document.getElementById('newsContentWysiwyg').innerHTML;
}
function browseNewsImage() {
const inputEl = document.getElementById('newsImage');
const previewEl = document.getElementById('newsImagePreview');
browseImageForField(inputEl, previewEl);
}
function cancelPostEditor() {
editingPostId = null;
showNewsList();
renderNewsList();
}
async function savePost() {
const title = document.getElementById('newsTitle').value.trim();
const date = document.getElementById('newsDate').value;
if (!title || !date) {
showToast(t('toast.news_date_required'), 'error');
return;
}
const post = {
id: editingPostId || '',
lang: document.getElementById('newsLang').value,
title: title,
slug: document.getElementById('newsSlug').value.trim(),
date: date,
author: document.getElementById('newsAuthor').value.trim(),
excerpt: document.getElementById('newsExcerpt').value.trim(),
image: document.getElementById('newsImage').value.trim(),
content: getNewsContent(),
hidden: document.getElementById('newsHidden').checked
};
try {
const formData = new FormData();
formData.append('action', 'save-news');
formData.append('post', JSON.stringify(post));
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
const wasNew = !editingPostId;
showToast(wasNew ? t('toast.news_created') : t('toast.news_saved'), 'success');
// Stay in editor — update slug reference for subsequent saves
if (result.data?.slug) {
editingPostId = result.data.slug;
document.getElementById('newsSlug').value = result.data.slug;
}
// Update view + trash buttons
updateNewsEditorButtons();
loadNews();
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
}
function deletePost(postId) {
const post = newsData.find(p => p.id === postId);
const title = post ? post.title : postId;
showModal(t('modal.delete_post'), t('modal.delete_post_confirm', {title: title}), async () => {
try {
const formData = new FormData();
formData.append('action', 'delete-news');
formData.append('post_id', postId);
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
newsData = newsData.filter(p => p.id !== postId);
renderNewsList();
closeModal();
showToast(t('toast.news_deleted'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
});
}
async function createNewsTranslation(sourcePost, targetLang, linkEl) {
linkEl.classList.add('disabled');
linkEl.textContent = '...';
try {
// Clone the source post with the new language
const newPost = Object.assign({}, sourcePost, {
id: '',
lang: targetLang,
lastModified: new Date().toISOString()
});
const formData = new FormData();
formData.append('action', 'save-news');
formData.append('post', JSON.stringify(newPost));
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
showToast(t('toast.news_created'), 'success');
await loadNews();
editPost(result.data.id);
} else {
showToast(result.message, 'error');
linkEl.classList.remove('disabled');
linkEl.textContent = t('pages.create');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
linkEl.classList.remove('disabled');
linkEl.textContent = t('pages.create');
}
}
async function toggleNewsStatus(postId) {
try {
const formData = new FormData();
formData.append('action', 'toggle-news-status');
formData.append('post_id', postId);
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
const post = newsData.find(p => p.id === postId);
if (post) post.hidden = result.data.hidden;
renderNewsList();
showToast(result.data.hidden ? t('news.draft') : t('news.published'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,339 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// Save content
async function saveContent() {
collectAllContent();
currentContent.page = currentPage;
currentContent.lang = document.getElementById('langSelect').value;
try {
const formData = new FormData();
formData.append('action', 'save');
formData.append('page', currentPage);
formData.append('content', JSON.stringify(currentContent));
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showToast(t('toast.saved'), 'success');
isDirty = false;
currentContent.lastModified = result.data.lastModified;
if (result.data.seoHealth) {
const lang = document.getElementById('langSelect').value;
const page = document.getElementById('pageSelect').value;
currentSeoHealth = result.data.seoHealth;
setPageSeoHealth(lang, page, result.data.seoHealth);
updateEditorSeoHealth(result.data.seoHealth);
}
document.getElementById('lastModified').textContent =
t('editor.last_saved', {date: formatDateShort(result.data.lastModified)});
loadBackups();
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_saving', {message: error.message}), 'error');
}
}
// Load backups
async function loadBackups() {
try {
const response = await fetch(`api.php?action=backups&page=${currentPage}`);
const result = await response.json();
if (result.success) {
renderBackups(result.data);
}
} catch (error) {
console.error('Error loading backups:', error);
}
}
function renderBackups(backups) {
const container = document.getElementById('backupList');
if (backups.length === 0) {
container.innerHTML = '<p style="color: #666;">' + t('backups.no_backups') + '</p>';
return;
}
container.innerHTML = backups.map(backup => `
<div class="backup-item">
<div class="backup-info">
<span class="backup-date">${backup.date}</span>
<span class="backup-time">${backup.time}</span>
</div>
<div class="backup-actions">
<button class="btn btn-sm btn-secondary" onclick="previewBackup('${backup.filename}')">${t('backups.view')}</button>
<button class="btn btn-sm btn-primary" onclick="restoreBackup('${backup.filename}')">${t('backups.restore')}</button>
<button class="btn btn-sm btn-danger" onclick="deleteBackup('${backup.filename}')">${t('backups.delete')}</button>
</div>
</div>
`).join('');
}
async function previewBackup(filename) {
try {
const response = await fetch(`api.php?action=preview-backup&backup=${filename}`);
const result = await response.json();
if (result.success) {
showModal(
t('backups.view'),
renderBackupPreview(result.data),
closeModal,
{
html: true,
modalClass: 'modal-backup-preview',
confirmText: t('btn.close'),
confirmClass: 'btn btn-primary'
}
);
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
}
function flattenBackupPreviewFields(value, prefix, rows) {
if (Array.isArray(value)) {
if (value.length === 0) {
rows.push({ name: prefix || '[]', value: '[]' });
return;
}
var allScalar = value.every(function(item) {
return item === null || typeof item !== 'object';
});
if (allScalar) {
rows.push({
name: prefix || '[]',
value: value.map(formatBackupPreviewValue).join(', ')
});
return;
}
value.forEach(function(item, index) {
flattenBackupPreviewFields(item, (prefix ? prefix : '') + '[' + index + ']', rows);
});
return;
}
if (value && typeof value === 'object') {
var keys = Object.keys(value);
if (keys.length === 0) {
rows.push({ name: prefix || '{}', value: '{}' });
return;
}
keys.forEach(function(key) {
flattenBackupPreviewFields(value[key], prefix ? prefix + '.' + key : key, rows);
});
return;
}
rows.push({ name: prefix || 'value', value: formatBackupPreviewValue(value) });
}
function formatBackupPreviewValue(value) {
if (value === null) return 'null';
if (value === undefined) return '';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'number') return String(value);
var text = String(value);
return text === '' ? '—' : text;
}
function renderBackupPreview(data) {
var rows = [];
flattenBackupPreviewFields(data, '', rows);
if (!rows.length) {
return '<div class="backup-preview-modal"><p class="backup-preview-empty">Keine Inhalte im Backup gefunden.</p></div>';
}
return '<div class="backup-preview-modal">' + rows.map(function(row) {
return '<article class="backup-preview-field">' +
'<div class="backup-preview-field__name" title="' + escapeHtml(row.name) + '">' + escapeHtml(row.name) + '</div>' +
'<div class="backup-preview-field__value">' + escapeHtml(row.value) + '</div>' +
'</article>';
}).join('') + '</div>';
}
function restoreBackup(filename) {
showModal(t('modal.restore_backup'),
t('modal.restore_backup_confirm'),
async () => {
try {
const formData = new FormData();
formData.append('action', 'restore');
formData.append('backup', filename);
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showToast(t('toast.backup_restored'), 'success');
closeModal();
loadContent();
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
}
);
}
function deleteBackup(filename) {
showModal(t('modal.delete_backup'),
t('modal.delete_backup_confirm'),
async () => {
try {
const formData = new FormData();
formData.append('action', 'delete-backup');
formData.append('backup', filename);
formData.append('csrf_token', CSRF_TOKEN);
const response = await fetch('api.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showToast(t('toast.backup_deleted'), 'success');
closeModal();
loadBackups();
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
}
);
}
// Modal
function closeAllComboboxes() {
document.querySelectorAll('.rj-combobox__list:not([hidden])').forEach(function(list) {
list.hidden = true;
var root = list.closest('.rj-combobox');
if (root) {
var input = root.querySelector('input[role="combobox"]');
if (input) input.setAttribute('aria-expanded', 'false');
}
});
}
function showModal(title, text, onConfirm, options) {
options = options || {};
closeAllComboboxes();
var modalEl = document.querySelector('#modalOverlay .modal');
if (modalEl) {
modalEl.className = 'modal' + (options.modalClass ? ' ' + options.modalClass : '');
}
document.getElementById('modalTitle').textContent = title;
var modalText = document.getElementById('modalText');
if (options.html) {
modalText.innerHTML = text;
} else {
modalText.textContent = text;
}
document.getElementById('modalOverlay').style.display = 'flex';
document.getElementById('modalOverlay').setAttribute('aria-hidden', 'false');
var confirmBtn = document.getElementById('modalConfirm');
confirmBtn.onclick = onConfirm || closeModal;
if (options.confirmText) confirmBtn.textContent = options.confirmText;
if (options.confirmClass) confirmBtn.className = options.confirmClass;
if (options.hideConfirm) confirmBtn.style.display = 'none';
setTimeout(() => {
var focusTarget = options.focusSelector ? document.querySelector(options.focusSelector) : confirmBtn;
(focusTarget || confirmBtn).focus();
}, 0);
}
function closeModal() {
const overlay = document.getElementById('modalOverlay');
overlay.style.display = 'none';
overlay.setAttribute('aria-hidden', 'true');
const modalEl = overlay.querySelector('.modal');
if (modalEl) modalEl.className = 'modal';
// Reset confirm button to default state
const btn = document.getElementById('modalConfirm');
btn.textContent = t('btn.confirm');
btn.className = 'btn btn-danger';
btn.disabled = false;
btn.style.display = '';
}
// Format date without seconds
function formatDateShort(dateStr) {
const d = new Date(dateStr);
return rjFormatDate(d, 'datetime');
}
// Toast
function showToast(message, type = 'success') {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
toast.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.textContent = message;
document.body.appendChild(toast);
const duration = type === 'error' ? 6000 : 4000;
setTimeout(() => {
toast.classList.add('toast-fade-out');
toast.addEventListener('animationend', () => toast.remove());
}, duration);
}
// HTML escape
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Init
document.getElementById('langSelect').addEventListener('change', function() {
onTopbarLanguageChanged();
const m = document.getElementById('langSelectMobile');
if (m) m.value = this.value;
});
const langMobile = document.getElementById('langSelectMobile');
if (langMobile) {
langMobile.addEventListener('change', function() {
document.getElementById('langSelect').value = this.value;
onTopbarLanguageChanged();
});
}
const pageMobile = document.getElementById('pageSelectMobile');
if (pageMobile) {
pageMobile.addEventListener('change', function() {
document.getElementById('pageSelect').value = this.value;
});
}
document.getElementById('pageSelect').addEventListener('change', function() {
if (pageMobile) pageMobile.value = this.value;
});
const DASHBOARD_PATH = window.location.pathname.replace(/\/dashboard\.php$/, '/dashboard');

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// ============================================================
// CHANGE PASSWORD
// ============================================================
(function() {
var newPw = document.getElementById('newPassword');
var confirmPw = document.getElementById('newPasswordConfirm');
var form = document.getElementById('changePasswordForm');
var reqs = {
length: function() { return newPw.value.length >= 8; },
upper: function() { return /[A-Z]/.test(newPw.value); },
lower: function() { return /[a-z]/.test(newPw.value); },
digit: function() { return /[0-9]/.test(newPw.value); },
special: function() { return /[^A-Za-z0-9]/.test(newPw.value); },
match: function() { return newPw.value.length > 0 && newPw.value === confirmPw.value; }
};
function updateReqs() {
for (var key in reqs) {
var el = document.querySelector('#pwReqs [data-req="' + key + '"]');
if (el) {
if (reqs[key]()) {
el.classList.add('met');
el.classList.remove('unmet');
} else {
el.classList.remove('met');
el.classList.add('unmet');
}
}
}
}
newPw.addEventListener('input', updateReqs);
confirmPw.addEventListener('input', updateReqs);
form.addEventListener('submit', async function(e) {
e.preventDefault();
var btn = document.getElementById('changePwBtn');
btn.disabled = true;
btn.textContent = t('btn.changing');
try {
var formData = new FormData();
formData.append('action', 'change-password');
formData.append('current_password', document.getElementById('currentPassword').value);
formData.append('new_password', newPw.value);
formData.append('new_password_confirm', confirmPw.value);
formData.append('csrf_token', CSRF_TOKEN);
var response = await fetch('api.php', { method: 'POST', body: formData });
var result = await response.json();
if (result.success) {
showToast(t('toast.password_changed'), 'success');
form.reset();
updateReqs();
// Remove password warning banner if present
var warning = document.getElementById('passwordWarning');
if (warning) warning.remove();
var adminMain = document.getElementById('adminMain');
if (adminMain) adminMain.classList.remove('has-security-warning');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
} finally {
btn.disabled = false;
btn.textContent = t('settings.change_password');
}
});
})();

View File

@ -0,0 +1,48 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// ============================================================
// TOTAL RESET
// ============================================================
(function() {
var input = document.getElementById('totalResetConfirm');
var btn = document.getElementById('totalResetBtn');
input.addEventListener('input', function() {
btn.disabled = (input.value !== 'DELETE');
});
btn.addEventListener('click', async function() {
if (input.value !== 'DELETE') {
showToast(t('settings.total_reset_mismatch'), 'error');
return;
}
btn.disabled = true;
btn.textContent = '...';
try {
var formData = new FormData();
formData.append('action', 'total-reset');
formData.append('confirm', 'DELETE');
formData.append('csrf_token', CSRF_TOKEN);
var response = await fetch('api.php', { method: 'POST', body: formData });
var result = await response.json();
if (result.success) {
showToast(t('toast.total_reset_success'), 'success');
setTimeout(function() {
window.location.href = 'setup.php';
}, 1500);
} else {
showToast(result.message, 'error');
btn.disabled = false;
btn.textContent = t('settings.total_reset_btn');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
btn.disabled = false;
btn.textContent = t('settings.total_reset_btn');
}
});
})();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,405 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// Security check state is shared by the startup check, the warning banner and
// the system tab. var: switchTab('system') can run before this fragment executes.
var securityCheckRequest = null;
var securityStatus = null;
function runSecurityCheck(force) {
if (securityCheckRequest && !force) return securityCheckRequest;
const form = new FormData();
form.set('action', 'security-check');
form.set('csrf_token', CSRF_TOKEN);
form.set('scheme', window.location.protocol === 'https:' ? 'https' : 'http');
if (force) form.set('force', '1');
const request = fetch('api.php', { method: 'POST', body: form })
.then(response => response.json())
.then(result => {
if (!result.success) throw new Error(result.message || t('system.load_failed'));
return result.data;
})
.finally(() => { if (securityCheckRequest === request) securityCheckRequest = null; })
.then(data => { applySecurityStatus(data); return data; });
securityCheckRequest = request;
if (securityStatus) applySecurityStatus(securityStatus);
return request;
}
function applySecurityStatus(security) {
if (!security || !Array.isArray(security.items)) return;
securityStatus = security;
const folders = security.items.find(item => item.id === 'folders') || {};
const exposed = folders.state === 'critical' ? (folders.exposed || []) : [];
const banner = document.getElementById('securityExposureWarning');
if (banner) {
const data = exposed.some(path => path === 'content/' || path === 'backups/');
const text = t('security_check.banner_text', { paths: exposed.join(', ') }) + (data ? ' ' + t('security_check.banner_data') : '');
const body = document.getElementById('securityExposureText');
// role="alert" announces changes; do not repeat an unchanged warning.
if (body.textContent !== text) body.textContent = text;
banner.hidden = !exposed.length;
}
updateSystemBadge();
const panel = document.getElementById('systemSecurityPanel');
if (panel) panel.replaceWith(renderSecurityPanel(security));
}
function securityItemText(item) {
let key = item.id + '_' + item.state;
if (item.state === 'skipped') key = (item.id === 'folders' && item.canRun ? 'folders_' : '') + 'skipped_' + (item.reason || 'local');
else if (item.state === 'pending') key = securityCheckRequest || item.running ? 'running' : 'pending';
else if (item.state === 'unknown') key = item.reason ? 'unknown_reason' : 'unknown';
else if (item.id === 'http') key = 'http_' + item.mode;
else if (item.id === 'php' && item.state === 'ok' && !item.until) key = 'php_current';
const reason = item.reason ? t('security_check.reason_' + item.reason, { url: item.url || '', detail: item.detail || '' }) : '';
return t('security_check.' + key, {
paths: (item.exposed || []).join(', '), ip: item.ip || '', version: item.version || '', reason: reason,
until: item.until ? rjFormatDate(item.until, 'date') : ''
});
}
function renderSecurityDetails(item) {
const blocks = [];
const paragraph = (key, params) => {
const text = document.createElement('p'); text.textContent = t(key, params); blocks.push(text);
};
// Only media trash folders public: a warning with its own explanation.
if (['critical', 'warning', 'note'].includes(item.state)) paragraph('security_check.' + item.id + '_fix' + (item.id === 'folders' && item.state === 'warning' ? '_trash' : ''));
if (item.id === 'folders' && item.state === 'unknown' && item.url) paragraph('security_check.folders_manual', { url: item.url + '/content/settings.json' });
if (item.id === 'folders' && item.probes && item.probes.length) {
const list = document.createElement('ul'); list.className = 'system-check__probes';
for (const probe of item.probes) {
const entry = document.createElement('li'); const path = document.createElement('code');
path.textContent = probe.path;
entry.append(path, ' ' + t('security_check.probe_' + (probe.status ? probe.state : 'skipped'), { status: probe.status }));
list.append(entry);
}
blocks.push(list);
}
if (item.rules) {
const rules = document.createElement('div'); rules.className = 'system-check__rules';
const pre = document.createElement('pre'); const code = document.createElement('code');
code.textContent = item.rules; pre.append(code);
const copy = document.createElement('button'); copy.type = 'button'; copy.className = 'btn btn-secondary btn-sm';
copy.textContent = t('security_check.copy');
copy.onclick = () => {
const done = () => { copy.textContent = t('security_check.copied'); setTimeout(() => { copy.textContent = t('security_check.copy'); }, 1500); };
// Without the clipboard API (plain http://) or permission, select the rules for manual copying.
const select = () => {
const range = document.createRange(); range.selectNodeContents(code);
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
if (document.execCommand('copy')) done();
};
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(item.rules).then(done, select);
else select();
};
rules.append(pre, copy); blocks.push(rules);
paragraph('security_check.folders_hestia', { domain: window.location.hostname });
paragraph('security_check.folders_plesk');
paragraph('security_check.folders_htaccess');
}
if (!blocks.length) return null;
const details = document.createElement('details'); details.className = 'system-check__details';
details.open = item.state === 'critical';
const summary = document.createElement('summary');
summary.textContent = t(['critical', 'warning', 'note'].includes(item.state) ? 'security_check.how_to_fix' : 'system.details');
details.append(summary, ...blocks);
return details;
}
function renderSecurityPanel(security) {
const section = document.createElement('section');
section.className = 'dashboard-panel system-security'; section.id = 'systemSecurityPanel';
const header = document.createElement('div'); header.className = 'dashboard-panel-header';
const heading = document.createElement('h3'); heading.textContent = t('security_check.title');
header.append(heading);
const folders = security.items.find(item => item.id === 'folders') || {};
const actions = document.createElement('div'); actions.className = 'system-security__actions';
if (folders.checkedAt) {
const checked = document.createElement('span');
checked.textContent = t('security_check.checked_at', { date: rjFormatDate(folders.checkedAt, 'datetime') });
actions.append(checked);
}
if (folders.canRun) {
const busy = !!securityCheckRequest || !!folders.running;
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary btn-sm';
button.textContent = t(busy ? 'security_check.running' : 'security_check.run'); button.disabled = busy;
button.onclick = () => runSecurityCheck(true).catch(error => {
showToast(error.message, 'error');
if (securityStatus) applySecurityStatus(securityStatus);
});
actions.append(button);
}
header.append(actions);
section.append(header);
for (const item of security.items) {
const check = document.createElement('div'); check.className = 'system-check system-check--' + item.state;
const row = document.createElement('div'); row.className = 'system-status-row';
const name = document.createElement('strong'); name.textContent = t('security_check.' + item.id);
const text = document.createElement('span'); text.className = 'system-check__state'; text.textContent = securityItemText(item);
row.append(name, text); check.append(row);
const details = renderSecurityDetails(item);
if (details) check.append(details);
section.append(check);
}
return section;
}
// Search engine check (staging block forgotten at go-live). Same lifecycle as the security check.
var seoCheckRequest = null;
var seoStatus = null;
function updateSystemBadge() {
const badge = document.getElementById('systemBadge');
if (!badge) return;
const count = [securityStatus, seoStatus].reduce((sum, status) => sum + (status
? status.items.filter(item => item.state === 'critical' || item.state === 'warning').length : 0), 0);
badge.textContent = String(count);
badge.title = t('system.badge', { count: count });
badge.classList.toggle('mail-badge--hidden', count === 0);
}
function runSeoCheck(force) {
if (seoCheckRequest && !force) return seoCheckRequest;
const form = new FormData();
form.set('action', 'seo-check');
form.set('csrf_token', CSRF_TOKEN);
form.set('scheme', window.location.protocol === 'https:' ? 'https' : 'http');
if (force) form.set('force', '1');
const request = fetch('api.php', { method: 'POST', body: form })
.then(response => response.json())
.then(result => {
if (!result.success) throw new Error(result.message || t('system.load_failed'));
return result.data;
})
.finally(() => { if (seoCheckRequest === request) seoCheckRequest = null; })
.then(data => { applySeoStatus(data); return data; });
seoCheckRequest = request;
if (seoStatus) applySeoStatus(seoStatus);
return request;
}
// One click on the banner or in the panel: only this setting changes, nothing else.
async function allowSeoIndexing(button) {
button.disabled = true;
const form = new FormData();
form.set('action', 'save-settings');
form.set('csrf_token', CSRF_TOKEN);
form.set('settings', JSON.stringify({ seo: { noindexSite: false } }));
try {
// Saving settings needs the current revision; loading them records it (js/revision-client.js).
await fetch('api.php?action=load-settings');
const result = await (await fetch('api.php', { method: 'POST', body: form })).json();
if (!result.success) throw new Error(result.message);
currentSettings = result.data;
showToast(t('seo_check.allowed'), 'success');
await runSeoCheck(true);
} catch (error) {
showToast(error.message, 'error');
} finally {
button.disabled = false;
}
}
function applySeoStatus(seo) {
if (!seo || !Array.isArray(seo.items)) return;
seoStatus = seo;
const banner = document.getElementById('seoBlockedWarning');
if (banner) {
const where = [];
for (const item of seo.items) {
if (item.state !== 'warning') continue;
if (item.reason === 'blocked') where.push('robots.txt');
if (item.reason === 'noindex') for (const place of item.where || []) where.push(t('seo_check.where_' + place));
}
const text = t(seo.noindexSite ? 'seo_check.banner_text_setting' : 'seo_check.banner_text_other', { where: where.join(', ') });
const body = document.getElementById('seoBlockedText');
if (body.textContent !== text) body.textContent = text;
document.getElementById('seoBlockedAllow').hidden = !seo.noindexSite;
banner.hidden = !seo.blocked;
}
updateSystemBadge();
const panel = document.getElementById('systemSeoPanel');
if (panel) panel.replaceWith(renderSeoPanel(seo));
}
function seoItemText(item) {
let key = item.id + '_' + (item.reason || item.state);
if (item.state === 'skipped') key = 'skipped_' + (item.reason || 'local');
else if (item.state === 'pending') key = seoCheckRequest || item.running ? 'running' : 'pending';
else if (item.state === 'unknown') key = 'unknown_' + (item.reason || 'unreachable');
return t('seo_check.' + key, {
url: item.url || '', detail: item.detail || '', count: item.count || 0, total: item.total || 0,
where: (item.where || []).map(place => t('seo_check.where_' + place)).join(', ')
});
}
function renderSeoDetails(item) {
if (!['warning', 'note', 'unknown'].includes(item.state)) return null;
const blocks = [];
const paragraph = (text) => { const p = document.createElement('p'); p.textContent = text; blocks.push(p); };
if (item.url) {
const p = document.createElement('p'); const code = document.createElement('code'); code.textContent = item.url;
p.append(t('seo_check.url') + ' ', code); blocks.push(p);
}
if (item.state !== 'unknown') paragraph(t('seo_check.expected') + ' ' + t('seo_check.' + item.id + '_expected'));
if (item.state === 'unknown') paragraph(t('seo_check.unknown_fix', { url: item.url || '' }));
else if (item.fix) paragraph(t('seo_check.' + item.id + '_fix_' + item.fix, { url: item.url || '', detail: item.detail || '' }));
if (item.source === 'setting') {
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary btn-sm';
button.textContent = t('seo_check.allow'); button.onclick = () => allowSeoIndexing(button);
blocks.push(button);
}
const details = document.createElement('details'); details.className = 'system-check__details';
details.open = item.state === 'warning';
const summary = document.createElement('summary'); summary.textContent = t('security_check.how_to_fix');
details.append(summary, ...blocks);
return details;
}
function renderSeoPanel(seo) {
const section = document.createElement('section');
section.className = 'dashboard-panel system-security'; section.id = 'systemSeoPanel';
const header = document.createElement('div'); header.className = 'dashboard-panel-header';
const heading = document.createElement('h3'); heading.textContent = t('seo_check.title');
header.append(heading);
const actions = document.createElement('div'); actions.className = 'system-security__actions';
if (seo.checkedAt) {
const checked = document.createElement('span');
checked.textContent = t('security_check.checked_at', { date: rjFormatDate(seo.checkedAt, 'datetime') });
actions.append(checked);
}
if (seo.canRun) {
const busy = !!seoCheckRequest || !!seo.running;
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary btn-sm';
button.textContent = t(busy ? 'security_check.running' : 'security_check.run'); button.disabled = busy;
button.onclick = () => runSeoCheck(true).catch(error => {
showToast(error.message, 'error');
if (seoStatus) applySeoStatus(seoStatus);
});
actions.append(button);
}
header.append(actions);
section.append(header);
for (const item of seo.items) {
const check = document.createElement('div'); check.className = 'system-check system-check--' + item.state;
const row = document.createElement('div'); row.className = 'system-status-row';
const name = document.createElement('strong'); name.textContent = t('seo_check.' + item.id);
const text = document.createElement('span'); text.className = 'system-check__state'; text.textContent = seoItemText(item);
row.append(name, text); check.append(row);
const details = renderSeoDetails(item);
if (details) check.append(details);
section.append(check);
}
return section;
}
async function loadSystemStatus() {
const target = document.getElementById('systemStatusBody');
if (!target) return;
target.textContent = t('system.loading');
const row = (label, value) => {
const item = document.createElement('div'); item.className = 'system-status-row';
const name = document.createElement('strong'); name.textContent = label;
const text = document.createElement('span'); text.textContent = value;
item.append(name, text); return item;
};
const group = title => {
const section = document.createElement('section'); section.className = 'dashboard-panel';
const heading = document.createElement('h3'); heading.textContent = title;
section.append(heading); target.append(section); return section;
};
try {
const scheme = window.location.protocol === 'https:' ? 'https' : 'http';
const response = await fetch('api.php?action=system-status&scheme=' + scheme);
const result = await response.json();
if (!result.success) throw new Error(result.message);
target.replaceChildren();
const data = result.data;
if (data.security) {
const placeholder = document.createElement('section'); placeholder.id = 'systemSecurityPanel';
target.append(placeholder);
applySecurityStatus(data.security);
// Refresh an outdated or missing probe in the background; local hosts only on request.
const folders = data.security.items.find(item => item.id === 'folders') || {};
if (folders.canRun && folders.stale && !data.security.local) {
runSecurityCheck(false).catch(() => { if (securityStatus) applySecurityStatus(securityStatus); });
}
}
if (data.seo) {
const placeholder = document.createElement('section'); placeholder.id = 'systemSeoPanel';
target.append(placeholder);
applySeoStatus(data.seo);
if (data.seo.canRun && data.seo.stale && !data.seo.local) {
runSeoCheck(false).catch(() => { if (seoStatus) applySeoStatus(seoStatus); });
}
}
const rjMegabytes = bytes => { const mb = bytes / 1048576; return mb >= 1 && Math.abs(mb - Math.round(mb)) < 0.05 ? String(Math.round(mb)) : String(Math.round(mb * 10) / 10); };
const runtime = group(t('system.runtime'));
runtime.append(row('PHP', data.php));
for (const ext of data.extensions) runtime.append(row(ext.name, t(ext.available ? 'system.available' : 'system.missing')));
if (data.agent) {
const agent = group(t('system.agent'));
agent.append(row(t('system.agent_state'), t(data.agent.enabled ? 'system.agent_on' : 'system.agent_off')));
agent.append(row(t('system.agent_tools'), t(data.agent.toolCalling ? 'system.available' : 'system.missing')));
agent.append(row(t('system.agent_gd'), t(data.agent.gd ? 'system.available' : 'system.missing')));
agent.append(row(t('system.agent_webp'), t(data.agent.webp ? 'system.available' : 'system.missing')));
agent.append(row(t('system.agent_research'), t(data.agent.research ? 'system.configured' : 'system.not_configured')));
agent.append(row(t('system.agent_stock'), t(data.agent.stock ? 'system.configured' : 'system.not_configured')));
agent.append(row('content/ai-agent/', t(data.agent.storageWritable ? 'system.writable' : 'system.not_writable')));
const note = document.createElement('p'); note.textContent = t('system.agent_storage_note'); agent.append(note);
}
if (data.uploads) {
const up = data.uploads;
const uploads = group(t('system.uploads'));
uploads.append(row(t('system.upload_server_limit'), up.serverMaxBytes ? rjMegabytes(up.serverMaxBytes) + ' MB (upload_max_filesize ' + up.uploadMaxFilesize + ', post_max_size ' + up.postMaxSize + ')' : t('system.upload_unlimited')));
uploads.append(row(t('system.upload_image_limit'), rjMegabytes(up.imageMaxBytes) + ' MB' + (up.serverMaxBytes && up.serverMaxBytes < up.imageMaxBytes ? ' · ' + t('system.upload_server_bound', { max: rjMegabytes(up.serverMaxBytes) }) : '')));
uploads.append(row(t('system.upload_web_version'), t(!up.webVersion ? 'system.upload_off' : (up.webp ? 'system.upload_on' : 'system.upload_no_webp'))));
uploads.append(row(t('system.upload_memory'), up.memoryLimit));
const uploadNote = document.createElement('p'); uploadNote.textContent = t('system.upload_note'); uploads.append(uploadNote);
}
const storage = group(t('system.storage'));
for (const path of data.paths) storage.append(row(path.name, t(path.writable ? 'system.writable' : 'system.not_writable')));
storage.append(row(t('system.last_backup'), data.lastBackup ? rjFormatDate(data.lastBackup, 'datetime') : t('system.no_backup')));
const jobs = group(t('system.failed_jobs'));
if (!data.failedJobs.length) jobs.append(row(t('system.state'), t('system.no_failed_jobs')));
for (const job of data.failedJobs) {
const details = document.createElement('details'); const summary = document.createElement('summary');
summary.textContent = job.id; const text = document.createElement('p'); text.textContent = job.error;
details.append(summary, text); jobs.append(details);
}
const requests = group(t('system.requests'));
const hint = document.createElement('p'); hint.textContent = t('system.resolve_hint'); requests.append(hint);
if (!data.requests.length) requests.append(row(t('system.state'), t('system.no_requests')));
for (const entry of data.requests) {
const article = document.createElement('article'); article.className = 'system-request';
article.append(row(entry.provider, formatAiCents(entry.reservedCents)));
const details = document.createElement('details'); const summary = document.createElement('summary'); summary.textContent = t('system.details');
details.append(summary, row(t('system.request_id'), entry.id), row(t('system.provider_tasks'), entry.tasks.join(', ') || '—'));
article.append(details);
const actions = document.createElement('div'); actions.className = 'system-request-actions';
for (const resolution of ['charged', 'released']) {
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary btn-sm';
button.textContent = t('system.resolve_' + resolution); button.disabled = !entry.resolvable;
button.onclick = async () => {
button.disabled = true;
const form = new FormData(); form.set('action', 'ai-resolve-request'); form.set('csrf_token', CSRF_TOKEN);
form.set('request_id', entry.id); form.set('resolution', resolution);
try {
const result = await (await fetch('api.php', { method: 'POST', body: form })).json();
if (!result.success) throw new Error(result.message);
await loadSystemStatus();
} catch (error) { showToast(error.message, 'error'); button.disabled = false; }
};
actions.append(button);
}
article.append(actions); requests.append(article);
}
} catch (_) { target.textContent = t('system.load_failed'); }
}
// Administrators: check security in the background once the dashboard has loaded.
// The server reuses a recent result and never delays the dashboard itself.
if (VALID_DASHBOARD_TABS.indexOf('system') !== -1) {
window.addEventListener('load', () => setTimeout(() => { runSecurityCheck(false).catch(() => {}); }, 1000));
window.addEventListener('load', () => setTimeout(() => { runSeoCheck(false).catch(() => {}); }, 1500));
}

View File

@ -0,0 +1,442 @@
<?php if (!defined('RAIJU_DASHBOARD')) { http_response_code(404); exit; } ?>
// ============================================================
// USER MANAGEMENT
// ============================================================
<?php if ($isAdminUser): ?>
var CURRENT_USER_ID = <?php echo json_encode($_SESSION['admin_user_id'] ?? ''); ?>;
function loadUsers() {
fetch('api.php?action=list-users&csrf_token=' + encodeURIComponent(CSRF_TOKEN))
.then(r => r.json())
.then(result => {
if (!result.success) return;
renderUsersTable(result.data);
});
}
function renderUsersTable(users) {
var tbody = document.getElementById('usersTableBody');
if (!tbody) return;
tbody.innerHTML = '';
users.forEach(function(user) {
var isCurrentUser = user.id === CURRENT_USER_ID;
var tr = document.createElement('tr');
if (isCurrentUser) tr.classList.add('users-table__current');
var roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1);
tr.innerHTML =
'<td>' + escapeHtml(user.username) + (isCurrentUser ? ' <em>(' + t('settings.user_you') + ')</em>' : '') + '</td>' +
'<td>' + escapeHtml(user.email || '—') + '</td>' +
'<td><span class="role-badge role-badge--' + user.role + '">' + roleLabel + '</span></td>' +
'<td>' + (user.lastLogin ? rjFormatDate(user.lastLogin, 'datetime') : '—') + '</td>' +
'<td class="users-table__actions">' +
'<button type="button" class="btn btn-sm btn-secondary" onclick="editUser(\'' + user.id + '\')" title="' + t('pages.edit') + '">' + t('pages.edit') + '</button> ' +
'<button type="button" class="btn-icon" onclick="resetUserPassword(\'' + user.id + '\', \'' + escapeHtml(user.username) + '\')" title="' + t('settings.reset_password') + '"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></button> ' +
(isCurrentUser ? '' : '<button type="button" class="btn-icon btn-icon--danger" onclick="deleteUserConfirm(\'' + user.id + '\', \'' + escapeHtml(user.username) + '\')" title="' + t('btn.delete') + '"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>') +
'</td>';
tbody.appendChild(tr);
});
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function generatePassword() {
var upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
var lower = 'abcdefghjkmnpqrstuvwxyz';
var digits = '23456789';
var special = '!@#$%&*+-=?';
var all = upper + lower + digits + special;
var arr = new Uint32Array(20);
crypto.getRandomValues(arr);
var pw = '';
pw += upper[arr[0] % upper.length];
pw += lower[arr[1] % lower.length];
pw += digits[arr[2] % digits.length];
pw += special[arr[3] % special.length];
for (var i = 4; i < 16; i++) pw += all[arr[i] % all.length];
var a = pw.split('');
var s = new Uint32Array(a.length);
crypto.getRandomValues(s);
for (var j = a.length - 1; j > 0; j--) {
var k = s[j] % (j + 1);
var tmp = a[j]; a[j] = a[k]; a[k] = tmp;
}
return a.join('');
}
// Open add user modal
document.getElementById('addUserBtn').addEventListener('click', function() {
document.getElementById('userFormId').value = '';
document.getElementById('userFormUsername').value = '';
document.getElementById('userFormEmail').value = '';
document.getElementById('userFormRole').value = 'editor';
document.getElementById('userFormPassword').value = '';
document.getElementById('userFormPasswordGroup').style.display = '';
document.getElementById('userGeneratedPw').style.display = 'none';
document.getElementById('userModalTitle').textContent = t('settings.add_user');
document.getElementById('userFormPassword').required = true;
closeAllComboboxes();
document.getElementById('userModalOverlay').style.display = 'flex';
});
function closeUserModal() {
document.getElementById('userModalOverlay').style.display = 'none';
}
// Generate password in user modal
document.getElementById('userGenPwBtn').addEventListener('click', function() {
var pw = generatePassword();
document.getElementById('userFormPassword').value = pw;
document.getElementById('userFormPassword').type = 'text';
document.getElementById('userGeneratedPwText').textContent = pw;
document.getElementById('userGeneratedPw').style.display = 'flex';
setTimeout(function() {
document.getElementById('userFormPassword').type = 'password';
}, 30000);
});
// Edit user
var _usersCache = [];
function editUser(userId) {
fetch('api.php?action=list-users&csrf_token=' + encodeURIComponent(CSRF_TOKEN))
.then(r => r.json())
.then(result => {
if (!result.success) return;
var user = result.data.find(u => u.id === userId);
if (!user) return;
document.getElementById('userFormId').value = user.id;
document.getElementById('userFormUsername').value = user.username;
document.getElementById('userFormEmail').value = user.email || '';
document.getElementById('userFormRole').value = user.role;
document.getElementById('userFormPassword').value = '';
document.getElementById('userFormPasswordGroup').style.display = 'none';
document.getElementById('userGeneratedPw').style.display = 'none';
document.getElementById('userModalTitle').textContent = t('settings.edit_user');
document.getElementById('userFormPassword').required = false;
closeAllComboboxes();
document.getElementById('userModalOverlay').style.display = 'flex';
});
}
// Submit user form (add or edit)
document.getElementById('userForm').addEventListener('submit', async function(e) {
e.preventDefault();
var userId = document.getElementById('userFormId').value;
var isEdit = !!userId;
var formData = new FormData();
formData.append('action', isEdit ? 'update-user' : 'create-user');
formData.append('csrf_token', CSRF_TOKEN);
formData.append('username', document.getElementById('userFormUsername').value);
formData.append('email', document.getElementById('userFormEmail').value);
formData.append('role', document.getElementById('userFormRole').value);
if (isEdit) {
formData.append('user_id', userId);
} else {
formData.append('password', document.getElementById('userFormPassword').value);
}
try {
var response = await fetch('api.php', { method: 'POST', body: formData });
var result = await response.json();
if (result.success) {
closeUserModal();
loadUsers();
showToast(result.message, 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
});
// Reset password for a user
function resetUserPassword(userId, username) {
document.getElementById('resetPwUserId').value = userId;
document.getElementById('resetPwInput').value = '';
document.getElementById('resetPwGenerated').style.display = 'none';
document.getElementById('resetPwModalTitle').textContent = t('settings.reset_password') + ' — ' + username;
// Reset requirement indicators
document.querySelectorAll('#resetPwReqs .requirement').forEach(function(el) { el.classList.remove('met'); });
closeAllComboboxes();
document.getElementById('resetPwModalOverlay').style.display = 'flex';
setTimeout(function() { document.getElementById('resetPwInput').focus(); }, 100);
}
function closeResetPwModal() {
document.getElementById('resetPwModalOverlay').style.display = 'none';
}
// Generate password in reset modal
document.getElementById('resetPwGenBtn').addEventListener('click', function() {
var pw = generatePassword();
document.getElementById('resetPwInput').value = pw;
document.getElementById('resetPwInput').type = 'text';
document.getElementById('resetPwGeneratedText').textContent = pw;
document.getElementById('resetPwGenerated').style.display = 'flex';
validatePasswordRequirements(pw, '#resetPwReqs');
setTimeout(function() {
document.getElementById('resetPwInput').type = 'password';
}, 30000);
});
// Live validation for reset password
document.getElementById('resetPwInput').addEventListener('input', function() {
validatePasswordRequirements(this.value, '#resetPwReqs');
});
function validatePasswordRequirements(pw, containerSel) {
var container = document.querySelector(containerSel);
if (!container) return;
var checks = {
length: pw.length >= 8,
upper: /[A-Z]/.test(pw),
lower: /[a-z]/.test(pw),
digit: /[0-9]/.test(pw),
special: /[^A-Za-z0-9]/.test(pw)
};
Object.keys(checks).forEach(function(key) {
var el = container.querySelector('[data-req="' + key + '"]');
if (el) el.classList.toggle('met', checks[key]);
});
}
// Submit reset password form
document.getElementById('resetPwForm').addEventListener('submit', async function(e) {
e.preventDefault();
var userId = document.getElementById('resetPwUserId').value;
var pw = document.getElementById('resetPwInput').value;
var formData = new FormData();
formData.append('action', 'admin-reset-password');
formData.append('csrf_token', CSRF_TOKEN);
formData.append('user_id', userId);
formData.append('password', pw);
try {
var response = await fetch('api.php', { method: 'POST', body: formData });
var result = await response.json();
if (result.success) {
closeResetPwModal();
showToast(result.message, 'success');
} else {
showToast(result.message, 'error');
}
} catch (error) {
showToast(t('toast.error_generic', {message: error.message}), 'error');
}
});
// Delete user
function deleteUserConfirm(userId, username) {
showModal(
t('settings.delete_user'),
t('settings.delete_user_confirm', {username: username}),
function() {
var formData = new FormData();
formData.append('action', 'delete-user');
formData.append('csrf_token', CSRF_TOKEN);
formData.append('user_id', userId);
fetch('api.php', { method: 'POST', body: formData })
.then(r => r.json())
.then(result => {
if (result.success) {
closeModal();
loadUsers();
showToast(result.message, 'success');
} else {
showToast(result.message, 'error');
}
});
}
);
}
// Load users when the users panel becomes visible
var _usersLoaded = false;
var _menuOrderLoaded = false;
// Watch for settings tab switches to load data on demand
document.querySelectorAll('.settings-tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
if (this.getAttribute('data-settings-action')) return;
var tab = this.getAttribute('data-settings-tab');
if (!tab) return;
loadSettingsTabData(tab);
});
});
// ============================================================
// MENU ORDER
// ============================================================
var _menuOrderItems = [];
var menuOrderSelect = document.getElementById('menuOrderSelect');
var menuOrderList = document.getElementById('menuOrderList');
var menuOrderEmpty = document.getElementById('menuOrderEmpty');
var saveMenuOrderBtn = document.getElementById('saveMenuOrderBtn');
if (menuOrderSelect) {
menuOrderSelect.addEventListener('change', function() {
loadMenuOrder();
});
}
if (saveMenuOrderBtn) {
saveMenuOrderBtn.addEventListener('click', function() {
saveMenuOrder();
});
}
async function loadMenuOrder() {
var menuId = menuOrderSelect ? menuOrderSelect.value : '';
if (!menuId) return;
var defaultLang = '<?php echo defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'; ?>';
try {
var resp = await fetch('api.php?action=get-menu-items&menu=' + encodeURIComponent(menuId) + '&lang=' + encodeURIComponent(defaultLang));
var result = await resp.json();
if (result.success && result.data && result.data.items) {
_menuOrderItems = result.data.items;
renderMenuOrderList();
} else {
_menuOrderItems = [];
renderMenuOrderList();
}
} catch (e) {
showToast(t('toast.error'), 'error');
}
}
function renderMenuOrderList() {
if (!menuOrderList) return;
if (_menuOrderItems.length === 0) {
menuOrderList.style.display = 'none';
menuOrderEmpty.style.display = 'block';
if (saveMenuOrderBtn) saveMenuOrderBtn.disabled = true;
return;
}
menuOrderList.style.display = 'block';
menuOrderEmpty.style.display = 'none';
if (saveMenuOrderBtn) saveMenuOrderBtn.disabled = false;
var dragGripSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>';
var html = '';
_menuOrderItems.forEach(function(item, i) {
html += '<div class="menu-order-item" data-index="' + i + '" draggable="true">';
html += '<span class="menu-order-item__drag-handle">' + dragGripSvg + '</span>';
html += '<span class="menu-order-item__label">' + escapeHtml(item.label || item.page || '') + '</span>';
html += '<span class="menu-order-item__slug">' + escapeHtml(item.page || '') + '</span>';
html += '<span class="menu-order-item__actions">';
html += '<button type="button" class="btn-icon" title="' + t('btn.move_up') + '"' + (i === 0 ? ' disabled' : '') + ' onclick="moveMenuItem(' + i + ', -1)">';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>';
html += '</button>';
html += '<button type="button" class="btn-icon" title="' + t('btn.move_down') + '"' + (i === _menuOrderItems.length - 1 ? ' disabled' : '') + ' onclick="moveMenuItem(' + i + ', 1)">';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>';
html += '</button>';
html += '</span>';
html += '</div>';
});
menuOrderList.innerHTML = html;
initMenuDragAndDrop();
}
function moveMenuItem(index, direction) {
var newIndex = index + direction;
if (newIndex < 0 || newIndex >= _menuOrderItems.length) return;
var item = _menuOrderItems.splice(index, 1)[0];
_menuOrderItems.splice(newIndex, 0, item);
renderMenuOrderList();
}
// Drag and drop for menu order items
var _menuDragIndex = null;
function initMenuDragAndDrop() {
var items = menuOrderList.querySelectorAll('.menu-order-item');
items.forEach(function(el) {
el.addEventListener('dragstart', function(e) {
_menuDragIndex = parseInt(this.dataset.index);
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
el.addEventListener('dragend', function() {
_menuDragIndex = null;
this.classList.remove('dragging');
items.forEach(function(item) {
item.classList.remove('drag-over-top', 'drag-over-bottom');
});
});
el.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
var rect = this.getBoundingClientRect();
var midY = rect.top + rect.height / 2;
this.classList.remove('drag-over-top', 'drag-over-bottom');
if (e.clientY < midY) {
this.classList.add('drag-over-top');
} else {
this.classList.add('drag-over-bottom');
}
});
el.addEventListener('dragleave', function() {
this.classList.remove('drag-over-top', 'drag-over-bottom');
});
el.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('drag-over-top', 'drag-over-bottom');
var targetIndex = parseInt(this.dataset.index);
if (_menuDragIndex === null || _menuDragIndex === targetIndex) return;
var rect = this.getBoundingClientRect();
var midY = rect.top + rect.height / 2;
var insertBefore = e.clientY < midY;
var item = _menuOrderItems.splice(_menuDragIndex, 1)[0];
var newIndex = insertBefore ? targetIndex : targetIndex + 1;
if (_menuDragIndex < targetIndex) newIndex--;
_menuOrderItems.splice(newIndex, 0, item);
renderMenuOrderList();
});
});
}
async function saveMenuOrder() {
var menuId = menuOrderSelect ? menuOrderSelect.value : '';
if (!menuId) return;
var defaultLang = '<?php echo defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'; ?>';
var order = _menuOrderItems.map(function(item) { return item.page || ''; }).filter(Boolean);
var formData = new FormData();
formData.append('action', 'save-menu-order');
formData.append('menu', menuId);
formData.append('lang', defaultLang);
formData.append('order', JSON.stringify(order));
formData.append('csrf_token', CSRF_TOKEN);
try {
var resp = await fetch('api.php', { method: 'POST', body: formData });
var result = await resp.json();
if (result.success) {
showToast(t('settings.menu_order_saved'), 'success');
} else {
showToast(result.message || t('toast.error'), 'error');
}
} catch (e) {
showToast(t('toast.error'), 'error');
}
}
<?php endif; ?>

98
admin/fonts.css Normal file
View File

@ -0,0 +1,98 @@
/*
* Raiju Admin — local web fonts
* Loaded by the admin pages only. A face is downloaded only when the
* selected dashboard font (data-font on <html>) actually uses it, so the
* default "System" setting transfers no font file at all.
* Inter, IBM Plex Sans, Atkinson Hyperlegible Next and Public Sans are the
* variable fonts from Google Fonts (SIL Open Font License 1.1), latin and
* latin-ext subsets; Geist Mono (MIT) serves the Console theme.
* See THIRD-PARTY-NOTICES.md.
*/
@font-face {
font-family: 'Geist Mono';
src: url('fonts/geist-mono-variable.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Inter – latin */
@font-face {
font-family: 'Inter';
src: url('fonts/inter-variable-latin.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Inter – latin-ext */
@font-face {
font-family: 'Inter';
src: url('fonts/inter-variable-latin-ext.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* IBM Plex Sans – latin */
@font-face {
font-family: 'IBM Plex Sans';
src: url('fonts/ibm-plex-sans-variable-latin.woff2') format('woff2');
font-weight: 100 700;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* IBM Plex Sans – latin-ext */
@font-face {
font-family: 'IBM Plex Sans';
src: url('fonts/ibm-plex-sans-variable-latin-ext.woff2') format('woff2');
font-weight: 100 700;
font-style: normal;
font-display: swap;
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Atkinson Hyperlegible Next – latin */
@font-face {
font-family: 'Atkinson Hyperlegible Next';
src: url('fonts/atkinson-hyperlegible-next-variable-latin.woff2') format('woff2');
font-weight: 200 800;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Atkinson Hyperlegible Next – latin-ext */
@font-face {
font-family: 'Atkinson Hyperlegible Next';
src: url('fonts/atkinson-hyperlegible-next-variable-latin-ext.woff2') format('woff2');
font-weight: 200 800;
font-style: normal;
font-display: swap;
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Public Sans – latin */
@font-face {
font-family: 'Public Sans';
src: url('fonts/public-sans-variable-latin.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Public Sans – latin-ext */
@font-face {
font-family: 'Public Sans';
src: url('fonts/public-sans-variable-latin-ext.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

768
admin/index.php Normal file
View File

@ -0,0 +1,768 @@
<?php
/**
* Admin Login
*/
// Redirect to setup if not yet configured
if (!file_exists(__DIR__ . '/config.php')) {
header('Location: setup.php');
exit;
}
require_once 'config.php';
require_once __DIR__ . '/../includes/version.php';
require_once __DIR__ . '/lang/i18n.php';
require_once __DIR__ . '/users.php';
require_once __DIR__ . '/../includes/asset-helpers.php';
ensureUsersFile();
require_once __DIR__ . '/../includes/session-helper.php';
raijuSessionStart();
$error = '';
$lockoutWait = 0; // seconds remaining for countdown
$timeoutNoticeTimestamp = isset($_GET['timeout']) ? (int)$_GET['timeout'] : 0;
$showTimeoutNotice = $timeoutNoticeTimestamp > 0 && abs(time() - $timeoutNoticeTimestamp) <= 120;
// ============================================================
// BRUTE FORCE PROTECTION (IP-based, file-backed)
// ============================================================
define('LOGIN_ATTEMPTS_FILE', __DIR__ . '/../content/login_attempts.json');
define('BRUTE_FORCE_DELAY_STEP', 15); // seconds per failed attempt after threshold
define('BRUTE_FORCE_THRESHOLD', 3); // free attempts before delay kicks in
define('BRUTE_FORCE_MAX_ATTEMPTS', 20); // hard lockout after this many
define('BRUTE_FORCE_LOCKOUT_TIME', 86400); // 24 hours hard lockout
function getIpHash() {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
return hash('sha256', $ip . 'raiju');
}
function loadAttempts() {
if (!file_exists(LOGIN_ATTEMPTS_FILE)) {
return [];
}
$data = json_decode(file_get_contents(LOGIN_ATTEMPTS_FILE), true);
return is_array($data) ? $data : [];
}
function saveAttempts($data) {
// Cleanup entries older than 24 hours
$now = time();
foreach ($data as $hash => $entry) {
if ($now - ($entry['last'] ?? 0) > BRUTE_FORCE_LOCKOUT_TIME) {
unset($data[$hash]);
}
}
$dir = dirname(LOGIN_ATTEMPTS_FILE);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents(LOGIN_ATTEMPTS_FILE, json_encode($data, JSON_PRETTY_PRINT), LOCK_EX);
}
/**
* Check if login is allowed for this IP.
* Returns ['allowed' => bool, 'wait' => seconds, 'locked' => bool]
*/
function checkBruteForce() {
$ipHash = getIpHash();
$data = loadAttempts();
$entry = $data[$ipHash] ?? null;
if ($entry === null) {
return ['allowed' => true, 'wait' => 0, 'locked' => false];
}
$now = time();
$count = $entry['count'] ?? 0;
$lastAttempt = $entry['last'] ?? 0;
// Hard lockout after MAX_ATTEMPTS
if ($count >= BRUTE_FORCE_MAX_ATTEMPTS) {
$remaining = BRUTE_FORCE_LOCKOUT_TIME - ($now - $lastAttempt);
if ($remaining > 0) {
return ['allowed' => false, 'wait' => $remaining, 'locked' => true];
}
// Lockout expired, clean up
unset($data[$ipHash]);
saveAttempts($data);
return ['allowed' => true, 'wait' => 0, 'locked' => false];
}
// Progressive delay after threshold
if ($count >= BRUTE_FORCE_THRESHOLD) {
$delay = ($count - BRUTE_FORCE_THRESHOLD + 1) * BRUTE_FORCE_DELAY_STEP;
$remaining = $delay - ($now - $lastAttempt);
if ($remaining > 0) {
return ['allowed' => false, 'wait' => $remaining, 'locked' => false];
}
}
return ['allowed' => true, 'wait' => 0, 'locked' => false];
}
function recordFailedAttempt() {
$ipHash = getIpHash();
$data = loadAttempts();
$now = time();
if (!isset($data[$ipHash])) {
$data[$ipHash] = ['count' => 0, 'first' => $now, 'last' => $now];
}
$data[$ipHash]['count']++;
$data[$ipHash]['last'] = $now;
saveAttempts($data);
}
function resetAttempts() {
$ipHash = getIpHash();
$data = loadAttempts();
unset($data[$ipHash]);
saveAttempts($data);
}
function raijuIsLoopbackName(string $host): bool {
return raijuSessionIsLoopback($host);
}
function raijuDevLoginAvailable(): bool {
return raijuSessionDevLoginAllowed();
}
/**
* Separate rate-limiting for password reset requests.
* Uses a "reset_" prefix to avoid sharing counters with login attempts.
* More lenient: 5 attempts per hour.
*/
function checkResetRateLimit() {
$key = 'reset_' . getIpHash();
$data = loadAttempts();
$entry = $data[$key] ?? null;
if ($entry === null) {
return ['allowed' => true];
}
$now = time();
$count = $entry['count'] ?? 0;
$firstAttempt = $entry['first'] ?? $now;
// Reset counter after 1 hour
if ($now - $firstAttempt > 3600) {
unset($data[$key]);
saveAttempts($data);
return ['allowed' => true];
}
// Allow 5 attempts per hour
if ($count >= 5) {
return ['allowed' => false];
}
return ['allowed' => true];
}
function recordResetAttempt() {
$key = 'reset_' . getIpHash();
$data = loadAttempts();
$now = time();
if (!isset($data[$key])) {
$data[$key] = ['count' => 0, 'first' => $now, 'last' => $now];
}
$data[$key]['count']++;
$data[$key]['last'] = $now;
saveAttempts($data);
}
// ============================================================
// PASSWORD STRENGTH CHECK
// ============================================================
function isPasswordWeak($password) {
if (strlen($password) < 8) return true;
if (!preg_match('/[A-Z]/', $password)) return true;
if (!preg_match('/[a-z]/', $password)) return true;
if (!preg_match('/[0-9]/', $password)) return true;
if (!preg_match('/[^A-Za-z0-9]/', $password)) return true;
return false;
}
// ============================================================
// REDIRECT VALIDATION
// ============================================================
function validateRedirectUrl($url) {
return raijuSessionRedirectUrl(is_string($url) ? $url : '');
}
// ============================================================
// LOGOUT
// ============================================================
if (isset($_GET['logout'])) {
$redirect = validateRedirectUrl($_GET['redirect'] ?? ($_SERVER['HTTP_REFERER'] ?? '/'));
session_destroy();
header('Location: ' . $redirect);
exit;
}
// Save redirect URL (where to go after login).
// Behaviour depends on the `general.frontendLoginRedirect` site setting:
// 'auto' — return to ?redirect=… page after login (default)
// 'dashboard' — always land in the dashboard; the dashboard banner offers
// a link back to the source page via $_SESSION['login_source_url'].
//
// We honour ?redirect=… on every GET so that an admin who arrives via the
// footer dblclick gets the freshest source URL even if a stale value lingers
// in the session from an earlier visit.
if (!empty($_GET['redirect'])) {
$sourceUrl = validateRedirectUrl($_GET['redirect']);
$_SESSION['login_source_url'] = $sourceUrl;
$settings = (defined('SETTINGS_PATH') && file_exists(SETTINGS_PATH))
? json_decode(file_get_contents(SETTINGS_PATH), true)
: [];
$loginMode = $settings['general']['frontendLoginRedirect'] ?? 'auto';
if ($loginMode === 'auto') {
$_SESSION['redirect_after_login'] = $sourceUrl;
} else {
$_SESSION['redirect_after_login'] = 'dashboard.php';
}
} elseif (!isset($_SESSION['redirect_after_login'])) {
$_SESSION['redirect_after_login'] = 'dashboard.php';
}
// Already logged in? Redirect to saved page
if (raijuSessionValidate()) {
$redirect = $_SESSION['redirect_after_login'] ?? 'dashboard.php';
unset($_SESSION['redirect_after_login']);
header('Location: ' . $redirect);
exit;
}
// ============================================================
// LOGIN ATTEMPT
// ============================================================
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
// Dev bypass: opt-in, loopback-only, existing admin users only.
// It must be checked before rate limiting so a local test copy cannot lock
// Codex/Claude out of the standard admin/dev workflow after failed probes.
// Special case: username "admin" with password "dev" always works on localhost,
// even if no "admin" user exists — uses a synthetic admin session in that case.
$devLoginAvailable = raijuDevLoginAvailable();
$devPassword = 'dev';
$usedDevLogin = false;
$devLoginCandidate = null;
if ($devLoginAvailable && $password === $devPassword) {
$candidate = findUserByUsername($username);
if ($candidate && ($candidate['role'] ?? '') === 'admin') {
$devLoginCandidate = $candidate;
$usedDevLogin = true;
} elseif ($username === 'admin') {
// Synthetic fallback: no "admin" user exists, create a virtual one for dev only.
$devLoginCandidate = ['id' => 'dev_admin', 'username' => 'admin', 'role' => 'admin', 'email' => ''];
$usedDevLogin = true;
}
}
$bruteCheck = $devLoginCandidate ? ['allowed' => true] : checkBruteForce();
if (!$bruteCheck['allowed']) {
$lockoutWait = $bruteCheck['wait'];
if ($bruteCheck['locked']) {
$hours = ceil($lockoutWait / 3600);
$error = t('login.error_locked', ['time' => $hours . ' ' . t('login.hour')]);
} else {
$error = t('login.error_too_many');
}
} else {
$user = $devLoginCandidate ?: verifyUserPassword($username, $password);
if ($user) {
resetAttempts();
session_regenerate_id(true);
raijuSessionForgetLogin();
$_SESSION['admin_logged_in'] = true;
$_SESSION['admin_login_time'] = time();
$_SESSION['admin_user_id'] = $user['id'];
$_SESSION['admin_username'] = $user['username'];
$_SESSION['admin_role'] = $user['role'];
$_SESSION['admin_password_fingerprint'] = hash('sha256', (string)($user['passwordHash'] ?? ''));
$_SESSION['raiju_dev_login'] = $usedDevLogin;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
updateUserLastLogin($user['id']);
// Check password strength and set warning
if (!$usedDevLogin && isPasswordWeak($password)) {
$_SESSION['password_warning'] = true;
}
// Prompt to add email if missing
if (empty($user['email'])) {
$_SESSION['email_missing'] = true;
}
$redirect = $_SESSION['redirect_after_login'] ?? 'dashboard.php';
unset($_SESSION['redirect_after_login']);
header('Location: ' . $redirect);
exit;
} else {
recordFailedAttempt();
// Re-check to show countdown if threshold now exceeded
$bruteCheck = checkBruteForce();
if (!$bruteCheck['allowed']) {
$lockoutWait = $bruteCheck['wait'];
$error = t('login.error_wait');
} else {
$error = t('login.error_invalid');
}
}
}
}
// ============================================================
// PASSWORD RESET FLOW
// ============================================================
$resetAction = $_GET['action'] ?? '';
$resetSuccess = '';
$resetError = '';
// Handle reset request (email form submission)
if ($resetAction === 'reset-request' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$resetRateCheck = checkResetRateLimit();
if (!$resetRateCheck['allowed']) {
$resetError = t('login.reset_rate_limit');
} else {
$resetEmail = trim($_POST['reset_email'] ?? '');
// Always show same message to prevent user enumeration
$resetSuccess = t('login.reset_sent');
if (!empty($resetEmail) && filter_var($resetEmail, FILTER_VALIDATE_EMAIL)) {
$user = findUserByEmail($resetEmail);
if ($user) {
$rawToken = generateResetToken($user['id']);
if ($rawToken) {
// Send reset email
$settingsPath = defined('SETTINGS_PATH') ? SETTINGS_PATH : __DIR__ . '/../content/settings.json';
$emailConfig = null;
if (file_exists($settingsPath)) {
$s = json_decode(file_get_contents($settingsPath), true);
if (!empty($s['email']) && ($s['email']['method'] ?? 'inactive') !== 'inactive') {
$emailConfig = $s['email'];
}
}
if ($emailConfig) {
require_once __DIR__ . '/../api/SmtpMailer.php';
$resetUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost')
. dirname($_SERVER['SCRIPT_NAME']) . '/index.php?action=reset&token=' . urlencode($rawToken);
$subject = t('login.reset_email_subject');
$body = t('login.reset_email_body', ['url' => $resetUrl, 'username' => $user['username']]);
$fromEmail = $emailConfig['fromEmail'] ?? $emailConfig['recipientEmail'] ?? '';
$fromName = $emailConfig['fromName'] ?? '';
if ($emailConfig['method'] === 'smtp') {
$mailer = new SmtpMailer(
$emailConfig['smtpHost'] ?? '',
intval($emailConfig['smtpPort'] ?? 587),
$emailConfig['smtpUsername'] ?? '',
$emailConfig['smtpPassword'] ?? '',
$emailConfig['smtpEncryption'] ?? 'tls'
);
$mailer->send($resetEmail, $subject, $body, $fromEmail, $fromName);
} elseif ($emailConfig['method'] === 'sendmail') {
$headers = [];
$headers[] = 'From: ' . ($fromName ? "=?UTF-8?B?" . base64_encode($fromName) . "?= <$fromEmail>" : $fromEmail);
$headers[] = 'Content-Type: text/plain; charset=UTF-8';
@mail($resetEmail, '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers));
}
}
}
}
}
// Record attempt to rate-limit (separate counter from login)
recordResetAttempt();
}
}
// Handle password reset (new password form submission)
if ($resetAction === 'reset' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$token = $_POST['token'] ?? '';
$newPassword = $_POST['new_password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
$user = validateResetToken($token);
if (!$user) {
$resetError = t('login.reset_invalid_token');
} elseif (empty($newPassword) || $newPassword !== $confirmPassword) {
$resetError = t('login.reset_password_mismatch');
} elseif (isPasswordWeak($newPassword)) {
$resetError = t('login.reset_password_weak');
} else {
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
if (completePasswordReset($user['id'], $token, $newHash)) {
$resetSuccess = t('login.reset_success');
$resetAction = ''; // Show login form with success message
} else {
$resetError = t('login.reset_invalid_token');
}
}
}
// Check if email is active (for showing forgot password link)
$emailActive = false;
$settingsForEmail = null;
if (defined('SETTINGS_PATH') && file_exists(SETTINGS_PATH)) {
$settingsForEmail = json_decode(file_get_contents(SETTINGS_PATH), true);
if (!empty($settingsForEmail['email']['method']) && $settingsForEmail['email']['method'] !== 'inactive') {
$emailActive = true;
}
}
$devLoginAvailable = raijuDevLoginAvailable();
function raijuLoginAssetPath(string $path): string {
$path = trim($path);
if ($path === '' || preg_match('#^https?://#i', $path)) {
return '';
}
return '/' . ltrim($path, '/');
}
function raijuLoginResolveBrandAsset(array $settings, string $asset): string {
if ($asset === 'favicon') {
return raijuLoginAssetPath((string)($settings['favicon'] ?? '/assets/images/favicon.svg'));
}
if ($asset === 'logo') {
$branding = $settings['branding'] ?? [];
$logo = (string)($branding['adminLogo'] ?? '');
if ($logo === '') {
$logo = (string)($branding['logo'] ?? '');
}
return raijuLoginAssetPath($logo !== '' ? $logo : (string)($settings['favicon'] ?? '/assets/images/favicon.svg'));
}
return '';
}
function raijuLoginCssUrl(string $path): string {
return str_replace(["\\", "\"", "\n", "\r"], ["\\\\", "\\\"", "", ""], $path);
}
function raijuLoginImageLayout(string $layout): string {
if ($layout === 'split') {
return 'left';
}
return in_array($layout, ['none', 'background', 'left', 'right'], true) ? $layout : 'none';
}
function raijuLoginRgbaFromHex(string $hex, int $opacity): string {
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) {
return '';
}
$opacity = max(0, min(100, $opacity));
$red = hexdec(substr($hex, 1, 2));
$green = hexdec(substr($hex, 3, 2));
$blue = hexdec(substr($hex, 5, 2));
$alpha = rtrim(rtrim(sprintf('%.2F', $opacity / 100), '0'), '.');
return "rgba($red, $green, $blue, $alpha)";
}
// Load settings for branding/theme
$_defaultFavicon = defined('RAIJU_DEFAULT_FAVICON') ? RAIJU_DEFAULT_FAVICON : '/assets/images/favicon.svg';
$siteSettings = [
'favicon' => $_defaultFavicon,
'branding' => ['logo' => '', 'logoDark' => '', 'adminLogo' => '', 'name' => '', 'showBranding' => true, 'logoDisplay' => 'both'],
'theme' => ['adminTheme' => 'light', 'preset' => 'soft', 'density' => 'auto', 'font' => 'system', 'primaryColor' => '#3858e9', 'accentColor' => '', 'sidebarBg' => '', 'darkSidebarBg' => ''],
'login' => ['brandAsset' => 'favicon', 'image' => '', 'imageLayout' => 'none', 'overlayColor' => '', 'overlayOpacity' => 86, 'boxStyle' => 'card', 'boxColor' => '', 'boxTextColor' => '']
];
if (defined('SETTINGS_PATH') && file_exists(SETTINGS_PATH)) {
$loadedSettings = json_decode(file_get_contents(SETTINGS_PATH), true);
if (is_array($loadedSettings)) {
// Only merge sub-arrays; skip old-format scalar values (e.g. "theme": "dark")
foreach ($siteSettings as $key => $defaults) {
if (isset($loadedSettings[$key]) && is_array($loadedSettings[$key])) {
$siteSettings[$key] = array_replace($defaults, $loadedSettings[$key]);
}
}
if (!empty($loadedSettings['favicon'])) $siteSettings['favicon'] = $loadedSettings['favicon'];
}
}
$adminTheme = $siteSettings['theme']['adminTheme'] ?? 'light';
$_themePreset = in_array($siteSettings['theme']['preset'] ?? '', ['soft', 'classic', 'paper', 'studio', 'console', 'pop'], true) ? $siteSettings['theme']['preset'] : 'soft';
$_themeFontRaw = $siteSettings['theme']['font'] ?? '';
if ($_themeFontRaw === 'geist') $_themeFontRaw = 'plex';
$_themeFont = in_array($_themeFontRaw, ['system', 'inter', 'plex', 'atkinson', 'public', 'geist-mono'], true) ? $_themeFontRaw : 'system';
$_loginBase = preg_match('/^#[0-9a-fA-F]{6}$/', (string)$siteSettings['theme']['primaryColor']) ? strtolower((string)$siteSettings['theme']['primaryColor']) : '#3858e9';
$_loginAccentRaw = strtolower((string)($siteSettings['theme']['accentColor'] ?? ''));
$_loginAccent = (preg_match('/^#[0-9a-fA-F]{6}$/', $_loginAccentRaw) && $_loginAccentRaw !== $_loginBase) ? $_loginAccentRaw : $_loginBase;
$showBranding = $siteSettings['branding']['showBranding'] ?? true;
// Backend uses the configured login asset, with admin logo preferred for "logo".
$loginSettings = $siteSettings['login'] ?? [];
$loginBrandAsset = (string)($loginSettings['brandAsset'] ?? 'favicon');
$brandLogo = raijuLoginResolveBrandAsset($siteSettings, $loginBrandAsset);
$brandName = $siteSettings['branding']['name'] ?? (defined('SITE_NAME') ? SITE_NAME : 'CMS');
$loginImage = raijuLoginAssetPath((string)($loginSettings['image'] ?? ''));
$loginImageLayout = $loginImage !== '' ? raijuLoginImageLayout((string)($loginSettings['imageLayout'] ?? 'none')) : 'none';
$loginBodyClass = 'login-page';
if ($loginImageLayout === 'background') {
$loginBodyClass .= ' login-page--background';
} elseif ($loginImageLayout === 'left') {
$loginBodyClass .= ' login-page--split login-page--image-left';
} elseif ($loginImageLayout === 'right') {
$loginBodyClass .= ' login-page--split login-page--image-right';
}
$loginCssImage = raijuLoginCssUrl($loginImage);
$loginOverlayColor = trim((string)($loginSettings['overlayColor'] ?? ''));
$loginOverlayOpacity = (int)($loginSettings['overlayOpacity'] ?? 86);
$loginOverlayRgba = raijuLoginRgbaFromHex($loginOverlayColor, $loginOverlayOpacity);
$loginOverlayStyle = $loginOverlayColor !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $loginOverlayColor)
? '; --login-overlay-color: ' . htmlspecialchars($loginOverlayRgba, ENT_QUOTES, 'UTF-8')
: '';
$loginBoxStyle = (string)($loginSettings['boxStyle'] ?? 'card');
if (!in_array($loginBoxStyle, ['card', 'plain'], true)) {
$loginBoxStyle = 'card';
}
$loginBoxColor = trim((string)($loginSettings['boxColor'] ?? ''));
$loginBoxTextColor = trim((string)($loginSettings['boxTextColor'] ?? ''));
$loginBoxVariables = [];
if ($loginBoxStyle === 'card' && $loginBoxColor !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $loginBoxColor)) {
$loginBoxVariables[] = '--login-box-bg: ' . $loginBoxColor;
}
if ($loginBoxTextColor !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $loginBoxTextColor)) {
$loginBoxVariables[] = '--login-box-text: ' . $loginBoxTextColor;
}
$loginBoxStyleAttr = $loginBoxVariables
? ' style="' . htmlspecialchars(implode('; ', $loginBoxVariables), ENT_QUOTES, 'UTF-8') . '"'
: '';
?>
<!DOCTYPE html>
<html lang="en" data-site-theme="<?php echo htmlspecialchars($adminTheme === 'system' ? 'light' : $adminTheme); ?>" data-theme-preset="<?php echo $_themePreset; ?>" data-font="<?php echo $_themeFont; ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Admin Login - <?php echo defined('SITE_NAME') ? SITE_NAME : 'CMS'; ?></title>
<?php
$_loginFavicon = $siteSettings['favicon'] ?? $brandLogo;
$_loginFaviconType = pathinfo($_loginFavicon, PATHINFO_EXTENSION) === 'svg' ? 'image/svg+xml' : 'image/png';
?>
<link rel="icon" href="<?php echo htmlspecialchars($_loginFavicon); ?>" type="<?php echo $_loginFaviconType; ?>">
<link rel="stylesheet" href="fonts.css">
<link rel="stylesheet" href="style.css">
<?php if ($adminTheme === 'system'): ?>
<script>
(function() {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-site-theme', 'dark');
}
})();
</script>
<?php endif; ?>
<style>
:root {
--rj-base: <?php echo $_loginBase; ?>;
--rj-accent-base: <?php echo $_loginAccent; ?>;
}
@supports not (color: oklch(from red l c h)) {
:root {
--rj-primary: <?php echo $_loginBase; ?>;
--rj-primary-btn: radial-gradient(ellipse at 50% 0%, color-mix(in srgb, <?php echo $_loginBase; ?> 70%, white) 0%, <?php echo $_loginBase; ?> 70%);
--rj-primary-btn-hover: radial-gradient(ellipse at 50% 0%, color-mix(in srgb, <?php echo $_loginBase; ?> 50%, white) 0%, <?php echo $_loginBase; ?> 70%);
--rj-brand: <?php echo $_loginAccent; ?>;
}
}
</style>
</head>
<body class="<?php echo htmlspecialchars($loginBodyClass); ?>"<?php echo $loginImageLayout !== 'none' ? ' style="--login-image: url(&quot;' . htmlspecialchars($loginCssImage, ENT_QUOTES, 'UTF-8') . '&quot;)' . $loginOverlayStyle . '"' : ''; ?>>
<div class="login-media" aria-hidden="true"></div>
<div class="login-container login-container--<?php echo htmlspecialchars($loginBoxStyle, ENT_QUOTES, 'UTF-8'); ?>"<?php echo $loginBoxStyleAttr; ?>>
<?php if ($showBranding && $brandLogo !== ''): ?>
<div class="login-logo">
<?php echo raijuIconOrImg($brandLogo, $brandName, ['width' => 40, 'height' => 40]); ?>
</div>
<?php endif; ?>
<h1><?php echo t('login.title'); ?></h1>
<p class="site-name"><?php echo defined('SITE_NAME') ? htmlspecialchars(SITE_NAME) : 'CMS'; ?></p>
<?php if ($resetSuccess && $resetAction === ''): ?>
<div class="success-message"><?php echo htmlspecialchars($resetSuccess); ?></div>
<?php endif; ?>
<?php if ($resetAction === 'reset-request'): ?>
<!-- Forgot Password: enter email -->
<?php if ($resetSuccess): ?>
<div class="success-message"><?php echo htmlspecialchars($resetSuccess); ?></div>
<p class="back-link"><a href="index.php">&larr; <?php echo t('login.back_to_login'); ?></a></p>
<?php else: ?>
<?php if ($resetError): ?>
<div class="error-message"><?php echo htmlspecialchars($resetError); ?></div>
<?php endif; ?>
<p class="reset-desc"><?php echo t('login.reset_desc'); ?></p>
<form method="post" action="?action=reset-request">
<div class="form-group">
<label for="reset_email"><?php echo t('login.reset_email_label'); ?></label>
<input type="email" id="reset_email" name="reset_email" required autofocus>
</div>
<button type="submit" class="btn btn-primary btn-block"><?php echo t('login.reset_send'); ?></button>
</form>
<p class="back-link"><a href="index.php">&larr; <?php echo t('login.back_to_login'); ?></a></p>
<?php endif; ?>
<?php elseif ($resetAction === 'reset'): ?>
<!-- Reset Password: enter new password -->
<?php
$tokenParam = $_GET['token'] ?? $_POST['token'] ?? '';
$tokenUser = validateResetToken($tokenParam);
?>
<?php if (!$tokenUser): ?>
<div class="error-message"><?php echo t('login.reset_invalid_token'); ?></div>
<p class="back-link"><a href="index.php">&larr; <?php echo t('login.back_to_login'); ?></a></p>
<?php else: ?>
<?php if ($resetError): ?>
<div class="error-message"><?php echo htmlspecialchars($resetError); ?></div>
<?php endif; ?>
<p class="reset-desc"><?php echo t('login.reset_new_password_desc'); ?></p>
<form method="post" action="?action=reset">
<input type="hidden" name="token" value="<?php echo htmlspecialchars($tokenParam); ?>">
<div class="form-group">
<label for="new_password"><?php echo t('settings.new_password'); ?></label>
<input type="password" id="new_password" name="new_password" required autofocus>
</div>
<div class="form-group">
<label for="confirm_password"><?php echo t('settings.confirm_password'); ?></label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary btn-block"><?php echo t('login.reset_set_password'); ?></button>
</form>
<p class="back-link"><a href="index.php">&larr; <?php echo t('login.back_to_login'); ?></a></p>
<?php endif; ?>
<?php else: ?>
<!-- Normal Login Form -->
<?php if ($showTimeoutNotice): ?>
<div class="info-message"><?php echo t('login.session_expired'); ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="error-message">
<?php echo htmlspecialchars($error); ?>
<?php if ($lockoutWait > 0): ?>
<div class="lockout-countdown" id="lockoutCountdown">
<span id="countdownSeconds"><?php echo $lockoutWait; ?></span>s remaining
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" action="" id="loginForm">
<div class="form-group">
<label for="username"><?php echo t('login.username'); ?></label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password"><?php echo t('login.password'); ?></label>
<div class="password-input-wrap">
<input type="password" id="password" name="password" required>
<button type="button" class="password-toggle" id="passwordToggle" aria-label="<?php echo t('login.show_password'); ?>" data-label-show="<?php echo t('login.show_password'); ?>" data-label-hide="<?php echo t('login.hide_password'); ?>">
<svg class="icon-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="icon-eye-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
<?php if ($emailActive): ?>
<p class="forgot-password"><a href="?action=reset-request"><?php echo t('login.forgot_password'); ?></a></p>
<?php endif; ?>
</div>
<button type="submit" class="btn btn-primary btn-block" id="loginBtn"<?php echo $lockoutWait > 0 ? ' disabled' : ''; ?>><?php echo t('login.button'); ?></button>
</form>
<p class="back-link"><a href="..">&larr; <?php echo t('login.back_to_site'); ?></a></p>
<?php endif; ?>
<p class="login-version">raiju <?php echo htmlspecialchars(raijuVersion(), ENT_QUOTES, 'UTF-8'); ?></p>
</div>
<script>
(function() {
var toggle = document.getElementById('passwordToggle');
if (toggle) {
toggle.addEventListener('click', function() {
var input = document.getElementById('password');
var isVisible = input.type === 'text';
input.type = isVisible ? 'password' : 'text';
this.classList.toggle('visible', !isVisible);
this.setAttribute('aria-label', isVisible ? this.dataset.labelShow : this.dataset.labelHide);
});
}
})();
</script>
<?php if (isset($_GET['timeout'])): ?>
<script>
(function() {
if (!window.history || !window.history.replaceState) return;
var url = new URL(window.location.href);
url.searchParams.delete('timeout');
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
})();
</script>
<?php endif; ?>
<?php if ($lockoutWait > 0): ?>
<script>
(function() {
var remaining = <?php echo (int)$lockoutWait; ?>;
var countdownEl = document.getElementById('countdownSeconds');
var loginBtn = document.getElementById('loginBtn');
var labelHour = <?php echo json_encode(t('login.hour')); ?>;
var labelMin = <?php echo json_encode(t('login.minute')); ?>;
function formatTime(seconds) {
if (seconds >= 3600) {
var h = Math.floor(seconds / 3600);
var m = Math.ceil((seconds % 3600) / 60);
return h + labelHour + ' ' + m + labelMin;
}
if (seconds >= 60) {
var m = Math.floor(seconds / 60);
var s = seconds % 60;
return m + ':' + (s < 10 ? '0' : '') + s;
}
return seconds;
}
function tick() {
remaining--;
if (remaining <= 0) {
countdownEl.parentElement.parentElement.style.display = 'none';
loginBtn.disabled = false;
loginBtn.textContent = <?php echo json_encode(t('login.button')); ?>;
return;
}
countdownEl.textContent = formatTime(remaining);
setTimeout(tick, 1000);
}
countdownEl.textContent = formatTime(remaining);
loginBtn.disabled = true;
setTimeout(tick, 1000);
})();
</script>
<?php endif; ?>
</body>
</html>

1943
admin/lang/cs.json Normal file

File diff suppressed because it is too large Load Diff

2093
admin/lang/de.json Normal file

File diff suppressed because it is too large Load Diff

213
admin/lang/editor-cs.json Normal file
View File

@ -0,0 +1,213 @@
{
"dashboard": "Nástěnka",
"cancel": "Zrušit",
"save": "Uložit",
"save_exit": "Uložit a ukončit",
"delete": "Smazat",
"close": "Zavřít",
"visual_editor": "Vizuální editor",
"content_editor": "Editor obsahu",
"logout": "Odhlásit se",
"saving": "Ukládání…",
"saving_exit": "Ukládání a ukončení…",
"undo": "Zpět",
"redo": "Znovu",
"edit_mode_hint": "Klikněte na text pro úpravu",
"cat.content": "Obsah",
"cat.media": "Média",
"cat.cards": "Karty a mřížky",
"cat.interactive": "Interaktivní",
"cat.layout": "Rozvržení",
"cat.embed": "Vložit",
"add_block": "Přidat blok",
"search_blocks": "Hledat bloky...",
"edit": "Upravit",
"edit_block_title": "Upravit {label}",
"show": "Zobrazit",
"hide": "Skrýt",
"drag_reorder": "Přetáhněte pro změnu pořadí",
"add_soundcloud": "Přidat SoundCloud",
"add_audio": "Přidat audio MP3",
"add_youtube": "Přidat YouTube",
"add_text": "Přidat textovou sekci",
"add_card": "Přidat kartu",
"bold": "Tučné",
"italic": "Kurzíva",
"insert_link": "Vložit odkaz",
"clean_formatting": "Vyčistit formátování",
"image_manager": "Správce obrázků",
"image_preview": "Náhled",
"event.title": "Název",
"event.location": "Místo",
"event.description": "Popis",
"event.admission": "Vstup",
"event.start_date": "Datum začátku *",
"event.start_time": "Čas začátku",
"event.end_date": "Datum konce",
"event.end_time": "Čas konce",
"event.url": "URL",
"event.image": "Obrázek",
"event.copy_to_all": "Kopírovat do všech",
"event.edit": "Upravit událost",
"event.new": "Nová událost",
"event.delete": "Smazat událost",
"event.delete_confirm": "Opravdu smazat tuto událost?",
"event.cannot_undo": "Tuto akci nelze vrátit zpět.",
"event.date_required": "Datum a název jsou povinné",
"footer.edit": "Upravit zápatí",
"footer.link_text": "Text odkazu",
"footer.link_url": "URL odkazu",
"footer.link_text_lang": "Text odkazu ({lang})",
"footer.copyright": "Copyright (Shortcodes: [id=\"...\"]text[/id])",
"section.delete": "Smazat sekci",
"section.delete_confirm": "Opravdu smazat tuto sekci ({type})?",
"section.reload_hint": "Stránka se po smazání znovu načte.",
"item.delete": "Smazat položku",
"item.delete_confirm": "Opravdu smazat tuto položku?",
"history.title": "Historie změn",
"history.clear": "Vymazat historii",
"history.empty": "Žádné změny v historii.",
"history.restore": "Obnovit",
"history.restore_title": "Obnovit stav",
"history.restore_confirm": "Obnovit stav z {date}?",
"history.overwrite_warning": "Aktuální obsah bude přepsán.",
"history.overwrite": "Přepsat",
"history.clear_confirm": "Smazat celou historii změn?",
"history.cleared": "Historie vymazána",
"history.not_found": "Záloha nenalezena",
"image.delete": "Smazat obrázek",
"image.delete_confirm": "Opravdu smazat \"{filename}\"?",
"image.trash_hint": "Obrázek bude přesunut do koše (images-trash).",
"image.no_selection": "Žádný obrázek nebyl vybrán",
"image.replace": "Nahradit obrázek",
"image.replacing": "Nahradit:",
"image.overwrite_file": "Přepsat soubor",
"image.overwrite_desc": "Nahraný obrázek přepíše existující soubor se stejným názvem",
"image.save_new_name": "Uložit pod novým názvem",
"image.save_new_desc": "Existující soubor zůstane zachován, nahraný soubor si ponechá svůj název",
"image.choose_file": "Vybrat soubor",
"image.upload": "Nahrát",
"image.drop_files": "Přetáhněte soubory k nahrání",
"image.or": "nebo",
"image.formats_hint": "Povolené formáty: JPG, PNG, WebP (max. 5 MB)",
"image.search": "Hledat soubor...",
"image.grid_view": "Mřížkové zobrazení",
"image.list_view": "Seznamové zobrazení",
"image.loading": "Načítání obrázků...",
"image.col_image": "Obrázek",
"image.col_filename": "Název souboru",
"image.col_size": "Velikost",
"image.col_date": "Datum",
"image.col_actions": "Akce",
"image.select": "Vybrat obrázek",
"image.uploaded": "Obrázek nahrán!",
"image.replaced": "Obrázek nahrazen!",
"image.trashed": "Obrázek přesunut do koše",
"image.format_error": "Povoleny pouze JPG, PNG a WebP",
"image.size_error": "Maximální velikost: 5 MB",
"image.uploading": "Nahrávání...",
"image.exists": "Soubor s názvem \"{filename}\" již existuje",
"image.upload_image": "Nahrát obrázek",
"audio.manager": "Správce audia",
"audio.upload": "Nahrát audio",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (max. 50 MB)",
"audio.search": "Hledat...",
"audio.loading": "Načítání audio souborů...",
"audio.error_loading": "Chyba načítání",
"audio.no_files": "Žádné audio soubory nenalezeny",
"audio.select": "Vybrat",
"audio.play": "Přehrát",
"audio.playing": "Přehrává se: {name}",
"audio.delete": "Smazat audio soubor",
"audio.delete_confirm": "Opravdu smazat \"{filename}\"?",
"audio.trash_hint": "Soubor bude přesunut do koše (audio-trash).",
"audio.uploaded": "Audio soubor nahrán!",
"audio.trashed": "Audio soubor přesunut do koše",
"audio.format_error": "Povoleny pouze MP3, WAV, OGG, M4A, AAC a FLAC",
"audio.size_error": "Maximální velikost: 50 MB",
"toast.no_changes": "Žádné změny k uložení",
"toast.saved": "Uloženo!",
"toast.error_saving": "Chyba při ukládání {page}: {message}",
"toast.error_saving_short": "Chyba při ukládání",
"toast.sort_same_section": "Řazení pouze ve stejné sekci",
"toast.order_changed": "Pořadí změněno",
"toast.hidden": "Skryto",
"toast.visible": "Viditelné",
"toast.row_hidden": "Řádek skrytý",
"toast.row_visible": "Řádek viditelný",
"toast.event_hidden": "Událost skryta",
"toast.event_visible": "Událost viditelná",
"toast.event_deleted": "Událost smazána",
"toast.formatting_cleaned": "Formátování vyčištěno",
"toast.select_text_first": "Nejdříve vyberte text",
"toast.undone": "Vráceno zpět!",
"toast.error_undo": "Chyba při vracení: {message}",
"toast.error_generic": "Chyba: {message}",
"toast.change_queued": "Změna ve frontě — klikněte na \"Uložit\"",
"toast.copied_to_empty": "Obsah zkopírován do prázdných polí v jiných jazycích",
"toast.upload_error": "Chyba nahrávání: {message}",
"toast.error_deleting": "Chyba při mazání: {message}",
"nav.unsaved_changes": "Máte neuložené změny. Opravdu opustit stránku?",
"nav.edit_mode_active": "Jste v režimu úprav. Opravdu opustit stránku?",
"link_target": "Cíl odkazu",
"link_external": "Vlastní",
"link_internal": "Stránky webu",
"image.no_images": "Nebyly nalezeny žádné obrázky.",
"image.no_search_results": "Nebyly nalezeny obrázky pro \"{term}\".",
"image.copy_path": "Kopírovat cestu",
"toast.copied": "Zkopírováno do schránky.",
"toast.error": "Došlo k chybě.",
"link_open_new_tab": "Otevřít v nové kartě",
"link_offer_download": "Nabídnout ke stažení",
"media_manager": "Media library",
"image.library": "Library",
"image.trash": "Trash",
"image.trash_empty": "Image trash is empty.",
"image.restore": "Restore image",
"image.restored": "Image restored",
"image.delete_permanently": "Delete permanently",
"image.delete_permanently_confirm": "Permanently delete \"{filename}\"? This cannot be undone.",
"image.deleted_permanently": "Image permanently deleted",
"image.empty_trash": "Empty trash",
"image.empty_trash_confirm": "Permanently delete all images in the trash? This cannot be undone.",
"image.trash_emptied": "Image trash emptied",
"media.type_all": "All",
"media.type_images": "Images",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Documents",
"media.upload": "Upload file",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office documents, TXT, RTF (max. 50 MB)",
"media.no_files": "No files found.",
"media.no_selection": "No file selected",
"media.trash_empty": "The media trash is empty.",
"media.select_file": "Select file",
"media.format_error": "This file type is not allowed",
"media.size_error": "The file is too large",
"media.open_file": "Open file",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Choose target field",
"copilot.choose_target_field_placeholder": "Select a field...",
"copilot.choose_target_field_hint": "You can also click an editable element on the page before opening this dialog.",
"copilot.choose_target_required": "Choose the field you want to change first.",
"copilot.current_field_content": "Current content",
"copilot.current_field_empty": "This field is currently empty.",
"copilot.current_field_select": "Choose a target field to preview its current content."
}

231
admin/lang/editor-de.json Normal file
View File

@ -0,0 +1,231 @@
{
"dashboard": "Dashboard",
"cancel": "Abbrechen",
"save": "Speichern",
"save_exit": "Speichern & Beenden",
"delete": "Löschen",
"close": "Schließen",
"visual_editor": "Visueller Editor",
"content_editor": "Inhaltseditor",
"logout": "Abmelden",
"saving": "Speichern…",
"saving_exit": "Speichern & Beenden…",
"undo": "Rückgängig",
"redo": "Wiederholen",
"edit_mode_hint": "Klicke auf Text zum Bearbeiten",
"page_backups.label": "Backups dieser Seite",
"page_backups.apply": "Übernehmen",
"page_backups.retry": "Erneut laden",
"page_backups.restoring": "Übernehme…",
"page_backups.unavailable": "Backups nicht verfügbar",
"page_backups.restore_title": "Backup wiederherstellen",
"page_backups.confirm": "{backup} für diese Seite übernehmen?",
"page_backups.saved_first": "Der aktuelle gespeicherte Stand wird vorher als Backup gesichert.",
"page_backups.unsaved": "Ungespeicherte Änderungen auf dieser Seite und in gemeinsam verwendeten Inhalten werden verworfen. Der aktuelle gespeicherte Stand dieser Seite wird vorher als Backup gesichert.",
"page_backups.reload": "Der Seitenstand hat sich geändert. Bitte lade die Seite neu, bevor du ein Backup übernimmst.",
"page_backups.failed": "Das Backup konnte nicht übernommen werden. Bitte versuche es erneut.",
"page_backups.unconfirmed": "Die Wiederherstellung konnte nicht bestätigt werden. Bitte lade die Seite neu und prüfe den Stand.",
"cat.content": "Inhalt",
"cat.media": "Medien",
"cat.cards": "Karten & Raster",
"cat.interactive": "Interaktiv",
"cat.layout": "Layout",
"cat.embed": "Einbetten",
"add_block": "Block hinzufügen",
"search_blocks": "Blöcke suchen...",
"edit": "Bearbeiten",
"edit_block_title": "{label} bearbeiten",
"editor.language_tabs": "Sprachen",
"editor.translate_from_active": "Aus aktiver Sprache übersetzen",
"editor.translating": "Übersetze...",
"editor.translate_done": "Übersetzungen eingefügt.",
"editor.translate_no_source": "Kein Ausgangstext zum Übersetzen vorhanden.",
"editor.translate_invalid_response": "Die KI-Antwort konnte nicht als Übersetzung gelesen werden.",
"show": "Einblenden",
"hide": "Ausblenden",
"drag_reorder": "Ziehen zum Umsortieren",
"add_soundcloud": "SoundCloud hinzufügen",
"add_audio": "Audio MP3 hinzufügen",
"add_youtube": "YouTube hinzufügen",
"add_text": "Textabschnitt hinzufügen",
"add_card": "Karte hinzufügen",
"bold": "Fett",
"italic": "Kursiv",
"insert_link": "Link einfügen",
"clean_formatting": "Formatierung bereinigen",
"image_manager": "Bildverwaltung",
"media_manager": "Mediathek",
"image_preview": "Bildvorschau",
"event.title": "Titel",
"event.location": "Ort",
"event.description": "Beschreibung",
"event.admission": "Eintritt",
"event.start_date": "Startdatum *",
"event.start_time": "Startzeit",
"event.end_date": "Enddatum",
"event.end_time": "Endzeit",
"event.url": "URL",
"event.image": "Bild",
"event.copy_to_all": "In alle kopieren",
"event.edit": "Termin bearbeiten",
"event.new": "Neuer Termin",
"event.delete": "Termin löschen",
"event.delete_confirm": "Diesen Termin wirklich löschen?",
"event.cannot_undo": "Diese Aktion kann nicht rückgängig gemacht werden.",
"event.date_required": "Datum und Titel sind erforderlich",
"footer.edit": "Footer bearbeiten",
"footer.link_text": "Linktext",
"footer.link_url": "Link-URL",
"footer.link_text_lang": "Linktext ({lang})",
"footer.copyright": "Copyright (Shortcodes: [id=\"...\"]Text[/id])",
"section.delete": "Abschnitt löschen",
"section.delete_confirm": "Diesen Abschnitt ({type}) wirklich löschen?",
"section.reload_hint": "Die Seite wird nach dem Löschen neu geladen.",
"item.delete": "Eintrag löschen",
"item.delete_confirm": "Diesen Eintrag wirklich löschen?",
"history.title": "Änderungsverlauf",
"history.clear": "Verlauf löschen",
"history.empty": "Keine Änderungen im Verlauf.",
"history.restore": "Wiederherstellen",
"history.restore_title": "Zustand wiederherstellen",
"history.restore_confirm": "Zustand vom {date} wiederherstellen?",
"history.overwrite_warning": "Der aktuelle Inhalt wird überschrieben.",
"history.overwrite": "Überschreiben",
"history.clear_confirm": "Den gesamten Änderungsverlauf löschen?",
"history.cleared": "Verlauf gelöscht",
"history.not_found": "Backup nicht gefunden",
"image.delete": "Bild löschen",
"image.delete_confirm": "\"{filename}\" wirklich löschen?",
"image.trash_hint": "Das Bild wird in den Papierkorb verschoben (images-trash).",
"image.no_selection": "Kein Bild ausgewählt",
"image.library": "Bibliothek",
"image.trash": "Papierkorb",
"image.trash_empty": "Der Bild-Papierkorb ist leer.",
"image.replace": "Bild ersetzen",
"image.replacing": "Ersetze:",
"image.overwrite_file": "Datei überschreiben",
"image.overwrite_desc": "Das hochgeladene Bild ersetzt die bestehende Datei mit gleichem Namen",
"image.save_new_name": "Unter neuem Namen speichern",
"image.save_new_desc": "Bestehende Datei bleibt erhalten, hochgeladene Datei behält ihren Namen",
"image.choose_file": "Datei auswählen",
"image.upload": "Hochladen",
"image.drop_files": "Dateien zum Hochladen ablegen",
"image.or": "oder",
"image.formats_hint": "Erlaubte Formate: JPG, PNG, WebP (max. 5 MB)",
"image.search": "Dateiname suchen...",
"image.grid_view": "Gitteransicht",
"image.list_view": "Listenansicht",
"image.loading": "Bilder werden geladen...",
"image.col_image": "Bild",
"image.col_filename": "Dateiname",
"image.col_size": "Größe",
"image.col_date": "Datum",
"image.col_actions": "Aktionen",
"image.select": "Bild auswählen",
"image.uploaded": "Bild hochgeladen!",
"image.replaced": "Bild ersetzt!",
"image.trashed": "Bild in den Papierkorb verschoben",
"image.restore": "Bild wiederherstellen",
"image.restored": "Bild wiederhergestellt",
"image.delete_permanently": "Endgültig löschen",
"image.delete_permanently_confirm": "\"{filename}\" endgültig löschen? Dies kann nicht rückgängig gemacht werden.",
"image.deleted_permanently": "Bild endgültig gelöscht",
"image.empty_trash": "Papierkorb leeren",
"image.empty_trash_confirm": "Alle Bilder im Papierkorb endgültig löschen? Dies kann nicht rückgängig gemacht werden.",
"image.trash_emptied": "Bild-Papierkorb geleert",
"image.format_error": "Nur JPG, PNG und WebP erlaubt",
"image.size_error": "Maximale Dateigröße: 5 MB",
"image.uploading": "Lade hoch...",
"image.exists": "Eine Datei mit dem Namen \"{filename}\" existiert bereits",
"image.upload_image": "Bild hochladen",
"audio.manager": "Audio-Verwaltung",
"audio.upload": "Audio hochladen",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (max. 50 MB)",
"audio.search": "Suchen...",
"audio.loading": "Audiodateien werden geladen...",
"audio.error_loading": "Fehler beim Laden",
"audio.no_files": "Keine Audiodateien gefunden",
"audio.select": "Auswählen",
"audio.play": "Abspielen",
"audio.playing": "Spiele: {name}",
"audio.delete": "Audiodatei löschen",
"audio.delete_confirm": "\"{filename}\" wirklich löschen?",
"audio.trash_hint": "Die Datei wird in den Papierkorb verschoben (audio-trash).",
"audio.uploaded": "Audiodatei hochgeladen!",
"audio.trashed": "Audiodatei in den Papierkorb verschoben",
"audio.format_error": "Nur MP3, WAV, OGG, M4A, AAC und FLAC erlaubt",
"audio.size_error": "Maximale Dateigröße: 50 MB",
"media.type_all": "Alle",
"media.type_images": "Bilder",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Dokumente",
"media.upload": "Datei hochladen",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office-Dokumente, TXT, RTF (max. 50 MB)",
"media.no_files": "Keine Dateien gefunden.",
"media.no_selection": "Keine Datei ausgewählt",
"media.trash_empty": "Der Medien-Papierkorb ist leer.",
"media.select_file": "Datei auswählen",
"media.format_error": "Dieser Dateityp ist nicht erlaubt",
"media.size_error": "Die Datei ist zu groß",
"media.open_file": "Datei öffnen",
"toast.no_changes": "Keine Änderungen zum Speichern",
"toast.saved": "Gespeichert!",
"toast.error_saving": "Fehler beim Speichern von {page}: {message}",
"toast.error_saving_short": "Fehler beim Speichern",
"toast.sort_same_section": "Sortierung nur innerhalb desselben Abschnitts",
"toast.order_changed": "Reihenfolge geändert",
"toast.hidden": "Ausgeblendet",
"toast.visible": "Sichtbar",
"toast.row_hidden": "Zeile ausgeblendet",
"toast.row_visible": "Zeile sichtbar",
"toast.event_hidden": "Termin ausgeblendet",
"toast.event_visible": "Termin sichtbar",
"toast.event_deleted": "Termin gelöscht",
"toast.formatting_cleaned": "Formatierung bereinigt",
"toast.select_text_first": "Bitte zuerst Text markieren",
"toast.undone": "Rückgängig gemacht!",
"toast.error_undo": "Fehler beim Rückgängigmachen: {message}",
"toast.error_generic": "Fehler: {message}",
"toast.change_queued": "Änderung vorgemerkt — klicke auf \"Speichern\"",
"toast.copied_to_empty": "Inhalt in leere Felder der anderen Sprachen kopiert",
"toast.upload_error": "Upload-Fehler: {message}",
"toast.error_deleting": "Fehler beim Löschen: {message}",
"nav.unsaved_changes": "Du hast ungespeicherte Änderungen. Seite wirklich verlassen?",
"nav.edit_mode_active": "Du bist im Bearbeitungsmodus. Seite wirklich verlassen?",
"link_target": "Linkziel",
"link_external": "Benutzerdefiniert",
"link_internal": "Seiten",
"image.no_images": "Keine Bilder gefunden.",
"image.no_search_results": "Keine Bilder gefunden für \"{term}\".",
"image.copy_path": "Pfad kopieren",
"toast.copied": "In Zwischenablage kopiert.",
"toast.error": "Ein Fehler ist aufgetreten.",
"link_open_new_tab": "In neuem Tab öffnen",
"link_offer_download": "Als Download anbieten",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Zielfeld auswählen",
"copilot.choose_target_field_placeholder": "Feld auswählen...",
"copilot.choose_target_field_hint": "Du kannst auch zuerst ein editierbares Element auf der Seite anklicken.",
"copilot.choose_target_required": "Wähle zuerst das Feld aus, das geändert werden soll.",
"copilot.current_field_content": "Aktueller Inhalt",
"copilot.current_field_empty": "Dieses Feld ist derzeit leer.",
"copilot.current_field_select": "Wähle ein Zielfeld aus, um den aktuellen Inhalt anzuzeigen."
}

231
admin/lang/editor-en.json Normal file
View File

@ -0,0 +1,231 @@
{
"dashboard": "Dashboard",
"cancel": "Cancel",
"save": "Save",
"save_exit": "Save & Exit",
"delete": "Delete",
"close": "Close",
"visual_editor": "Visual Editor",
"content_editor": "Content Editor",
"logout": "Log out",
"saving": "Saving…",
"saving_exit": "Saving & exiting…",
"undo": "Undo",
"redo": "Redo",
"edit_mode_hint": "Click any text to edit",
"page_backups.label": "Backups of this page",
"page_backups.apply": "Apply",
"page_backups.retry": "Retry",
"page_backups.restoring": "Applying…",
"page_backups.unavailable": "Backups unavailable",
"page_backups.restore_title": "Restore backup",
"page_backups.confirm": "Apply {backup} to this page?",
"page_backups.saved_first": "The current saved version will be backed up first.",
"page_backups.unsaved": "Unsaved changes to this page and shared content will be discarded. The current saved version of this page will be backed up first.",
"page_backups.reload": "The page has changed. Reload the page before applying a backup.",
"page_backups.failed": "The backup could not be applied. Please try again.",
"page_backups.unconfirmed": "The restore could not be confirmed. Reload the page and check its content.",
"cat.content": "Content",
"cat.media": "Media",
"cat.cards": "Cards & Grids",
"cat.interactive": "Interactive",
"cat.layout": "Layout",
"cat.embed": "Embed",
"add_block": "Add block",
"search_blocks": "Search blocks...",
"edit": "Edit",
"edit_block_title": "Edit {label}",
"editor.language_tabs": "Languages",
"editor.translate_from_active": "Translate from active language",
"editor.translating": "Translating...",
"editor.translate_done": "Translations inserted.",
"editor.translate_no_source": "No source text to translate.",
"editor.translate_invalid_response": "AI returned an unreadable translation.",
"show": "Show",
"hide": "Hide",
"drag_reorder": "Drag to reorder",
"add_soundcloud": "Add SoundCloud",
"add_audio": "Add Audio MP3",
"add_youtube": "Add YouTube",
"add_text": "Add text section",
"add_card": "Add card",
"bold": "Bold",
"italic": "Italic",
"insert_link": "Insert link",
"clean_formatting": "Clean formatting",
"image_manager": "Image manager",
"media_manager": "Media library",
"image_preview": "Image preview",
"event.title": "Title",
"event.location": "Location",
"event.description": "Description",
"event.admission": "Admission",
"event.start_date": "Start date *",
"event.start_time": "Start time",
"event.end_date": "End date",
"event.end_time": "End time",
"event.url": "URL",
"event.image": "Image",
"event.copy_to_all": "Copy to all",
"event.edit": "Edit event",
"event.new": "New event",
"event.delete": "Delete event",
"event.delete_confirm": "Really delete this event?",
"event.cannot_undo": "This action cannot be undone.",
"event.date_required": "Date and title are required",
"footer.edit": "Edit footer",
"footer.link_text": "Link text",
"footer.link_url": "Link URL",
"footer.link_text_lang": "Link text ({lang})",
"footer.copyright": "Copyright (Shortcodes: [id=\"...\"]text[/id])",
"section.delete": "Delete section",
"section.delete_confirm": "Really delete this section ({type})?",
"section.reload_hint": "Page will reload after deletion.",
"item.delete": "Delete item",
"item.delete_confirm": "Really delete this item?",
"history.title": "Change history",
"history.clear": "Clear history",
"history.empty": "No changes in history.",
"history.restore": "Restore",
"history.restore_title": "Restore state",
"history.restore_confirm": "Restore state from {date}?",
"history.overwrite_warning": "The current content will be overwritten.",
"history.overwrite": "Overwrite",
"history.clear_confirm": "Delete the entire change history?",
"history.cleared": "History cleared",
"history.not_found": "Backup not found",
"image.delete": "Delete image",
"image.delete_confirm": "Really delete \"{filename}\"?",
"image.trash_hint": "The image will be moved to trash (images-trash).",
"image.no_selection": "No image selected",
"image.library": "Library",
"image.trash": "Trash",
"image.trash_empty": "Image trash is empty.",
"image.replace": "Replace image",
"image.replacing": "Replace:",
"image.overwrite_file": "Overwrite file",
"image.overwrite_desc": "Uploaded image replaces the existing file with the same name",
"image.save_new_name": "Save with new name",
"image.save_new_desc": "Existing file is kept, uploaded file keeps its name",
"image.choose_file": "Choose file",
"image.upload": "Upload",
"image.drop_files": "Drop files to upload",
"image.or": "or",
"image.formats_hint": "Allowed formats: JPG, PNG, WebP (max. 5 MB)",
"image.search": "Search filename...",
"image.grid_view": "Grid view",
"image.list_view": "List view",
"image.loading": "Loading images...",
"image.col_image": "Image",
"image.col_filename": "Filename",
"image.col_size": "Size",
"image.col_date": "Date",
"image.col_actions": "Actions",
"image.select": "Select image",
"image.uploaded": "Image uploaded!",
"image.replaced": "Image replaced!",
"image.trashed": "Image moved to trash",
"image.restore": "Restore image",
"image.restored": "Image restored",
"image.delete_permanently": "Delete permanently",
"image.delete_permanently_confirm": "Permanently delete \"{filename}\"? This cannot be undone.",
"image.deleted_permanently": "Image permanently deleted",
"image.empty_trash": "Empty trash",
"image.empty_trash_confirm": "Permanently delete all images in the trash? This cannot be undone.",
"image.trash_emptied": "Image trash emptied",
"image.format_error": "Only JPG, PNG and WebP allowed",
"image.size_error": "Maximum file size: 5 MB",
"image.uploading": "Uploading...",
"image.exists": "A file named \"{filename}\" already exists",
"image.upload_image": "Upload image",
"audio.manager": "Audio manager",
"audio.upload": "Upload audio",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (max. 50 MB)",
"audio.search": "Search...",
"audio.loading": "Loading audio files...",
"audio.error_loading": "Error loading",
"audio.no_files": "No audio files found",
"audio.select": "Select",
"audio.play": "Play",
"audio.playing": "Playing: {name}",
"audio.delete": "Delete audio file",
"audio.delete_confirm": "Really delete \"{filename}\"?",
"audio.trash_hint": "The file will be moved to trash (audio-trash).",
"audio.uploaded": "Audio file uploaded!",
"audio.trashed": "Audio file moved to trash",
"audio.format_error": "Only MP3, WAV, OGG, M4A, AAC and FLAC allowed",
"audio.size_error": "Maximum file size: 50 MB",
"media.type_all": "All",
"media.type_images": "Images",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Documents",
"media.upload": "Upload file",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office documents, TXT, RTF (max. 50 MB)",
"media.no_files": "No files found.",
"media.no_selection": "No file selected",
"media.trash_empty": "The media trash is empty.",
"media.select_file": "Select file",
"media.format_error": "This file type is not allowed",
"media.size_error": "The file is too large",
"media.open_file": "Open file",
"toast.no_changes": "No changes to save",
"toast.saved": "Saved!",
"toast.error_saving": "Error saving {page}: {message}",
"toast.error_saving_short": "Error saving",
"toast.sort_same_section": "Sorting only within the same section",
"toast.order_changed": "Order changed",
"toast.hidden": "Hidden",
"toast.visible": "Visible",
"toast.row_hidden": "Row hidden",
"toast.row_visible": "Row visible",
"toast.event_hidden": "Event hidden",
"toast.event_visible": "Event visible",
"toast.event_deleted": "Event deleted",
"toast.formatting_cleaned": "Formatting cleaned",
"toast.select_text_first": "Please select text first",
"toast.undone": "Undone!",
"toast.error_undo": "Error undoing: {message}",
"toast.error_generic": "Error: {message}",
"toast.change_queued": "Change queued — click \"Save\" to apply",
"toast.copied_to_empty": "Content copied to empty fields in other languages",
"toast.upload_error": "Upload error: {message}",
"toast.error_deleting": "Error deleting: {message}",
"nav.unsaved_changes": "You have unsaved changes. Really leave this page?",
"nav.edit_mode_active": "You are in edit mode. Really leave this page?",
"link_target": "Link target",
"link_external": "Custom",
"link_internal": "Site pages",
"image.no_images": "No images found.",
"image.no_search_results": "No images found for \"{term}\".",
"image.copy_path": "Copy path",
"toast.copied": "Copied to clipboard.",
"toast.error": "An error occurred.",
"link_open_new_tab": "Open in new tab",
"link_offer_download": "Offer as download",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Choose target field",
"copilot.choose_target_field_placeholder": "Select a field...",
"copilot.choose_target_field_hint": "You can also click an editable element on the page before opening this dialog.",
"copilot.choose_target_required": "Choose the field you want to change first.",
"copilot.current_field_content": "Current content",
"copilot.current_field_empty": "This field is currently empty.",
"copilot.current_field_select": "Choose a target field to preview its current content."
}

213
admin/lang/editor-es.json Normal file
View File

@ -0,0 +1,213 @@
{
"dashboard": "Panel",
"cancel": "Cancelar",
"save": "Guardar",
"save_exit": "Guardar y salir",
"delete": "Eliminar",
"close": "Cerrar",
"visual_editor": "Editor visual",
"content_editor": "Editor de contenido",
"logout": "Cerrar sesión",
"saving": "Guardando…",
"saving_exit": "Guardando y saliendo…",
"undo": "Deshacer",
"redo": "Rehacer",
"edit_mode_hint": "Haz clic en cualquier texto para editar",
"cat.content": "Contenido",
"cat.media": "Multimedia",
"cat.cards": "Tarjetas y cuadrículas",
"cat.interactive": "Interactivo",
"cat.layout": "Diseño",
"cat.embed": "Insertar",
"add_block": "Añadir bloque",
"search_blocks": "Buscar bloques...",
"edit": "Editar",
"edit_block_title": "Editar {label}",
"show": "Mostrar",
"hide": "Ocultar",
"drag_reorder": "Arrastra para reordenar",
"add_soundcloud": "Añadir SoundCloud",
"add_audio": "Añadir audio MP3",
"add_youtube": "Añadir YouTube",
"add_text": "Añadir sección de texto",
"add_card": "Añadir tarjeta",
"bold": "Negrita",
"italic": "Cursiva",
"insert_link": "Insertar enlace",
"clean_formatting": "Limpiar formato",
"image_manager": "Gestor de imágenes",
"image_preview": "Vista previa",
"event.title": "Título",
"event.location": "Ubicación",
"event.description": "Descripción",
"event.admission": "Entrada",
"event.start_date": "Fecha de inicio *",
"event.start_time": "Hora de inicio",
"event.end_date": "Fecha de fin",
"event.end_time": "Hora de fin",
"event.url": "URL",
"event.image": "Imagen",
"event.copy_to_all": "Copiar a todos",
"event.edit": "Editar evento",
"event.new": "Nuevo evento",
"event.delete": "Eliminar evento",
"event.delete_confirm": "¿Eliminar este evento?",
"event.cannot_undo": "Esta acción no se puede deshacer.",
"event.date_required": "La fecha y el título son obligatorios",
"footer.edit": "Editar pie de página",
"footer.link_text": "Texto del enlace",
"footer.link_url": "URL del enlace",
"footer.link_text_lang": "Texto del enlace ({lang})",
"footer.copyright": "Copyright (Shortcodes: [id=\"...\"]texto[/id])",
"section.delete": "Eliminar sección",
"section.delete_confirm": "¿Eliminar esta sección ({type})?",
"section.reload_hint": "La página se recargará después de eliminar.",
"item.delete": "Eliminar elemento",
"item.delete_confirm": "¿Eliminar este elemento?",
"history.title": "Historial de cambios",
"history.clear": "Borrar historial",
"history.empty": "No hay cambios en el historial.",
"history.restore": "Restaurar",
"history.restore_title": "Restaurar estado",
"history.restore_confirm": "¿Restaurar estado de {date}?",
"history.overwrite_warning": "El contenido actual se sobrescribirá.",
"history.overwrite": "Sobrescribir",
"history.clear_confirm": "¿Borrar todo el historial de cambios?",
"history.cleared": "Historial borrado",
"history.not_found": "Copia no encontrada",
"image.delete": "Eliminar imagen",
"image.delete_confirm": "¿Eliminar \"{filename}\"?",
"image.trash_hint": "La imagen se moverá a la papelera (images-trash).",
"image.no_selection": "Ninguna imagen seleccionada",
"image.replace": "Reemplazar imagen",
"image.replacing": "Reemplazar:",
"image.overwrite_file": "Sobrescribir archivo",
"image.overwrite_desc": "La imagen subida reemplaza el archivo existente con el mismo nombre",
"image.save_new_name": "Guardar con nuevo nombre",
"image.save_new_desc": "El archivo existente se mantiene, el subido conserva su nombre",
"image.choose_file": "Seleccionar archivo",
"image.upload": "Subir",
"image.drop_files": "Arrastra archivos para subir",
"image.or": "o",
"image.formats_hint": "Formatos permitidos: JPG, PNG, WebP (máx. 5 MB)",
"image.search": "Buscar archivo...",
"image.grid_view": "Vista en cuadrícula",
"image.list_view": "Vista en lista",
"image.loading": "Cargando imágenes...",
"image.col_image": "Imagen",
"image.col_filename": "Nombre",
"image.col_size": "Tamaño",
"image.col_date": "Fecha",
"image.col_actions": "Acciones",
"image.select": "Seleccionar imagen",
"image.uploaded": "¡Imagen subida!",
"image.replaced": "¡Imagen reemplazada!",
"image.trashed": "Imagen movida a la papelera",
"image.format_error": "Solo se permiten JPG, PNG y WebP",
"image.size_error": "Tamaño máximo: 5 MB",
"image.uploading": "Subiendo...",
"image.exists": "Ya existe un archivo llamado \"{filename}\"",
"image.upload_image": "Subir imagen",
"audio.manager": "Gestor de audio",
"audio.upload": "Subir audio",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (máx. 50 MB)",
"audio.search": "Buscar...",
"audio.loading": "Cargando archivos de audio...",
"audio.error_loading": "Error al cargar",
"audio.no_files": "No se encontraron archivos de audio",
"audio.select": "Seleccionar",
"audio.play": "Reproducir",
"audio.playing": "Reproduciendo: {name}",
"audio.delete": "Eliminar archivo de audio",
"audio.delete_confirm": "¿Eliminar \"{filename}\"?",
"audio.trash_hint": "El archivo se moverá a la papelera (audio-trash).",
"audio.uploaded": "¡Archivo de audio subido!",
"audio.trashed": "Archivo de audio movido a la papelera",
"audio.format_error": "Solo se permiten MP3, WAV, OGG, M4A, AAC y FLAC",
"audio.size_error": "Tamaño máximo: 50 MB",
"toast.no_changes": "No hay cambios para guardar",
"toast.saved": "¡Guardado!",
"toast.error_saving": "Error al guardar {page}: {message}",
"toast.error_saving_short": "Error al guardar",
"toast.sort_same_section": "Solo se puede ordenar dentro de la misma sección",
"toast.order_changed": "Orden cambiado",
"toast.hidden": "Oculto",
"toast.visible": "Visible",
"toast.row_hidden": "Fila oculta",
"toast.row_visible": "Fila visible",
"toast.event_hidden": "Evento oculto",
"toast.event_visible": "Evento visible",
"toast.event_deleted": "Evento eliminado",
"toast.formatting_cleaned": "Formato limpiado",
"toast.select_text_first": "Selecciona texto primero",
"toast.undone": "¡Deshecho!",
"toast.error_undo": "Error al deshacer: {message}",
"toast.error_generic": "Error: {message}",
"toast.change_queued": "Cambio en cola — haz clic en \"Guardar\" para aplicar",
"toast.copied_to_empty": "Contenido copiado a campos vacíos en otros idiomas",
"toast.upload_error": "Error de subida: {message}",
"toast.error_deleting": "Error al eliminar: {message}",
"nav.unsaved_changes": "Tienes cambios sin guardar. ¿Salir de la página?",
"nav.edit_mode_active": "Estás en modo edición. ¿Salir de la página?",
"link_target": "Destino del enlace",
"link_external": "Personalizado",
"link_internal": "Páginas del sitio",
"image.no_images": "No se encontraron imágenes.",
"image.no_search_results": "No hay imágenes para \"{term}\".",
"image.copy_path": "Copiar ruta",
"toast.copied": "Copiado al portapapeles.",
"toast.error": "Se produjo un error.",
"link_open_new_tab": "Abrir en nueva pestaña",
"link_offer_download": "Ofrecer como descarga",
"media_manager": "Media library",
"image.library": "Library",
"image.trash": "Trash",
"image.trash_empty": "Image trash is empty.",
"image.restore": "Restore image",
"image.restored": "Image restored",
"image.delete_permanently": "Delete permanently",
"image.delete_permanently_confirm": "Permanently delete \"{filename}\"? This cannot be undone.",
"image.deleted_permanently": "Image permanently deleted",
"image.empty_trash": "Empty trash",
"image.empty_trash_confirm": "Permanently delete all images in the trash? This cannot be undone.",
"image.trash_emptied": "Image trash emptied",
"media.type_all": "All",
"media.type_images": "Images",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Documents",
"media.upload": "Upload file",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office documents, TXT, RTF (max. 50 MB)",
"media.no_files": "No files found.",
"media.no_selection": "No file selected",
"media.trash_empty": "The media trash is empty.",
"media.select_file": "Select file",
"media.format_error": "This file type is not allowed",
"media.size_error": "The file is too large",
"media.open_file": "Open file",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Choose target field",
"copilot.choose_target_field_placeholder": "Select a field...",
"copilot.choose_target_field_hint": "You can also click an editable element on the page before opening this dialog.",
"copilot.choose_target_required": "Choose the field you want to change first.",
"copilot.current_field_content": "Current content",
"copilot.current_field_empty": "This field is currently empty.",
"copilot.current_field_select": "Choose a target field to preview its current content."
}

213
admin/lang/editor-fr.json Normal file
View File

@ -0,0 +1,213 @@
{
"dashboard": "Tableau de bord",
"cancel": "Annuler",
"save": "Enregistrer",
"save_exit": "Enregistrer et quitter",
"delete": "Supprimer",
"close": "Fermer",
"visual_editor": "Éditeur visuel",
"content_editor": "Éditeur de contenu",
"logout": "Déconnexion",
"saving": "Enregistrement…",
"saving_exit": "Enregistrement et fermeture…",
"undo": "Annuler",
"redo": "Rétablir",
"edit_mode_hint": "Cliquez sur un texte pour modifier",
"cat.content": "Contenu",
"cat.media": "Médias",
"cat.cards": "Cartes et grilles",
"cat.interactive": "Interactif",
"cat.layout": "Mise en page",
"cat.embed": "Intégrer",
"add_block": "Ajouter un bloc",
"search_blocks": "Rechercher des blocs...",
"edit": "Modifier",
"edit_block_title": "Modifier {label}",
"show": "Afficher",
"hide": "Masquer",
"drag_reorder": "Glisser pour réorganiser",
"add_soundcloud": "Ajouter SoundCloud",
"add_audio": "Ajouter audio MP3",
"add_youtube": "Ajouter YouTube",
"add_text": "Ajouter une section texte",
"add_card": "Ajouter une carte",
"bold": "Gras",
"italic": "Italique",
"insert_link": "Insérer un lien",
"clean_formatting": "Nettoyer le formatage",
"image_manager": "Gestionnaire d'images",
"image_preview": "Aperçu",
"event.title": "Titre",
"event.location": "Lieu",
"event.description": "Description",
"event.admission": "Entrée",
"event.start_date": "Date de début *",
"event.start_time": "Heure de début",
"event.end_date": "Date de fin",
"event.end_time": "Heure de fin",
"event.url": "URL",
"event.image": "Image",
"event.copy_to_all": "Copier vers tous",
"event.edit": "Modifier l'événement",
"event.new": "Nouvel événement",
"event.delete": "Supprimer l'événement",
"event.delete_confirm": "Supprimer cet événement ?",
"event.cannot_undo": "Cette action est irréversible.",
"event.date_required": "La date et le titre sont obligatoires",
"footer.edit": "Modifier le pied de page",
"footer.link_text": "Texte du lien",
"footer.link_url": "URL du lien",
"footer.link_text_lang": "Texte du lien ({lang})",
"footer.copyright": "Copyright (Shortcodes : [id=\"...\"]texte[/id])",
"section.delete": "Supprimer la section",
"section.delete_confirm": "Supprimer cette section ({type}) ?",
"section.reload_hint": "La page sera rechargée après suppression.",
"item.delete": "Supprimer l'élément",
"item.delete_confirm": "Supprimer cet élément ?",
"history.title": "Historique des modifications",
"history.clear": "Effacer l'historique",
"history.empty": "Aucun changement dans l'historique.",
"history.restore": "Restaurer",
"history.restore_title": "Restaurer l'état",
"history.restore_confirm": "Restaurer l'état du {date} ?",
"history.overwrite_warning": "Le contenu actuel sera écrasé.",
"history.overwrite": "Écraser",
"history.clear_confirm": "Supprimer tout l'historique des modifications ?",
"history.cleared": "Historique effacé",
"history.not_found": "Sauvegarde introuvable",
"image.delete": "Supprimer l'image",
"image.delete_confirm": "Supprimer \"{filename}\" ?",
"image.trash_hint": "L'image sera déplacée dans la corbeille (images-trash).",
"image.no_selection": "Aucune image sélectionnée",
"image.replace": "Remplacer l'image",
"image.replacing": "Remplacer :",
"image.overwrite_file": "Écraser le fichier",
"image.overwrite_desc": "L'image téléchargée remplace le fichier existant du même nom",
"image.save_new_name": "Enregistrer sous un nouveau nom",
"image.save_new_desc": "Le fichier existant est conservé, le fichier téléchargé garde son nom",
"image.choose_file": "Choisir un fichier",
"image.upload": "Télécharger",
"image.drop_files": "Déposez des fichiers à télécharger",
"image.or": "ou",
"image.formats_hint": "Formats autorisés : JPG, PNG, WebP (max. 5 Mo)",
"image.search": "Rechercher un fichier...",
"image.grid_view": "Vue en grille",
"image.list_view": "Vue en liste",
"image.loading": "Chargement des images...",
"image.col_image": "Image",
"image.col_filename": "Nom de fichier",
"image.col_size": "Taille",
"image.col_date": "Date",
"image.col_actions": "Actions",
"image.select": "Sélectionner l'image",
"image.uploaded": "Image téléchargée !",
"image.replaced": "Image remplacée !",
"image.trashed": "Image déplacée dans la corbeille",
"image.format_error": "Seuls JPG, PNG et WebP sont autorisés",
"image.size_error": "Taille maximale : 5 Mo",
"image.uploading": "Téléchargement...",
"image.exists": "Un fichier nommé \"{filename}\" existe déjà",
"image.upload_image": "Télécharger une image",
"audio.manager": "Gestionnaire audio",
"audio.upload": "Télécharger un audio",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (max. 50 Mo)",
"audio.search": "Rechercher...",
"audio.loading": "Chargement des fichiers audio...",
"audio.error_loading": "Erreur de chargement",
"audio.no_files": "Aucun fichier audio trouvé",
"audio.select": "Sélectionner",
"audio.play": "Lire",
"audio.playing": "Lecture : {name}",
"audio.delete": "Supprimer le fichier audio",
"audio.delete_confirm": "Supprimer \"{filename}\" ?",
"audio.trash_hint": "Le fichier sera déplacé dans la corbeille (audio-trash).",
"audio.uploaded": "Fichier audio téléchargé !",
"audio.trashed": "Fichier audio déplacé dans la corbeille",
"audio.format_error": "Seuls MP3, WAV, OGG, M4A, AAC et FLAC sont autorisés",
"audio.size_error": "Taille maximale : 50 Mo",
"toast.no_changes": "Aucun changement à enregistrer",
"toast.saved": "Enregistré !",
"toast.error_saving": "Erreur lors de l'enregistrement de {page} : {message}",
"toast.error_saving_short": "Erreur lors de l'enregistrement",
"toast.sort_same_section": "Tri uniquement dans la même section",
"toast.order_changed": "Ordre modifié",
"toast.hidden": "Masqué",
"toast.visible": "Visible",
"toast.row_hidden": "Ligne masquée",
"toast.row_visible": "Ligne visible",
"toast.event_hidden": "Événement masqué",
"toast.event_visible": "Événement visible",
"toast.event_deleted": "Événement supprimé",
"toast.formatting_cleaned": "Formatage nettoyé",
"toast.select_text_first": "Veuillez d'abord sélectionner du texte",
"toast.undone": "Annulé !",
"toast.error_undo": "Erreur lors de l'annulation : {message}",
"toast.error_generic": "Erreur : {message}",
"toast.change_queued": "Modification en file d'attente — cliquez sur « Enregistrer »",
"toast.copied_to_empty": "Contenu copié dans les champs vides des autres langues",
"toast.upload_error": "Erreur de téléchargement : {message}",
"toast.error_deleting": "Erreur lors de la suppression : {message}",
"nav.unsaved_changes": "Vous avez des modifications non enregistrées. Quitter la page ?",
"nav.edit_mode_active": "Vous êtes en mode édition. Quitter la page ?",
"link_target": "Cible du lien",
"link_external": "Personnalisé",
"link_internal": "Pages du site",
"image.no_images": "Aucune image trouvée.",
"image.no_search_results": "Aucune image trouvée pour « {term} ».",
"image.copy_path": "Copier le chemin",
"toast.copied": "Copié dans le presse-papiers.",
"toast.error": "Une erreur s'est produite.",
"link_open_new_tab": "Ouvrir dans un nouvel onglet",
"link_offer_download": "Proposer en téléchargement",
"media_manager": "Media library",
"image.library": "Library",
"image.trash": "Trash",
"image.trash_empty": "Image trash is empty.",
"image.restore": "Restore image",
"image.restored": "Image restored",
"image.delete_permanently": "Delete permanently",
"image.delete_permanently_confirm": "Permanently delete \"{filename}\"? This cannot be undone.",
"image.deleted_permanently": "Image permanently deleted",
"image.empty_trash": "Empty trash",
"image.empty_trash_confirm": "Permanently delete all images in the trash? This cannot be undone.",
"image.trash_emptied": "Image trash emptied",
"media.type_all": "All",
"media.type_images": "Images",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Documents",
"media.upload": "Upload file",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office documents, TXT, RTF (max. 50 MB)",
"media.no_files": "No files found.",
"media.no_selection": "No file selected",
"media.trash_empty": "The media trash is empty.",
"media.select_file": "Select file",
"media.format_error": "This file type is not allowed",
"media.size_error": "The file is too large",
"media.open_file": "Open file",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Choose target field",
"copilot.choose_target_field_placeholder": "Select a field...",
"copilot.choose_target_field_hint": "You can also click an editable element on the page before opening this dialog.",
"copilot.choose_target_required": "Choose the field you want to change first.",
"copilot.current_field_content": "Current content",
"copilot.current_field_empty": "This field is currently empty.",
"copilot.current_field_select": "Choose a target field to preview its current content."
}

213
admin/lang/editor-it.json Normal file
View File

@ -0,0 +1,213 @@
{
"dashboard": "Pannello",
"cancel": "Annulla",
"save": "Salva",
"save_exit": "Salva ed esci",
"delete": "Elimina",
"close": "Chiudi",
"visual_editor": "Editor visuale",
"content_editor": "Editor contenuti",
"logout": "Esci",
"saving": "Salvataggio…",
"saving_exit": "Salvataggio e uscita…",
"undo": "Annulla",
"redo": "Ripristina",
"edit_mode_hint": "Clicca su un testo per modificare",
"cat.content": "Contenuto",
"cat.media": "Multimedia",
"cat.cards": "Schede e griglie",
"cat.interactive": "Interattivo",
"cat.layout": "Layout",
"cat.embed": "Incorpora",
"add_block": "Aggiungi blocco",
"search_blocks": "Cerca blocchi...",
"edit": "Modifica",
"edit_block_title": "Modifica {label}",
"show": "Mostra",
"hide": "Nascondi",
"drag_reorder": "Trascina per riordinare",
"add_soundcloud": "Aggiungi SoundCloud",
"add_audio": "Aggiungi audio MP3",
"add_youtube": "Aggiungi YouTube",
"add_text": "Aggiungi sezione testo",
"add_card": "Aggiungi scheda",
"bold": "Grassetto",
"italic": "Corsivo",
"insert_link": "Inserisci link",
"clean_formatting": "Pulisci formattazione",
"image_manager": "Gestore immagini",
"image_preview": "Anteprima",
"event.title": "Titolo",
"event.location": "Luogo",
"event.description": "Descrizione",
"event.admission": "Ingresso",
"event.start_date": "Data inizio *",
"event.start_time": "Ora inizio",
"event.end_date": "Data fine",
"event.end_time": "Ora fine",
"event.url": "URL",
"event.image": "Immagine",
"event.copy_to_all": "Copia in tutti",
"event.edit": "Modifica evento",
"event.new": "Nuovo evento",
"event.delete": "Elimina evento",
"event.delete_confirm": "Eliminare questo evento?",
"event.cannot_undo": "Questa azione non può essere annullata.",
"event.date_required": "Data e titolo sono obbligatori",
"footer.edit": "Modifica piè di pagina",
"footer.link_text": "Testo del link",
"footer.link_url": "URL del link",
"footer.link_text_lang": "Testo del link ({lang})",
"footer.copyright": "Copyright (Shortcode: [id=\"...\"]testo[/id])",
"section.delete": "Elimina sezione",
"section.delete_confirm": "Eliminare questa sezione ({type})?",
"section.reload_hint": "La pagina verrà ricaricata dopo l'eliminazione.",
"item.delete": "Elimina elemento",
"item.delete_confirm": "Eliminare questo elemento?",
"history.title": "Cronologia modifiche",
"history.clear": "Cancella cronologia",
"history.empty": "Nessuna modifica nella cronologia.",
"history.restore": "Ripristina",
"history.restore_title": "Ripristina stato",
"history.restore_confirm": "Ripristinare lo stato del {date}?",
"history.overwrite_warning": "Il contenuto attuale verrà sovrascritto.",
"history.overwrite": "Sovrascrivi",
"history.clear_confirm": "Eliminare tutta la cronologia delle modifiche?",
"history.cleared": "Cronologia cancellata",
"history.not_found": "Backup non trovato",
"image.delete": "Elimina immagine",
"image.delete_confirm": "Eliminare \"{filename}\"?",
"image.trash_hint": "L'immagine verrà spostata nel cestino (images-trash).",
"image.no_selection": "Nessuna immagine selezionata",
"image.replace": "Sostituisci immagine",
"image.replacing": "Sostituisci:",
"image.overwrite_file": "Sovrascrivi file",
"image.overwrite_desc": "L'immagine caricata sostituisce il file esistente con lo stesso nome",
"image.save_new_name": "Salva con nuovo nome",
"image.save_new_desc": "Il file esistente viene mantenuto, il file caricato conserva il suo nome",
"image.choose_file": "Scegli file",
"image.upload": "Carica",
"image.drop_files": "Trascina file per caricare",
"image.or": "o",
"image.formats_hint": "Formati consentiti: JPG, PNG, WebP (max. 5 MB)",
"image.search": "Cerca file...",
"image.grid_view": "Vista griglia",
"image.list_view": "Vista elenco",
"image.loading": "Caricamento immagini...",
"image.col_image": "Immagine",
"image.col_filename": "Nome file",
"image.col_size": "Dimensione",
"image.col_date": "Data",
"image.col_actions": "Azioni",
"image.select": "Seleziona immagine",
"image.uploaded": "Immagine caricata!",
"image.replaced": "Immagine sostituita!",
"image.trashed": "Immagine spostata nel cestino",
"image.format_error": "Solo JPG, PNG e WebP consentiti",
"image.size_error": "Dimensione massima: 5 MB",
"image.uploading": "Caricamento...",
"image.exists": "Esiste già un file chiamato \"{filename}\"",
"image.upload_image": "Carica immagine",
"audio.manager": "Gestore audio",
"audio.upload": "Carica audio",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (max. 50 MB)",
"audio.search": "Cerca...",
"audio.loading": "Caricamento file audio...",
"audio.error_loading": "Errore di caricamento",
"audio.no_files": "Nessun file audio trovato",
"audio.select": "Seleziona",
"audio.play": "Riproduci",
"audio.playing": "In riproduzione: {name}",
"audio.delete": "Elimina file audio",
"audio.delete_confirm": "Eliminare \"{filename}\"?",
"audio.trash_hint": "Il file verrà spostato nel cestino (audio-trash).",
"audio.uploaded": "File audio caricato!",
"audio.trashed": "File audio spostato nel cestino",
"audio.format_error": "Solo MP3, WAV, OGG, M4A, AAC e FLAC consentiti",
"audio.size_error": "Dimensione massima: 50 MB",
"toast.no_changes": "Nessuna modifica da salvare",
"toast.saved": "Salvato!",
"toast.error_saving": "Errore nel salvataggio di {page}: {message}",
"toast.error_saving_short": "Errore nel salvataggio",
"toast.sort_same_section": "Ordinamento solo nella stessa sezione",
"toast.order_changed": "Ordine modificato",
"toast.hidden": "Nascosto",
"toast.visible": "Visibile",
"toast.row_hidden": "Riga nascosta",
"toast.row_visible": "Riga visibile",
"toast.event_hidden": "Evento nascosto",
"toast.event_visible": "Evento visibile",
"toast.event_deleted": "Evento eliminato",
"toast.formatting_cleaned": "Formattazione pulita",
"toast.select_text_first": "Seleziona prima il testo",
"toast.undone": "Annullato!",
"toast.error_undo": "Errore nell'annullamento: {message}",
"toast.error_generic": "Errore: {message}",
"toast.change_queued": "Modifica in coda — clicca \"Salva\" per applicare",
"toast.copied_to_empty": "Contenuto copiato nei campi vuoti delle altre lingue",
"toast.upload_error": "Errore di caricamento: {message}",
"toast.error_deleting": "Errore nell'eliminazione: {message}",
"nav.unsaved_changes": "Hai modifiche non salvate. Uscire dalla pagina?",
"nav.edit_mode_active": "Sei in modalità modifica. Uscire dalla pagina?",
"link_target": "Destinazione del link",
"link_external": "Personalizzato",
"link_internal": "Pagine del sito",
"image.no_images": "Nessuna immagine trovata.",
"image.no_search_results": "Nessuna immagine trovata per \"{term}\".",
"image.copy_path": "Copia percorso",
"toast.copied": "Copiato negli appunti.",
"toast.error": "Si è verificato un errore.",
"link_open_new_tab": "Apri in una nuova scheda",
"link_offer_download": "Proponi come download",
"media_manager": "Media library",
"image.library": "Library",
"image.trash": "Trash",
"image.trash_empty": "Image trash is empty.",
"image.restore": "Restore image",
"image.restored": "Image restored",
"image.delete_permanently": "Delete permanently",
"image.delete_permanently_confirm": "Permanently delete \"{filename}\"? This cannot be undone.",
"image.deleted_permanently": "Image permanently deleted",
"image.empty_trash": "Empty trash",
"image.empty_trash_confirm": "Permanently delete all images in the trash? This cannot be undone.",
"image.trash_emptied": "Image trash emptied",
"media.type_all": "All",
"media.type_images": "Images",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Documents",
"media.upload": "Upload file",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office documents, TXT, RTF (max. 50 MB)",
"media.no_files": "No files found.",
"media.no_selection": "No file selected",
"media.trash_empty": "The media trash is empty.",
"media.select_file": "Select file",
"media.format_error": "This file type is not allowed",
"media.size_error": "The file is too large",
"media.open_file": "Open file",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Choose target field",
"copilot.choose_target_field_placeholder": "Select a field...",
"copilot.choose_target_field_hint": "You can also click an editable element on the page before opening this dialog.",
"copilot.choose_target_required": "Choose the field you want to change first.",
"copilot.current_field_content": "Current content",
"copilot.current_field_empty": "This field is currently empty.",
"copilot.current_field_select": "Choose a target field to preview its current content."
}

213
admin/lang/editor-pl.json Normal file
View File

@ -0,0 +1,213 @@
{
"dashboard": "Panel",
"cancel": "Anuluj",
"save": "Zapisz",
"save_exit": "Zapisz i wyjdź",
"delete": "Usuń",
"close": "Zamknij",
"visual_editor": "Edytor wizualny",
"content_editor": "Edytor treści",
"logout": "Wyloguj",
"saving": "Zapisywanie…",
"saving_exit": "Zapisywanie i wychodzenie…",
"undo": "Cofnij",
"redo": "Ponów",
"edit_mode_hint": "Kliknij tekst, aby edytować",
"cat.content": "Treść",
"cat.media": "Multimedia",
"cat.cards": "Karty i siatki",
"cat.interactive": "Interaktywne",
"cat.layout": "Układ",
"cat.embed": "Osadź",
"add_block": "Dodaj blok",
"search_blocks": "Szukaj bloków...",
"edit": "Edytuj",
"edit_block_title": "Edytuj {label}",
"show": "Pokaż",
"hide": "Ukryj",
"drag_reorder": "Przeciągnij, aby zmienić kolejność",
"add_soundcloud": "Dodaj SoundCloud",
"add_audio": "Dodaj audio MP3",
"add_youtube": "Dodaj YouTube",
"add_text": "Dodaj sekcję tekstową",
"add_card": "Dodaj kartę",
"bold": "Pogrubienie",
"italic": "Kursywa",
"insert_link": "Wstaw link",
"clean_formatting": "Wyczyść formatowanie",
"image_manager": "Menedżer obrazów",
"image_preview": "Podgląd",
"event.title": "Tytuł",
"event.location": "Miejsce",
"event.description": "Opis",
"event.admission": "Wstęp",
"event.start_date": "Data rozpoczęcia *",
"event.start_time": "Godzina rozpoczęcia",
"event.end_date": "Data zakończenia",
"event.end_time": "Godzina zakończenia",
"event.url": "URL",
"event.image": "Obraz",
"event.copy_to_all": "Kopiuj do wszystkich",
"event.edit": "Edytuj wydarzenie",
"event.new": "Nowe wydarzenie",
"event.delete": "Usuń wydarzenie",
"event.delete_confirm": "Usunąć to wydarzenie?",
"event.cannot_undo": "Tej akcji nie można cofnąć.",
"event.date_required": "Data i tytuł są wymagane",
"footer.edit": "Edytuj stopkę",
"footer.link_text": "Tekst linku",
"footer.link_url": "URL linku",
"footer.link_text_lang": "Tekst linku ({lang})",
"footer.copyright": "Copyright (Shortcodes: [id=\"...\"]tekst[/id])",
"section.delete": "Usuń sekcję",
"section.delete_confirm": "Usunąć tę sekcję ({type})?",
"section.reload_hint": "Strona zostanie przeładowana po usunięciu.",
"item.delete": "Usuń element",
"item.delete_confirm": "Usunąć ten element?",
"history.title": "Historia zmian",
"history.clear": "Wyczyść historię",
"history.empty": "Brak zmian w historii.",
"history.restore": "Przywróć",
"history.restore_title": "Przywróć stan",
"history.restore_confirm": "Przywrócić stan z {date}?",
"history.overwrite_warning": "Bieżąca treść zostanie nadpisana.",
"history.overwrite": "Nadpisz",
"history.clear_confirm": "Usunąć całą historię zmian?",
"history.cleared": "Historia wyczyszczona",
"history.not_found": "Kopia zapasowa nie znaleziona",
"image.delete": "Usuń obraz",
"image.delete_confirm": "Usunąć \"{filename}\"?",
"image.trash_hint": "Obraz zostanie przeniesiony do kosza (images-trash).",
"image.no_selection": "Nie wybrano obrazu",
"image.replace": "Zastąp obraz",
"image.replacing": "Zastąp:",
"image.overwrite_file": "Nadpisz plik",
"image.overwrite_desc": "Przesłany obraz zastąpi istniejący plik o tej samej nazwie",
"image.save_new_name": "Zapisz pod nową nazwą",
"image.save_new_desc": "Istniejący plik zostanie zachowany, przesłany plik zachowa swoją nazwę",
"image.choose_file": "Wybierz plik",
"image.upload": "Prześlij",
"image.drop_files": "Upuść pliki do przesłania",
"image.or": "lub",
"image.formats_hint": "Dozwolone formaty: JPG, PNG, WebP (maks. 5 MB)",
"image.search": "Szukaj pliku...",
"image.grid_view": "Widok siatki",
"image.list_view": "Widok listy",
"image.loading": "Ładowanie obrazów...",
"image.col_image": "Obraz",
"image.col_filename": "Nazwa pliku",
"image.col_size": "Rozmiar",
"image.col_date": "Data",
"image.col_actions": "Akcje",
"image.select": "Wybierz obraz",
"image.uploaded": "Obraz przesłany!",
"image.replaced": "Obraz zastąpiony!",
"image.trashed": "Obraz przeniesiony do kosza",
"image.format_error": "Dozwolone tylko JPG, PNG i WebP",
"image.size_error": "Maksymalny rozmiar: 5 MB",
"image.uploading": "Przesyłanie...",
"image.exists": "Plik o nazwie \"{filename}\" już istnieje",
"image.upload_image": "Prześlij obraz",
"audio.manager": "Menedżer audio",
"audio.upload": "Prześlij audio",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (maks. 50 MB)",
"audio.search": "Szukaj...",
"audio.loading": "Ładowanie plików audio...",
"audio.error_loading": "Błąd ładowania",
"audio.no_files": "Nie znaleziono plików audio",
"audio.select": "Wybierz",
"audio.play": "Odtwórz",
"audio.playing": "Odtwarzanie: {name}",
"audio.delete": "Usuń plik audio",
"audio.delete_confirm": "Usunąć \"{filename}\"?",
"audio.trash_hint": "Plik zostanie przeniesiony do kosza (audio-trash).",
"audio.uploaded": "Plik audio przesłany!",
"audio.trashed": "Plik audio przeniesiony do kosza",
"audio.format_error": "Dozwolone tylko MP3, WAV, OGG, M4A, AAC i FLAC",
"audio.size_error": "Maksymalny rozmiar: 50 MB",
"toast.no_changes": "Brak zmian do zapisania",
"toast.saved": "Zapisano!",
"toast.error_saving": "Błąd zapisu {page}: {message}",
"toast.error_saving_short": "Błąd zapisu",
"toast.sort_same_section": "Sortowanie tylko w tej samej sekcji",
"toast.order_changed": "Kolejność zmieniona",
"toast.hidden": "Ukryto",
"toast.visible": "Widoczne",
"toast.row_hidden": "Wiersz ukryty",
"toast.row_visible": "Wiersz widoczny",
"toast.event_hidden": "Wydarzenie ukryte",
"toast.event_visible": "Wydarzenie widoczne",
"toast.event_deleted": "Wydarzenie usunięte",
"toast.formatting_cleaned": "Formatowanie wyczyszczone",
"toast.select_text_first": "Najpierw zaznacz tekst",
"toast.undone": "Cofnięto!",
"toast.error_undo": "Błąd cofania: {message}",
"toast.error_generic": "Błąd: {message}",
"toast.change_queued": "Zmiana w kolejce — kliknij \"Zapisz\", aby zastosować",
"toast.copied_to_empty": "Treść skopiowana do pustych pól w innych językach",
"toast.upload_error": "Błąd przesyłania: {message}",
"toast.error_deleting": "Błąd usuwania: {message}",
"nav.unsaved_changes": "Masz niezapisane zmiany. Opuścić stronę?",
"nav.edit_mode_active": "Jesteś w trybie edycji. Opuścić stronę?",
"link_target": "Cel linku",
"link_external": "Niestandardowy",
"link_internal": "Strony witryny",
"image.no_images": "Nie znaleziono obrazów.",
"image.no_search_results": "Nie znaleziono obrazów dla \"{term}\".",
"image.copy_path": "Kopiuj ścieżkę",
"toast.copied": "Skopiowano do schowka.",
"toast.error": "Wystąpił błąd.",
"link_open_new_tab": "Otwórz w nowej karcie",
"link_offer_download": "Zaoferuj do pobrania",
"media_manager": "Media library",
"image.library": "Library",
"image.trash": "Trash",
"image.trash_empty": "Image trash is empty.",
"image.restore": "Restore image",
"image.restored": "Image restored",
"image.delete_permanently": "Delete permanently",
"image.delete_permanently_confirm": "Permanently delete \"{filename}\"? This cannot be undone.",
"image.deleted_permanently": "Image permanently deleted",
"image.empty_trash": "Empty trash",
"image.empty_trash_confirm": "Permanently delete all images in the trash? This cannot be undone.",
"image.trash_emptied": "Image trash emptied",
"media.type_all": "All",
"media.type_images": "Images",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Documents",
"media.upload": "Upload file",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office documents, TXT, RTF (max. 50 MB)",
"media.no_files": "No files found.",
"media.no_selection": "No file selected",
"media.trash_empty": "The media trash is empty.",
"media.select_file": "Select file",
"media.format_error": "This file type is not allowed",
"media.size_error": "The file is too large",
"media.open_file": "Open file",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Choose target field",
"copilot.choose_target_field_placeholder": "Select a field...",
"copilot.choose_target_field_hint": "You can also click an editable element on the page before opening this dialog.",
"copilot.choose_target_required": "Choose the field you want to change first.",
"copilot.current_field_content": "Current content",
"copilot.current_field_empty": "This field is currently empty.",
"copilot.current_field_select": "Choose a target field to preview its current content."
}

213
admin/lang/editor-pt.json Normal file
View File

@ -0,0 +1,213 @@
{
"dashboard": "Painel",
"cancel": "Cancelar",
"save": "Salvar",
"save_exit": "Salvar e sair",
"delete": "Excluir",
"close": "Fechar",
"visual_editor": "Editor visual",
"content_editor": "Editor de conteúdo",
"logout": "Sair",
"saving": "Salvando…",
"saving_exit": "Salvando e saindo…",
"undo": "Desfazer",
"redo": "Refazer",
"edit_mode_hint": "Clique em qualquer texto para editar",
"cat.content": "Conteúdo",
"cat.media": "Mídia",
"cat.cards": "Cartões e grades",
"cat.interactive": "Interativo",
"cat.layout": "Layout",
"cat.embed": "Incorporar",
"add_block": "Adicionar bloco",
"search_blocks": "Buscar blocos...",
"edit": "Editar",
"edit_block_title": "Editar {label}",
"show": "Mostrar",
"hide": "Ocultar",
"drag_reorder": "Arraste para reordenar",
"add_soundcloud": "Adicionar SoundCloud",
"add_audio": "Adicionar áudio MP3",
"add_youtube": "Adicionar YouTube",
"add_text": "Adicionar seção de texto",
"add_card": "Adicionar cartão",
"bold": "Negrito",
"italic": "Itálico",
"insert_link": "Inserir link",
"clean_formatting": "Limpar formatação",
"image_manager": "Gerenciador de imagens",
"image_preview": "Pré-visualização",
"event.title": "Título",
"event.location": "Local",
"event.description": "Descrição",
"event.admission": "Entrada",
"event.start_date": "Data de início *",
"event.start_time": "Hora de início",
"event.end_date": "Data de término",
"event.end_time": "Hora de término",
"event.url": "URL",
"event.image": "Imagem",
"event.copy_to_all": "Copiar para todos",
"event.edit": "Editar evento",
"event.new": "Novo evento",
"event.delete": "Excluir evento",
"event.delete_confirm": "Excluir este evento?",
"event.cannot_undo": "Esta ação não pode ser desfeita.",
"event.date_required": "Data e título são obrigatórios",
"footer.edit": "Editar rodapé",
"footer.link_text": "Texto do link",
"footer.link_url": "URL do link",
"footer.link_text_lang": "Texto do link ({lang})",
"footer.copyright": "Copyright (Shortcodes: [id=\"...\"]texto[/id])",
"section.delete": "Excluir seção",
"section.delete_confirm": "Excluir esta seção ({type})?",
"section.reload_hint": "A página será recarregada após a exclusão.",
"item.delete": "Excluir item",
"item.delete_confirm": "Excluir este item?",
"history.title": "Histórico de alterações",
"history.clear": "Limpar histórico",
"history.empty": "Nenhuma alteração no histórico.",
"history.restore": "Restaurar",
"history.restore_title": "Restaurar estado",
"history.restore_confirm": "Restaurar estado de {date}?",
"history.overwrite_warning": "O conteúdo atual será sobrescrito.",
"history.overwrite": "Sobrescrever",
"history.clear_confirm": "Excluir todo o histórico de alterações?",
"history.cleared": "Histórico limpo",
"history.not_found": "Backup não encontrado",
"image.delete": "Excluir imagem",
"image.delete_confirm": "Excluir \"{filename}\"?",
"image.trash_hint": "A imagem será movida para a lixeira (images-trash).",
"image.no_selection": "Nenhuma imagem selecionada",
"image.replace": "Substituir imagem",
"image.replacing": "Substituir:",
"image.overwrite_file": "Sobrescrever arquivo",
"image.overwrite_desc": "A imagem enviada substitui o arquivo existente com o mesmo nome",
"image.save_new_name": "Salvar com novo nome",
"image.save_new_desc": "O arquivo existente é mantido, o arquivo enviado mantém seu nome",
"image.choose_file": "Escolher arquivo",
"image.upload": "Enviar",
"image.drop_files": "Arraste arquivos para enviar",
"image.or": "ou",
"image.formats_hint": "Formatos permitidos: JPG, PNG, WebP (máx. 5 MB)",
"image.search": "Buscar arquivo...",
"image.grid_view": "Visualização em grade",
"image.list_view": "Visualização em lista",
"image.loading": "Carregando imagens...",
"image.col_image": "Imagem",
"image.col_filename": "Nome do arquivo",
"image.col_size": "Tamanho",
"image.col_date": "Data",
"image.col_actions": "Ações",
"image.select": "Selecionar imagem",
"image.uploaded": "Imagem enviada!",
"image.replaced": "Imagem substituída!",
"image.trashed": "Imagem movida para a lixeira",
"image.format_error": "Apenas JPG, PNG e WebP são permitidos",
"image.size_error": "Tamanho máximo: 5 MB",
"image.uploading": "Enviando...",
"image.exists": "Já existe um arquivo chamado \"{filename}\"",
"image.upload_image": "Enviar imagem",
"audio.manager": "Gerenciador de áudio",
"audio.upload": "Enviar áudio",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (máx. 50 MB)",
"audio.search": "Buscar...",
"audio.loading": "Carregando arquivos de áudio...",
"audio.error_loading": "Erro ao carregar",
"audio.no_files": "Nenhum arquivo de áudio encontrado",
"audio.select": "Selecionar",
"audio.play": "Reproduzir",
"audio.playing": "Reproduzindo: {name}",
"audio.delete": "Excluir arquivo de áudio",
"audio.delete_confirm": "Excluir \"{filename}\"?",
"audio.trash_hint": "O arquivo será movido para a lixeira (audio-trash).",
"audio.uploaded": "Arquivo de áudio enviado!",
"audio.trashed": "Arquivo de áudio movido para a lixeira",
"audio.format_error": "Apenas MP3, WAV, OGG, M4A, AAC e FLAC são permitidos",
"audio.size_error": "Tamanho máximo: 50 MB",
"toast.no_changes": "Nenhuma alteração para salvar",
"toast.saved": "Salvo!",
"toast.error_saving": "Erro ao salvar {page}: {message}",
"toast.error_saving_short": "Erro ao salvar",
"toast.sort_same_section": "Ordenação apenas na mesma seção",
"toast.order_changed": "Ordem alterada",
"toast.hidden": "Oculto",
"toast.visible": "Visível",
"toast.row_hidden": "Linha oculta",
"toast.row_visible": "Linha visível",
"toast.event_hidden": "Evento oculto",
"toast.event_visible": "Evento visível",
"toast.event_deleted": "Evento excluído",
"toast.formatting_cleaned": "Formatação limpa",
"toast.select_text_first": "Selecione o texto primeiro",
"toast.undone": "Desfeito!",
"toast.error_undo": "Erro ao desfazer: {message}",
"toast.error_generic": "Erro: {message}",
"toast.change_queued": "Alteração em fila — clique em \"Salvar\" para aplicar",
"toast.copied_to_empty": "Conteúdo copiado para campos vazios em outros idiomas",
"toast.upload_error": "Erro no envio: {message}",
"toast.error_deleting": "Erro ao excluir: {message}",
"nav.unsaved_changes": "Você tem alterações não salvas. Sair da página?",
"nav.edit_mode_active": "Você está no modo de edição. Sair da página?",
"link_target": "Destino do link",
"link_external": "Personalizado",
"link_internal": "Páginas do site",
"image.no_images": "Nenhuma imagem encontrada.",
"image.no_search_results": "Nenhuma imagem encontrada para \"{term}\".",
"image.copy_path": "Copiar caminho",
"toast.copied": "Copiado para a área de transferência.",
"toast.error": "Ocorreu um erro.",
"link_open_new_tab": "Abrir em nova aba",
"link_offer_download": "Oferecer como download",
"media_manager": "Media library",
"image.library": "Library",
"image.trash": "Trash",
"image.trash_empty": "Image trash is empty.",
"image.restore": "Restore image",
"image.restored": "Image restored",
"image.delete_permanently": "Delete permanently",
"image.delete_permanently_confirm": "Permanently delete \"{filename}\"? This cannot be undone.",
"image.deleted_permanently": "Image permanently deleted",
"image.empty_trash": "Empty trash",
"image.empty_trash_confirm": "Permanently delete all images in the trash? This cannot be undone.",
"image.trash_emptied": "Image trash emptied",
"media.type_all": "All",
"media.type_images": "Images",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Documents",
"media.upload": "Upload file",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office documents, TXT, RTF (max. 50 MB)",
"media.no_files": "No files found.",
"media.no_selection": "No file selected",
"media.trash_empty": "The media trash is empty.",
"media.select_file": "Select file",
"media.format_error": "This file type is not allowed",
"media.size_error": "The file is too large",
"media.open_file": "Open file",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Choose target field",
"copilot.choose_target_field_placeholder": "Select a field...",
"copilot.choose_target_field_hint": "You can also click an editable element on the page before opening this dialog.",
"copilot.choose_target_required": "Choose the field you want to change first.",
"copilot.current_field_content": "Current content",
"copilot.current_field_empty": "This field is currently empty.",
"copilot.current_field_select": "Choose a target field to preview its current content."
}

213
admin/lang/editor-tr.json Normal file
View File

@ -0,0 +1,213 @@
{
"dashboard": "Kontrol Paneli",
"cancel": "İptal",
"save": "Kaydet",
"save_exit": "Kaydet ve çık",
"delete": "Sil",
"close": "Kapat",
"visual_editor": "Görsel düzenleyici",
"content_editor": "İçerik düzenleyici",
"logout": "Çıkış yap",
"saving": "Kaydediliyor…",
"saving_exit": "Kaydedilip çıkılıyor…",
"undo": "Geri al",
"redo": "Yinele",
"edit_mode_hint": "Düzenlemek için metne tıklayın",
"cat.content": "İçerik",
"cat.media": "Medya",
"cat.cards": "Kartlar ve ızgaralar",
"cat.interactive": "Etkileşimli",
"cat.layout": "Düzen",
"cat.embed": "Yerleştir",
"add_block": "Blok ekle",
"search_blocks": "Blok ara...",
"edit": "Düzenle",
"edit_block_title": "{label} düzenle",
"show": "Göster",
"hide": "Gizle",
"drag_reorder": "Sıralamak için sürükleyin",
"add_soundcloud": "SoundCloud ekle",
"add_audio": "Ses MP3 ekle",
"add_youtube": "YouTube ekle",
"add_text": "Metin bölümü ekle",
"add_card": "Kart ekle",
"bold": "Kalın",
"italic": "İtalik",
"insert_link": "Bağlantı ekle",
"clean_formatting": "Biçimlendirmeyi temizle",
"image_manager": "Görsel yöneticisi",
"image_preview": "Önizleme",
"event.title": "Başlık",
"event.location": "Konum",
"event.description": "Açıklama",
"event.admission": "Giriş",
"event.start_date": "Başlangıç tarihi *",
"event.start_time": "Başlangıç saati",
"event.end_date": "Bitiş tarihi",
"event.end_time": "Bitiş saati",
"event.url": "URL",
"event.image": "Görsel",
"event.copy_to_all": "Tümüne kopyala",
"event.edit": "Etkinliği düzenle",
"event.new": "Yeni etkinlik",
"event.delete": "Etkinliği sil",
"event.delete_confirm": "Bu etkinlik silinsin mi?",
"event.cannot_undo": "Bu işlem geri alınamaz.",
"event.date_required": "Tarih ve başlık gereklidir",
"footer.edit": "Alt bilgiyi düzenle",
"footer.link_text": "Bağlantı metni",
"footer.link_url": "Bağlantı URL'si",
"footer.link_text_lang": "Bağlantı metni ({lang})",
"footer.copyright": "Telif hakkı (Kısa kodlar: [id=\"...\"]metin[/id])",
"section.delete": "Bölümü sil",
"section.delete_confirm": "Bu bölüm ({type}) silinsin mi?",
"section.reload_hint": "Silme sonrası sayfa yeniden yüklenecek.",
"item.delete": "Öğeyi sil",
"item.delete_confirm": "Bu öğe silinsin mi?",
"history.title": "Değişiklik geçmişi",
"history.clear": "Geçmişi temizle",
"history.empty": "Geçmişte değişiklik yok.",
"history.restore": "Geri yükle",
"history.restore_title": "Durumu geri yükle",
"history.restore_confirm": "{date} tarihindeki durum geri yüklensin mi?",
"history.overwrite_warning": "Mevcut içerik üzerine yazılacak.",
"history.overwrite": "Üzerine yaz",
"history.clear_confirm": "Tüm değişiklik geçmişi silinsin mi?",
"history.cleared": "Geçmiş temizlendi",
"history.not_found": "Yedek bulunamadı",
"image.delete": "Görseli sil",
"image.delete_confirm": "\"{filename}\" silinsin mi?",
"image.trash_hint": "Görsel çöp kutusuna taşınacak (images-trash).",
"image.no_selection": "Görsel seçilmedi",
"image.replace": "Görseli değiştir",
"image.replacing": "Değiştir:",
"image.overwrite_file": "Dosyanın üzerine yaz",
"image.overwrite_desc": "Yüklenen görsel aynı adlı mevcut dosyanın yerini alır",
"image.save_new_name": "Yeni adla kaydet",
"image.save_new_desc": "Mevcut dosya korunur, yüklenen dosya kendi adını korur",
"image.choose_file": "Dosya seç",
"image.upload": "Yükle",
"image.drop_files": "Dosyaları yüklemek için bırakın",
"image.or": "veya",
"image.formats_hint": "İzin verilen biçimler: JPG, PNG, WebP (maks. 5 MB)",
"image.search": "Dosya ara...",
"image.grid_view": "Izgara görünümü",
"image.list_view": "Liste görünümü",
"image.loading": "Görseller yükleniyor...",
"image.col_image": "Görsel",
"image.col_filename": "Dosya adı",
"image.col_size": "Boyut",
"image.col_date": "Tarih",
"image.col_actions": "İşlemler",
"image.select": "Görsel seç",
"image.uploaded": "Görsel yüklendi!",
"image.replaced": "Görsel değiştirildi!",
"image.trashed": "Görsel çöp kutusuna taşındı",
"image.format_error": "Yalnızca JPG, PNG ve WebP'ye izin verilir",
"image.size_error": "Maksimum dosya boyutu: 5 MB",
"image.uploading": "Yükleniyor...",
"image.exists": "\"{filename}\" adlı bir dosya zaten mevcut",
"image.upload_image": "Görsel yükle",
"audio.manager": "Ses yöneticisi",
"audio.upload": "Ses yükle",
"audio.formats_hint": "MP3, WAV, OGG, M4A, AAC, FLAC (maks. 50 MB)",
"audio.search": "Ara...",
"audio.loading": "Ses dosyaları yükleniyor...",
"audio.error_loading": "Yükleme hatası",
"audio.no_files": "Ses dosyası bulunamadı",
"audio.select": "Seç",
"audio.play": "Oynat",
"audio.playing": "Oynatılıyor: {name}",
"audio.delete": "Ses dosyasını sil",
"audio.delete_confirm": "\"{filename}\" silinsin mi?",
"audio.trash_hint": "Dosya çöp kutusuna taşınacak (audio-trash).",
"audio.uploaded": "Ses dosyası yüklendi!",
"audio.trashed": "Ses dosyası çöp kutusuna taşındı",
"audio.format_error": "Yalnızca MP3, WAV, OGG, M4A, AAC ve FLAC'a izin verilir",
"audio.size_error": "Maksimum dosya boyutu: 50 MB",
"toast.no_changes": "Kaydedilecek değişiklik yok",
"toast.saved": "Kaydedildi!",
"toast.error_saving": "{page} kaydedilirken hata: {message}",
"toast.error_saving_short": "Kaydetme hatası",
"toast.sort_same_section": "Sıralama yalnızca aynı bölüm içinde",
"toast.order_changed": "Sıralama değiştirildi",
"toast.hidden": "Gizlendi",
"toast.visible": "Görünür",
"toast.row_hidden": "Satır gizlendi",
"toast.row_visible": "Satır görünür",
"toast.event_hidden": "Etkinlik gizlendi",
"toast.event_visible": "Etkinlik görünür",
"toast.event_deleted": "Etkinlik silindi",
"toast.formatting_cleaned": "Biçimlendirme temizlendi",
"toast.select_text_first": "Lütfen önce metin seçin",
"toast.undone": "Geri alındı!",
"toast.error_undo": "Geri alma hatası: {message}",
"toast.error_generic": "Hata: {message}",
"toast.change_queued": "Değişiklik sıraya alındı — uygulamak için \"Kaydet\"e tıklayın",
"toast.copied_to_empty": "İçerik diğer dillerdeki boş alanlara kopyalandı",
"toast.upload_error": "Yükleme hatası: {message}",
"toast.error_deleting": "Silme hatası: {message}",
"nav.unsaved_changes": "Kaydedilmemiş değişiklikleriniz var. Sayfadan çıkılsın mı?",
"nav.edit_mode_active": "Düzenleme modundasınız. Sayfadan çıkılsın mı?",
"link_target": "Bağlantı hedefi",
"link_external": "Özel",
"link_internal": "Site sayfaları",
"image.no_images": "Görsel bulunamadı.",
"image.no_search_results": "\"{term}\" için görsel bulunamadı.",
"image.copy_path": "Yolu kopyala",
"toast.copied": "Panoya kopyalandı.",
"toast.error": "Bir hata oluştu.",
"link_open_new_tab": "Yeni sekmede aç",
"link_offer_download": "İndirme olarak sun",
"media_manager": "Media library",
"image.library": "Library",
"image.trash": "Trash",
"image.trash_empty": "Image trash is empty.",
"image.restore": "Restore image",
"image.restored": "Image restored",
"image.delete_permanently": "Delete permanently",
"image.delete_permanently_confirm": "Permanently delete \"{filename}\"? This cannot be undone.",
"image.deleted_permanently": "Image permanently deleted",
"image.empty_trash": "Empty trash",
"image.empty_trash_confirm": "Permanently delete all images in the trash? This cannot be undone.",
"image.trash_emptied": "Image trash emptied",
"media.type_all": "All",
"media.type_images": "Images",
"media.type_audio": "Audio",
"media.type_video": "Video",
"media.type_documents": "Documents",
"media.upload": "Upload file",
"media.video_formats_hint": "MP4, WebM, MOV, M4V (max. 250 MB)",
"media.document_formats_hint": "PDF, Office documents, TXT, RTF (max. 50 MB)",
"media.no_files": "No files found.",
"media.no_selection": "No file selected",
"media.trash_empty": "The media trash is empty.",
"media.select_file": "Select file",
"media.format_error": "This file type is not allowed",
"media.size_error": "The file is too large",
"media.open_file": "Open file",
"copilot.undo_detail": "Changed: {field}",
"copilot.unknown_field": "selected field",
"copilot.section_field_label": "Section {section} {field}",
"copilot.change_details_title": "Prepare change",
"copilot.change_details_hint": "Describe the change. The Assistant will create a reviewable preview before anything is applied.",
"copilot.target_field": "Target",
"copilot.current_selection_none": "No field selected",
"copilot.change_instruction": "Change instruction",
"copilot.change_instruction_placeholder": "Describe what should change on this page...",
"copilot.prepare_preview": "Prepare preview",
"copilot.content_type": "Content type",
"copilot.content_type_page": "Page",
"copilot.content_type_news": "News post",
"copilot.content_type_event": "Event / appointment",
"copilot.draft_changes_hint": "Open a guided dialog to prepare a reviewed page change.",
"copilot.draft_content_hint": "Open a guided dialog to prepare a new page, news post, event, or appointment.",
"copilot.draft_image_hint": "Open image options and enter or refine the image prompt.",
"copilot.choose_target_field": "Choose target field",
"copilot.choose_target_field_placeholder": "Select a field...",
"copilot.choose_target_field_hint": "You can also click an editable element on the page before opening this dialog.",
"copilot.choose_target_required": "Choose the field you want to change first.",
"copilot.current_field_content": "Current content",
"copilot.current_field_empty": "This field is currently empty.",
"copilot.current_field_select": "Choose a target field to preview its current content."
}

2093
admin/lang/en.json Normal file

File diff suppressed because it is too large Load Diff

1943
admin/lang/es.json Normal file

File diff suppressed because it is too large Load Diff

1943
admin/lang/fr.json Normal file

File diff suppressed because it is too large Load Diff

125
admin/lang/i18n.php Normal file
View File

@ -0,0 +1,125 @@
<?php
/**
* raiju CMS — Admin i18n helper
*
* Provides t() for translating admin UI strings.
* Language files: admin/lang/{code}.json (flat key-value)
* Editor files: admin/lang/editor-{code}.json
*
* Resolution order for admin language:
* 1. settings.json → general.adminLanguage (if set)
* 2. config.php → SITE_LANG_DEFAULT
* 3. 'en' (ultimate fallback)
*/
/**
* Determine the admin UI language code.
*/
function _nbAdminLang(): string {
static $lang = null;
if ($lang !== null) return $lang;
// 1. Override from settings.json
if (defined('SETTINGS_PATH') && is_file(SETTINGS_PATH)) {
$settings = json_decode(file_get_contents(SETTINGS_PATH), true);
$override = $settings['general']['adminLanguage'] ?? '';
if ($override !== '') {
$lang = $override;
return $lang;
}
}
// 2. Primary site language
$lang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en';
return $lang;
}
/**
* Load a JSON translation file. Returns associative array.
*/
function _nbLoadLangFile(string $path): array {
if (!is_file($path)) return [];
$data = json_decode(file_get_contents($path), true);
return is_array($data) ? $data : [];
}
/**
* Translate an admin UI string.
*
* @param string $key Dot-notation key, e.g. "nav.pages"
* @param array $params Placeholder replacements: ['slug' => 'home'] replaces {slug}
* @return string Translated string, or English fallback, or the key itself
*/
function t(string $key, array $params = []): string {
static $strings = null;
static $fallback = null;
if ($strings === null) {
$langDir = __DIR__ . '/';
$lang = _nbAdminLang();
$strings = _nbLoadLangFile($langDir . $lang . '.json');
$fallback = ($lang !== 'en') ? _nbLoadLangFile($langDir . 'en.json') : [];
}
$text = $strings[$key] ?? $fallback[$key] ?? $key;
if ($params) {
foreach ($params as $k => $v) {
$text = str_replace('{' . $k . '}', $v, $text);
}
}
return $text;
}
/**
* Return all admin translation strings as array (for JS injection).
*/
function tAll(): array {
static $merged = null;
if ($merged !== null) return $merged;
$langDir = __DIR__ . '/';
$lang = _nbAdminLang();
$en = _nbLoadLangFile($langDir . 'en.json');
$current = ($lang !== 'en') ? _nbLoadLangFile($langDir . $lang . '.json') : [];
$merged = array_merge($en, $current);
return $merged;
}
/**
* Return all editor translation strings (for frontend JS injection).
*/
function tEditorAll(): array {
$langDir = __DIR__ . '/';
$lang = _nbAdminLang();
$en = _nbLoadLangFile($langDir . 'editor-en.json');
$current = ($lang !== 'en') ? _nbLoadLangFile($langDir . 'editor-' . $lang . '.json') : [];
return array_merge($en, $current);
}
/**
* List available admin languages (code => native name).
* Scans admin/lang/ for {code}.json files (excludes editor-* files).
*/
function tAvailableLanguages(): array {
$langDir = __DIR__ . '/';
$names = [
'en' => 'English', 'de' => 'Deutsch', 'es' => 'Español',
'fr' => 'Français', 'it' => 'Italiano', 'pt' => 'Português',
'nl' => 'Nederlands', 'pl' => 'Polski', 'ja' => '日本語',
'zh' => '中文', 'ko' => '한국어', 'ru' => 'Русский',
'cs' => 'Čeština', 'tr' => 'Türkçe',
];
$langs = [];
foreach (glob($langDir . '*.json') as $file) {
$basename = basename($file, '.json');
if (str_starts_with($basename, 'editor-')) continue;
$langs[$basename] = $names[$basename] ?? $basename;
}
ksort($langs);
return $langs;
}

1943
admin/lang/it.json Normal file

File diff suppressed because it is too large Load Diff

1943
admin/lang/pl.json Normal file

File diff suppressed because it is too large Load Diff

1943
admin/lang/pt.json Normal file

File diff suppressed because it is too large Load Diff

1943
admin/lang/tr.json Normal file

File diff suppressed because it is too large Load Diff

680
admin/setup.php Normal file
View File

@ -0,0 +1,680 @@
<?php
/**
* raiju CMS - Initial Setup Wizard
* Creates config.php on first installation.
* Self-disabling: redirects to login once config.php exists.
*/
// Already configured? Redirect to login
if (file_exists(__DIR__ . '/config.php')) {
header('Location: index.php');
exit;
}
require_once __DIR__ . '/lang/i18n.php';
require_once __DIR__ . '/../includes/session-helper.php';
raijuSessionStart();
$_SESSION['setup_csrf'] ??= bin2hex(random_bytes(32));
$error = '';
$success = false;
// Password strength check (same rules as index.php)
function isPasswordWeak($password) {
if (strlen($password) < 8) return true;
if (!preg_match('/[A-Z]/', $password)) return true;
if (!preg_match('/[a-z]/', $password)) return true;
if (!preg_match('/[0-9]/', $password)) return true;
if (!preg_match('/[^A-Za-z0-9]/', $password)) return true;
return false;
}
// Languages available for selection in setup
$setupLanguages = [
'de' => 'Deutsch',
'en' => 'English',
'fr' => 'Français',
'es' => 'Español',
'it' => 'Italiano',
'pt' => 'Português',
'nl' => 'Nederlands',
'pl' => 'Polski',
'cs' => 'Čeština',
'da' => 'Dansk',
'sv' => 'Svenska',
'no' => 'Norsk',
'fi' => 'Suomi',
'hu' => 'Magyar',
'ro' => 'Română',
'hr' => 'Hrvatski',
'sk' => 'Slovenčina',
'sl' => 'Slovenščina',
'bg' => 'Български',
'el' => 'Ελληνικά',
'tr' => 'Türkçe',
'ru' => 'Русский',
'uk' => 'Українська',
'ja' => '日本語',
'zh' => '中文',
'ko' => '한국어',
'ar' => 'العربية',
];
$selectedSecondaryLang = $_POST['secondary_lang'] ?? 'de';
if ($selectedSecondaryLang !== '' && !isset($setupLanguages[$selectedSecondaryLang])) {
$selectedSecondaryLang = 'de';
}
/**
* Generate starter site files after config is created.
* Creates: language dirs, homepage + demo page templates, starter content,
* nav-config, settings, news posts, .htaccess update.
*/
function generateStarterSite($primaryLang, $secondaryLang, $siteName, $setupLanguages) {
require_once __DIR__ . '/starter-content.php';
$root = dirname(__DIR__);
$languages = [$primaryLang];
if (!empty($secondaryLang)) $languages[] = $secondaryLang;
$i18n = getStarterI18n($siteName);
$contentDir = $root . '/content/pages';
if (!is_dir($contentDir)) @mkdir($contentDir, 0755, true);
$newsDir = $root . '/content/news';
if (!is_dir($newsDir)) @mkdir($newsDir, 0755, true);
// 1. Create language directories + page templates
foreach ($languages as $lang) {
$dir = $root . '/' . $lang;
if (!is_dir($dir)) @mkdir($dir, 0755, true);
$t = $i18n[$lang] ?? $i18n['en'];
// Homepage template (renders sections from JSON)
$homeTpl = <<<'PHPTPL'
<?php
$pageTitle = '{SITE_NAME}';
$pageDescription = '';
$currentLang = '{LANG}';
$currentPage = 'home';
$contentPage = '{LANG}_home';
if (!isset($basePath)) $basePath = '../';
$_includeBase = dirname(__DIR__) . '/';
include $_includeBase . 'includes/header.php';
include $_includeBase . 'includes/content-loader.php';
?>
<main class="main-content">
<div class="content-inner">
<?php echo renderAllSections('{LANG}_home'); ?>
</div>
</main>
<?php include $_includeBase . 'includes/footer.php'; ?>
PHPTPL;
$homeTpl = str_replace(['{SITE_NAME}', '{LANG}'], [$siteName, $lang], $homeTpl);
file_put_contents($dir . '/index.php', $homeTpl);
// Components page template (custom layout with render functions)
$compTpl = getComponentsTemplate($lang, $t['comp_title']);
$compTpl = str_replace(['{TITLE}', '{LANG}'], [$t['comp_title'], $lang], $compTpl);
file_put_contents($dir . '/components.php', $compTpl);
// News listing template
$newsTpl = getNewsTemplate($lang, $t['news_page_title'], $t['news_intro']);
$newsTpl = str_replace(['{TITLE}', '{INTRO}', '{LANG}'], [$t['news_page_title'], $t['news_intro'], $lang], $newsTpl);
file_put_contents($dir . '/news.php', $newsTpl);
// News post detail template
$newsPath = ($lang === $primaryLang) ? 'news' : $lang . '/news';
$newsPostTpl = getNewsPostTemplate($lang, $t['news_back']);
$newsPostTpl = str_replace(['{LANG}', '{NEWS_PATH}', '{BACK_LABEL}'], [$lang, $newsPath, $t['news_back']], $newsPostTpl);
file_put_contents($dir . '/news-post.php', $newsPostTpl);
// 2. Create content JSON files
// Homepage
$homeContent = getHomeContent($lang, $siteName, $t);
file_put_contents($contentDir . '/' . $lang . '_home.json', json_encode($homeContent, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// About page (JSON only — front controller serves it)
$aboutContent = getAboutContent($lang, $t);
file_put_contents($contentDir . '/' . $lang . '_about.json', json_encode($aboutContent, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Block types demo (JSON only — front controller serves it)
$blocksContent = getBlocksContent($lang, $t);
file_put_contents($contentDir . '/' . $lang . '_blocks.json', json_encode($blocksContent, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Components page (JSON data for the PHP template)
$compContent = getComponentsContent($lang, $t);
file_put_contents($contentDir . '/' . $lang . '_components.json', json_encode($compContent, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Contact page (JSON only — front controller serves it + includes contact form)
$contactSlug = $lang === 'de' ? 'kontakt' : ($lang === 'es' ? 'contacto' : 'contact');
$contactTitle = $lang === 'de' ? 'Kontakt' : ($lang === 'es' ? 'Contacto' : 'Contact');
$contactHeadingText = $lang === 'de' ? 'Kontakt aufnehmen' : ($lang === 'es' ? 'Ponte en contacto' : 'Get in Touch');
$contactSubtitle = $lang === 'de' ? 'Wir freuen uns auf Ihre Nachricht' : ($lang === 'es' ? 'Nos encantaría saber de ti' : 'We\'d love to hear from you');
$contactIntro = $lang === 'de' ? '<p>Haben Sie eine Frage, Feedback oder möchten Sie einfach Hallo sagen? Füllen Sie das Formular aus und wir melden uns so schnell wie möglich bei Ihnen.</p>' : ($lang === 'es' ? '<p>¿Tienes una pregunta, comentario o simplemente quieres saludar? Rellena el formulario y te responderemos lo antes posible.</p>' : '<p>Have a question, feedback, or just want to say hello? Fill out the form below and we\'ll get back to you as soon as possible.</p>');
$contactContent = [
'page' => $lang . '_' . $contactSlug,
'lang' => $lang,
'title' => $contactTitle,
'description' => $contactTitle,
'sections' => [
['id' => 's1', 'type' => 'heading', 'text' => $contactHeadingText, 'level' => 'h1', 'subtitle' => $contactSubtitle],
['id' => 's2', 'type' => 'text', 'title' => '', 'content' => $contactIntro],
],
];
file_put_contents($contentDir . '/' . $lang . '_' . $contactSlug . '.json', json_encode($contactContent, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// 3. Create demo news posts
$posts = getNewsPosts($lang, $t);
foreach ($posts as $post) {
file_put_contents($newsDir . '/' . $post['filename'], json_encode($post['data'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
}
// 4. Create footer.json
$footerData = [
'page' => 'footer',
'lastModified' => null,
'tagline' => [],
'services' => [],
'claim' => [],
'contact' => ['phone' => '', 'email' => ''],
'credit' => ['text' => '', 'link' => '', 'linkText' => ''],
'contactHeading' => [],
// The [id="adminAccess"] shortcode wraps the year in a <span> that
// includes/footer.php uses as the hidden admin-login double-click target.
'copyright' => '&copy; [id="adminAccess"]' . date('Y') . '[/id] ' . htmlspecialchars($siteName),
];
foreach ($languages as $lang) {
if ($lang === 'de') {
$footerData['tagline'][$lang] = 'Ein leichtes Flat-File-CMS für editierbare Websites.';
$footerData['services'][$lang] = 'Inhalte inline bearbeiten, Seiten als JSON verwalten und individuelle Layouts sauber pflegen.';
$footerData['claim'][$lang] = 'Keine Datenbank. Kein schwerer Stack. Nur Inhalte, die Kunden selbst aktualisieren können.';
} elseif ($lang === 'es') {
$footerData['tagline'][$lang] = 'Un CMS ligero de archivos planos para sitios web editables.';
$footerData['services'][$lang] = 'Edita contenido en línea, gestiona páginas como JSON y mantén diseños personalizados.';
$footerData['claim'][$lang] = 'Sin base de datos. Sin stack pesado. Solo contenido que tus clientes pueden actualizar.';
} else {
$footerData['tagline'][$lang] = 'A lightweight flat-file CMS for editable websites.';
$footerData['services'][$lang] = 'Edit content inline, manage pages as JSON, and keep custom layouts maintainable.';
$footerData['claim'][$lang] = 'No database. No heavy stack. Just content your clients can update.';
}
$footerData['contactHeading'][$lang] = $lang === 'de' ? 'Kontakt' : ($lang === 'es' ? 'Contacto' : 'Contact');
}
file_put_contents($root . '/content/pages/footer.json', json_encode($footerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// 4b. Create menus.json (menu registry)
$menusData = ['menus' => [
'header' => ['label' => [], 'type' => 'standard', 'weight' => 0],
'footer-pages' => ['label' => [], 'weight' => 10],
'footer-legal' => ['label' => [], 'weight' => 20],
]];
foreach ($languages as $lang) {
$menusData['menus']['header']['label'][$lang] = $lang === 'de' ? 'Kopfzeile' : ($lang === 'es' ? 'Encabezado' : 'Header');
$menusData['menus']['footer-pages']['label'][$lang] = $lang === 'de' ? 'Seiten' : ($lang === 'es' ? 'Páginas' : 'Pages');
$menusData['menus']['footer-legal']['label'][$lang] = $lang === 'de' ? 'Rechtliches' : 'Info';
}
file_put_contents($root . '/content/menus.json', json_encode($menusData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// 5. Create nav-config.php (includes all demo pages)
file_put_contents($root . '/includes/nav-config.php', getNavConfig($languages, $primaryLang, $setupLanguages));
// 6. Create settings.json
$settings = [
'favicon' => '/assets/images/favicon.svg',
'branding' => [
'logo' => '',
'name' => $siteName,
'showBranding' => true,
'logoDisplay' => 'both',
],
'theme' => [
'adminTheme' => 'light',
'primaryColor' => '#3858e9',
'accentColor' => '#3858e9',
],
'email' => [
'method' => 'inactive',
'recipientEmail' => '',
'bccEmail' => '',
'fromEmail' => '',
'fromName' => '',
'smtpHost' => '',
'smtpPort' => 587,
'smtpUsername' => '',
'smtpPassword' => '',
'smtpEncryption' => 'tls',
],
];
file_put_contents($root . '/content/settings.json', json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// 7. Create starter events
$starterEvents = getStarterEvents($languages);
file_put_contents($root . '/content/events.json', json_encode($starterEvents, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// 8. Create demo contact messages
$starterMails = getStarterMails();
file_put_contents($root . '/content/mails.json', json_encode($starterMails, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// 9. Update .htaccess primary language if not 'en'
$htaccessPath = $root . '/.htaccess';
if ($primaryLang !== 'en' && file_exists($htaccessPath)) {
$htaccess = file_get_contents($htaccessPath);
$htaccess = str_replace(
['%{DOCUMENT_ROOT}/en/%1.php', 'en/$1.php [L]'],
['%{DOCUMENT_ROOT}/' . $primaryLang . '/%1.php', $primaryLang . '/$1.php [L]'],
$htaccess
);
file_put_contents($htaccessPath, $htaccess);
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$siteName = trim($_POST['site_name'] ?? '');
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$passwordConfirm = $_POST['password_confirm'] ?? '';
$primaryLang = $_POST['primary_lang'] ?? 'en';
$secondaryLang = $_POST['secondary_lang'] ?? '';
// Validate language selections
if (!isset($setupLanguages[$primaryLang])) {
$primaryLang = 'en';
}
if (!empty($secondaryLang) && !isset($setupLanguages[$secondaryLang])) {
$secondaryLang = '';
}
if ($secondaryLang === $primaryLang) {
$secondaryLang = '';
}
// Validation
if (!is_string($_POST['csrf_token'] ?? null) || !hash_equals($_SESSION['setup_csrf'], $_POST['csrf_token'])) {
$error = t('api.csrf');
} elseif (empty($siteName)) {
$error = t('setup.error_site_name');
} elseif (empty($username)) {
$error = t('setup.error_username');
} elseif (strlen($username) < 3) {
$error = t('setup.error_username_short');
} elseif (empty($password)) {
$error = t('setup.error_password');
} elseif ($password !== $passwordConfirm) {
$error = t('setup.error_password_mismatch');
} elseif (isPasswordWeak($password)) {
$error = t('setup.error_password_weak');
} else {
// Generate hash and write config
$hash = password_hash($password, PASSWORD_DEFAULT);
$escapedSiteName = addcslashes($siteName, "'\\");
// Build $SITE_LANGUAGES array
$languages = [$primaryLang => $setupLanguages[$primaryLang]];
if (!empty($secondaryLang)) {
$languages[$secondaryLang] = $setupLanguages[$secondaryLang];
}
// Build languages PHP code
$langLines = [];
foreach ($languages as $code => $name) {
$langLines[] = " '" . $code . "' => '" . addcslashes($name, "'\\") . "'";
}
$langArrayCode = implode(",\n", $langLines);
$configContent = <<<CONFIGTPL
<?php
/**
* raiju CMS - Configuration
* Generated by Setup Wizard.
* User accounts are managed in content/users.json.
*/
// Default favicon path used as fallback by the admin UI before settings.json
// values are applied. Override in settings.json -> "favicon".
define('RAIJU_DEFAULT_FAVICON', '/assets/images/favicon.svg');
// ============================================================
// SITE SETTINGS
// ============================================================
define('SITE_NAME', '{$escapedSiteName}');
// Development login. Existing admin users can log in with password "dev" only
// from loopback hosts (localhost, 127.0.0.1, ::1). This is ignored on non-local
// hosts even if accidentally left enabled. Set false to disable it locally too.
define('RAIJU_DEV_LOGIN', true);
// ============================================================
// LANGUAGES
// ============================================================
// Primary language: pages live at root (no URL prefix).
// Additional languages: pages live under /{code}/ (e.g. /en/).
define('SITE_LANG_DEFAULT', '{$primaryLang}');
\$SITE_LANGUAGES = [
{$langArrayCode},
];
// ============================================================
// PATHS (relative to webroot)
// ============================================================
define('CONTENT_PATH', __DIR__ . '/../content/pages/');
define('PAGES_TRASH_PATH', __DIR__ . '/../content/pages-trash/');
define('BACKUP_PATH', __DIR__ . '/../backups/');
define('EVENTS_PATH', __DIR__ . '/../content/events.json');
define('IMAGES_PATH', __DIR__ . '/../assets/images/');
define('IMAGES_TRASH_PATH', __DIR__ . '/../assets/images-trash/');
define('AUDIO_PATH', __DIR__ . '/../assets/audio/');
define('AUDIO_TRASH_PATH', __DIR__ . '/../assets/audio-trash/');
define('VIDEO_PATH', __DIR__ . '/../assets/videos/');
define('VIDEO_TRASH_PATH', __DIR__ . '/../assets/videos-trash/');
define('DOCUMENTS_PATH', __DIR__ . '/../assets/documents/');
define('DOCUMENTS_TRASH_PATH', __DIR__ . '/../assets/documents-trash/');
define('SETTINGS_PATH', __DIR__ . '/../content/settings.json');
define('USERS_PATH', __DIR__ . '/../content/users.json');
// ============================================================
// LIMITS
// ============================================================
define('MAX_BACKUPS', 30);
define('SESSION_LIFETIME', 3600); // 1 hour
CONFIGTPL;
$result = file_put_contents(__DIR__ . '/config.php', $configContent, LOCK_EX);
if ($result === false) {
$error = t('setup.error_write');
} else {
// Create users.json with the first admin user
require_once __DIR__ . '/users.php';
$usersData = [
'users' => [
[
'id' => 'u_' . bin2hex(random_bytes(5)),
'username' => $username,
'email' => $email,
'role' => 'admin',
'passwordHash' => $hash,
'createdAt' => gmdate('c'),
'createdBy' => 'setup',
'lastLogin' => null,
'resetToken' => null,
'resetTokenExpiry' => null,
]
]
];
saveUsers($usersData);
// Generate starter site files
generateStarterSite($primaryLang, $secondaryLang, $siteName, $setupLanguages);
$success = true;
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title><?php echo t('setup.page_title'); ?></title>
<link rel="stylesheet" href="style.css">
</head>
<body class="login-page">
<div class="login-container setup-container">
<?php if ($success): ?>
<div class="login-logo">
<svg viewBox="0 0 512 512" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M320.651,322.407V512H192.336V322.564c1.453-34.984,39.059-62.298,73.259-56.514 C296.351,271.252,320.384,295.232,320.651,322.407z M0,40v432c0,22.091,17.909,40,40,40h67.365V326.434 c-0.024-70.202,51.86-130.778,123.397-144.037c46.41-8.745,94.693,5.947,128.83,39.209c28.14,27.213,44.142,63.684,45.059,102.694 c0.11,0.272-0.231,187.421-0.155,187.7H472c22.091,0,40-17.909,40-40V40c0-22.091-17.909-40-40-40H40C17.909,0,0,17.909,0,40z"/>
</svg>
</div>
<h1><?php echo t('setup.complete_title'); ?></h1>
<div class="success-message">
<?php echo t('setup.complete_message'); ?>
</div>
<p class="site-name">
<?php echo t('setup.complete_login_hint'); ?>
</p>
<a href="index.php" class="btn btn-primary btn-block"><?php echo t('setup.go_to_login'); ?></a>
<?php else: ?>
<div class="login-logo">
<svg viewBox="0 0 512 512" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M320.651,322.407V512H192.336V322.564c1.453-34.984,39.059-62.298,73.259-56.514 C296.351,271.252,320.384,295.232,320.651,322.407z M0,40v432c0,22.091,17.909,40,40,40h67.365V326.434 c-0.024-70.202,51.86-130.778,123.397-144.037c46.41-8.745,94.693,5.947,128.83,39.209c28.14,27.213,44.142,63.684,45.059,102.694 c0.11,0.272-0.231,187.421-0.155,187.7H472c22.091,0,40-17.909,40-40V40c0-22.091-17.909-40-40-40H40C17.909,0,0,17.909,0,40z"/>
</svg>
</div>
<h1><?php echo t('setup.title'); ?></h1>
<p class="site-name"><?php echo t('setup.subtitle'); ?></p>
<?php if ($error): ?>
<div class="error-message"><?php echo htmlspecialchars($error); ?></div>
<?php endif; ?>
<form method="post" action="" id="setupForm">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($_SESSION['setup_csrf']); ?>">
<div class="form-group">
<label for="site_name"><?php echo t('setup.site_name'); ?></label>
<input type="text" id="site_name" name="site_name" required
value="<?php echo htmlspecialchars($_POST['site_name'] ?? ''); ?>"
placeholder="<?php echo t('setup.site_name_placeholder'); ?>">
</div>
<div class="setup-lang-row">
<div class="form-group">
<label for="primary_lang"><?php echo t('setup.primary_language'); ?></label>
<select id="primary_lang" name="primary_lang">
<?php foreach ($setupLanguages as $code => $name): ?>
<option value="<?php echo $code; ?>"<?php echo ($code === ($_POST['primary_lang'] ?? 'en')) ? ' selected' : ''; ?>>
<?php echo htmlspecialchars($name); ?> (<?php echo $code; ?>)
</option>
<?php endforeach; ?>
</select>
<small class="form-hint"><?php echo t('setup.primary_language_hint'); ?></small>
</div>
<div class="form-group">
<label for="secondary_lang"><?php echo t('setup.additional_language'); ?></label>
<select id="secondary_lang" name="secondary_lang">
<option value=""><?php echo t('setup.additional_language_none'); ?></option>
<?php foreach ($setupLanguages as $code => $name): ?>
<option value="<?php echo $code; ?>"<?php echo ($code === $selectedSecondaryLang) ? ' selected' : ''; ?>>
<?php echo htmlspecialchars($name); ?> (<?php echo $code; ?>)
</option>
<?php endforeach; ?>
</select>
<small class="form-hint" id="secondaryLangHint" data-template="<?php echo htmlspecialchars(t('setup.additional_language_hint'), ENT_QUOTES); ?>"><?php echo $selectedSecondaryLang !== '' ? t('setup.additional_language_hint', ['code' => $selectedSecondaryLang]) : ''; ?></small>
</div>
</div>
<div class="form-group">
<label for="username"><?php echo t('setup.admin_username'); ?></label>
<input type="text" id="username" name="username" required
value="<?php echo htmlspecialchars($_POST['username'] ?? 'admin'); ?>"
autocomplete="username">
</div>
<div class="form-group">
<label for="email"><?php echo t('setup.admin_email'); ?></label>
<input type="email" id="email" name="email"
value="<?php echo htmlspecialchars($_POST['email'] ?? ''); ?>"
placeholder="<?php echo t('setup.admin_email_placeholder'); ?>"
autocomplete="email">
<small class="form-hint"><?php echo t('setup.admin_email_hint'); ?></small>
</div>
<div class="form-group">
<label for="password"><?php echo t('setup.password'); ?></label>
<div class="password-field-row">
<input type="password" id="password" name="password" required
autocomplete="new-password">
<button type="button" class="btn btn-secondary btn-sm" id="generatePwBtn" title="<?php echo t('setup.generate'); ?>"><?php echo t('setup.generate'); ?></button>
</div>
<div class="generated-password" id="generatedPw" style="display: none;">
<code id="generatedPwText"></code>
<button type="button" class="btn-copy" id="copyPwBtn" title="<?php echo t('setup.copy'); ?>" data-label-copy="<?php echo t('setup.copy'); ?>" data-label-copied="<?php echo t('setup.copied'); ?>"><?php echo t('setup.copy'); ?></button>
</div>
</div>
<div class="form-group">
<label for="password_confirm"><?php echo t('setup.confirm_password'); ?></label>
<input type="password" id="password_confirm" name="password_confirm" required
autocomplete="new-password">
</div>
<div class="password-requirements password-requirements--compact" id="passwordReqs">
<small><?php echo t('settings.pw_requirements'); ?></small>
<div class="requirement-tags">
<span class="requirement" data-req="length"><?php echo t('setup.pw_length'); ?></span>
<span class="requirement" data-req="upper"><?php echo t('setup.pw_upper'); ?></span>
<span class="requirement" data-req="lower"><?php echo t('setup.pw_lower'); ?></span>
<span class="requirement" data-req="digit"><?php echo t('setup.pw_digit'); ?></span>
<span class="requirement" data-req="special"><?php echo t('setup.pw_special'); ?></span>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block" id="setupBtn"><?php echo t('setup.create_config'); ?></button>
</form>
<?php endif; ?>
</div>
<script>
(function() {
// Language dropdowns: prevent selecting same language for both
var primaryLang = document.getElementById('primary_lang');
var secondaryLang = document.getElementById('secondary_lang');
if (primaryLang && secondaryLang) {
var secondaryLangHint = document.getElementById('secondaryLangHint');
function updateSecondaryHint() {
if (!secondaryLangHint) return;
if (!secondaryLang.value) {
secondaryLangHint.textContent = '';
return;
}
secondaryLangHint.textContent = secondaryLangHint.dataset.template.replace('{code}', secondaryLang.value);
}
function updateSecondaryOptions() {
var primaryVal = primaryLang.value;
var options = secondaryLang.querySelectorAll('option');
for (var i = 0; i < options.length; i++) {
if (options[i].value === '' ) continue;
options[i].disabled = (options[i].value === primaryVal);
}
if (secondaryLang.value === primaryVal) {
secondaryLang.value = '';
}
updateSecondaryHint();
}
primaryLang.addEventListener('change', updateSecondaryOptions);
secondaryLang.addEventListener('change', updateSecondaryHint);
updateSecondaryOptions();
}
var pw = document.getElementById('password');
var pwConfirm = document.getElementById('password_confirm');
if (!pw) return;
var reqs = {
length: function(v) { return v.length >= 8; },
upper: function(v) { return /[A-Z]/.test(v); },
lower: function(v) { return /[a-z]/.test(v); },
digit: function(v) { return /[0-9]/.test(v); },
special: function(v) { return /[^A-Za-z0-9]/.test(v); }
};
function updateReqs() {
var val = pw.value;
for (var key in reqs) {
var el = document.querySelector('[data-req="' + key + '"]');
if (el) {
if (reqs[key](val)) {
el.classList.add('met');
el.classList.remove('unmet');
} else {
el.classList.remove('met');
el.classList.add('unmet');
}
}
}
}
pw.addEventListener('input', updateReqs);
// Generate secure random password
function generatePassword() {
var upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
var lower = 'abcdefghjkmnpqrstuvwxyz';
var digits = '23456789';
var special = '!@#$%&*+-=?';
var all = upper + lower + digits + special;
// Ensure at least one of each category
var password = '';
var arr = new Uint32Array(20);
crypto.getRandomValues(arr);
password += upper[arr[0] % upper.length];
password += lower[arr[1] % lower.length];
password += digits[arr[2] % digits.length];
password += special[arr[3] % special.length];
for (var i = 4; i < 16; i++) {
password += all[arr[i] % all.length];
}
// Shuffle
var a = password.split('');
var shuffleArr = new Uint32Array(a.length);
crypto.getRandomValues(shuffleArr);
for (var j = a.length - 1; j > 0; j--) {
var k = shuffleArr[j] % (j + 1);
var tmp = a[j]; a[j] = a[k]; a[k] = tmp;
}
return a.join('');
}
var genBtn = document.getElementById('generatePwBtn');
var genDisplay = document.getElementById('generatedPw');
var genText = document.getElementById('generatedPwText');
var copyBtn = document.getElementById('copyPwBtn');
genBtn.addEventListener('click', function() {
var generated = generatePassword();
pw.value = generated;
pwConfirm.value = generated;
pw.type = 'text';
pwConfirm.type = 'text';
genText.textContent = generated;
genDisplay.style.display = 'flex';
updateReqs();
// Revert to password fields after 30 seconds
setTimeout(function() {
pw.type = 'password';
pwConfirm.type = 'password';
}, 30000);
});
if (copyBtn) {
copyBtn.addEventListener('click', function() {
navigator.clipboard.writeText(genText.textContent).then(function() {
copyBtn.textContent = copyBtn.dataset.labelCopied;
setTimeout(function() { copyBtn.textContent = copyBtn.dataset.labelCopy; }, 2000);
});
});
}
})();
</script>
</body>
</html>

1476
admin/starter-content.php Normal file

File diff suppressed because it is too large Load Diff

12165
admin/style.css Normal file

File diff suppressed because it is too large Load Diff

333
admin/users.php Normal file
View File

@ -0,0 +1,333 @@
<?php
/**
* Multi-User Management
* CRUD operations for content/users.json
* Handles migration from single-user config.php constants.
*/
require_once __DIR__ . '/../includes/json-store.php';
if (!defined('USERS_PATH')) {
define('USERS_PATH', __DIR__ . '/../content/users.json');
}
/**
* Load all users from users.json.
* Returns ['users' => [...]] or empty structure.
*/
function loadUsers(): array {
if (!file_exists(USERS_PATH)) {
return ['users' => []];
}
$data = json_decode(file_get_contents(USERS_PATH), true);
if (!is_array($data) || !is_array($data['users'] ?? null)) {
return ['users' => []];
}
return $data;
}
/**
* Save users array to users.json.
*/
function saveUsers(array $data): bool {
return raijuJsonUpdate(USERS_PATH, function (array &$stored) use ($data): void { $stored = $data; }, ['users' => []]);
}
/** Mutate one account while holding the shared user-store lock. */
function updateUserRecord(string $userId, callable $update): bool {
return raijuJsonUpdate(USERS_PATH, function (array &$data) use ($userId, $update): bool {
if (!is_array($data['users'] ?? null)) return false;
foreach ($data['users'] as &$user) {
if (($user['id'] ?? '') === $userId) return $update($user, $data) !== false;
}
return false;
}, ['users' => []]);
}
/**
* Find user by username (case-insensitive).
*/
function findUserByUsername(string $username): ?array {
$data = loadUsers();
$lower = strtolower($username);
foreach ($data['users'] as $user) {
if (strtolower($user['username']) === $lower) {
return $user;
}
}
return null;
}
/**
* Find user by email (case-insensitive).
*/
function findUserByEmail(string $email): ?array {
$data = loadUsers();
$lower = strtolower($email);
foreach ($data['users'] as $user) {
if (!empty($user['email']) && strtolower($user['email']) === $lower) {
return $user;
}
}
return null;
}
/**
* Find user by ID.
*/
function findUserById(string $id): ?array {
$data = loadUsers();
foreach ($data['users'] as $user) {
if ($user['id'] === $id) {
return $user;
}
}
return null;
}
/**
* Verify username + password. Returns user array on success, false on failure.
*/
function verifyUserPassword(string $username, string $password) {
$user = findUserByUsername($username);
if (!$user) {
return false;
}
if (!password_verify($password, $user['passwordHash'])) {
return false;
}
return $user;
}
/**
* Create a new user. Returns the user array.
*/
function createUser(string $username, string $email, string $password, string $role, string $createdBy): array {
$user = [
'id' => 'u_' . bin2hex(random_bytes(5)),
'username' => $username,
'email' => $email,
'role' => in_array($role, ['admin', 'editor']) ? $role : 'editor',
'passwordHash' => password_hash($password, PASSWORD_DEFAULT),
'createdAt' => gmdate('c'),
'createdBy' => $createdBy,
'lastLogin' => null,
'resetToken' => null,
'resetTokenExpiry' => null,
];
$saved = raijuJsonUpdate(USERS_PATH, function (array &$data) use ($user): bool {
if (!is_array($data['users'] ?? null)) return false;
foreach ($data['users'] as $existing) {
if (strcasecmp($existing['username'], $user['username']) === 0
|| ($user['email'] !== '' && strcasecmp($existing['email'] ?? '', $user['email']) === 0)) return false;
}
$data['users'][] = $user;
return true;
}, ['users' => []]);
if (!$saved) throw new RuntimeException('Could not create user; account may already exist or storage is unavailable.');
return $user;
}
/**
* Update user fields (username, email, role). Does NOT update password.
*/
function updateUser(string $userId, array $fields): bool {
return updateUserRecord($userId, function (array &$user, array $data) use ($fields): bool {
if (isset($fields['role']) && !in_array($fields['role'], ['admin', 'editor'], true)) return false;
if ($user['role'] === 'admin' && ($fields['role'] ?? 'admin') !== 'admin'
&& count(array_filter($data['users'], fn($u) => $u['role'] === 'admin')) <= 1) return false;
foreach ($data['users'] as $other) {
if ($other['id'] === $user['id']) continue;
foreach (['username', 'email'] as $key) {
if (!empty($fields[$key]) && strcasecmp($other[$key] ?? '', $fields[$key]) === 0) return false;
}
}
foreach (['username', 'email', 'role'] as $key) {
if (array_key_exists($key, $fields)) $user[$key] = $fields[$key];
}
return true;
});
}
/**
* Update a user's password hash.
*/
function updateUserPassword(string $userId, string $newHash): bool {
return updateUserRecord($userId, function (array &$user) use ($newHash): void {
$user['passwordHash'] = $newHash;
$user['resetToken'] = null;
$user['resetTokenExpiry'] = null;
});
}
/**
* Update last login timestamp.
*/
function updateUserLastLogin(string $userId): bool {
return updateUserRecord($userId, function (array &$user): void { $user['lastLogin'] = gmdate('c'); });
}
/**
* Delete a user by ID.
*/
function deleteUser(string $userId): bool {
return raijuJsonUpdate(USERS_PATH, function (array &$data) use ($userId): bool {
if (!is_array($data['users'] ?? null)) return false;
$filtered = [];
$found = false;
foreach ($data['users'] as $user) {
if ($user['id'] === $userId) {
if ($user['role'] === 'admin' && count(array_filter($data['users'], fn($u) => $u['role'] === 'admin')) <= 1) return false;
$found = true;
} else {
$filtered[] = $user;
}
}
if (!$found) {
return false;
}
$data['users'] = $filtered;
return true;
}, ['users' => []]);
}
/**
* Count users with a given role.
*/
function countUsersByRole(string $role): int {
$data = loadUsers();
$count = 0;
foreach ($data['users'] as $user) {
if ($user['role'] === $role) {
$count++;
}
}
return $count;
}
/**
* Generate a password reset token. Stores SHA-256 hash in users.json,
* returns the raw token (to be sent via email).
*/
function generateResetToken(string $userId): ?string {
$rawToken = bin2hex(random_bytes(32));
$hashedToken = hash('sha256', $rawToken);
$saved = updateUserRecord($userId, function (array &$user) use ($hashedToken): void {
$user['resetToken'] = $hashedToken;
$user['resetTokenExpiry'] = time() + 3600;
});
return $saved ? $rawToken : null;
}
/**
* Validate a reset token. Returns user array if valid, false otherwise.
*/
function validateResetToken(string $rawToken) {
$hashedToken = hash('sha256', $rawToken);
$data = loadUsers();
foreach ($data['users'] as $user) {
if ($user['resetToken'] === $hashedToken) {
if (($user['resetTokenExpiry'] ?? 0) > time()) {
return $user;
}
// A newer token may have been issued since this snapshot was read.
// Expired tokens are rejected without overwriting the account.
return false;
}
}
return false;
}
/**
* Clear reset token for a user.
*/
function clearResetToken(string $userId): bool {
return updateUserRecord($userId, function (array &$user): void {
$user['resetToken'] = null;
$user['resetTokenExpiry'] = null;
});
}
/** Validate and consume a reset token in the same transaction as the password. */
function completePasswordReset(string $userId, string $rawToken, string $newHash): bool {
return updateUserRecord($userId, function (array &$user) use ($rawToken, $newHash): bool {
if (($user['resetTokenExpiry'] ?? 0) <= time()
|| !hash_equals((string)($user['resetToken'] ?? ''), hash('sha256', $rawToken))) return false;
$user['passwordHash'] = $newHash;
$user['resetToken'] = null;
$user['resetTokenExpiry'] = null;
return true;
});
}
/**
* Migrate from single-user config.php constants to users.json.
* Called automatically when users.json does not exist but ADMIN_USERNAME is defined.
*/
function migrateFromConfig(): void {
if (file_exists(USERS_PATH)) {
return;
}
if (!defined('ADMIN_USERNAME') || !defined('ADMIN_PASSWORD_HASH')) {
return;
}
$data = [
'users' => [
[
'id' => 'u_' . bin2hex(random_bytes(5)),
'username' => ADMIN_USERNAME,
'email' => '',
'role' => 'admin',
'passwordHash' => ADMIN_PASSWORD_HASH,
'createdAt' => gmdate('c'),
'createdBy' => 'migration',
'lastLogin' => null,
'resetToken' => null,
'resetTokenExpiry' => null,
]
]
];
saveUsers($data);
}
/**
* Ensure users.json exists. Migrates from config.php if needed.
* Call this early in any file that needs user data.
*/
function ensureUsersFile(): void {
if (!file_exists(USERS_PATH)) {
migrateFromConfig();
}
}
/**
* Get user list safe for API output (strips sensitive fields).
*/
function getUsersForApi(): array {
$data = loadUsers();
$result = [];
foreach ($data['users'] as $user) {
$result[] = [
'id' => $user['id'],
'username' => $user['username'],
'email' => $user['email'] ?? '',
'role' => $user['role'],
'createdAt' => $user['createdAt'],
'lastLogin' => $user['lastLogin'],
];
}
return $result;
}
/**
* Check if the current session user has admin role.
*/
function isAdmin(): bool {
return ($_SESSION['admin_role'] ?? '') === 'admin';
}

302
api/SmtpMailer.php Normal file
View File

@ -0,0 +1,302 @@
<?php
/**
* Simple SMTP mailer class (without external dependencies)
* Supports TLS/SSL encryption
**/
class SmtpMailer {
private $host;
private $port;
private $username;
private $password;
private $encryption;
private $socket;
private $lastError = '';
private $debug = false;
private $logFunction = null;
public function __construct($host, $port, $username, $password, $encryption = 'tls') {
$this->host = $host;
$this->port = $port;
$this->username = $username;
$this->password = $password;
$this->encryption = strtolower($encryption);
}
public function setDebug($debug) {
$this->debug = $debug;
}
public function setLogFunction($func) {
$this->logFunction = $func;
}
private function log($message) {
if ($this->logFunction && is_callable($this->logFunction)) {
call_user_func($this->logFunction, $message);
}
if ($this->debug) {
error_log($message);
}
}
public function getLastError() {
return $this->lastError;
}
/**
* Sends an email via SMTP
*/
public function send($to, $subject, $body, $fromEmail, $fromName = '', $replyTo = '', $bcc = []) {
try {
// Connect to the server
if (!$this->connect()) {
return false;
}
// Send EHLO
if (!$this->ehlo()) {
$this->disconnect();
return false;
}
// Start TLS if necessary
if ($this->encryption === 'tls') {
if (!$this->startTls()) {
$this->disconnect();
return false;
}
// After STARTTLS, send EHLO again
if (!$this->ehlo()) {
$this->disconnect();
return false;
}
}
// authentication
if (!$this->authenticate()) {
$this->disconnect();
return false;
}
// Send email
if (!$this->sendMail($to, $subject, $body, $fromEmail, $fromName, $replyTo, $bcc)) {
$this->disconnect();
return false;
}
$this->disconnect();
return true;
} catch (Exception $e) {
$this->lastError = $e->getMessage();
$this->disconnect();
return false;
}
}
private function connect() {
$host = $this->host;
// SSL prefix for port 465
if ($this->encryption === 'ssl') {
$host = 'ssl://' . $host;
}
$this->log("Connecting to $host:$this->port ...");
$this->socket = @fsockopen($host, $this->port, $errno, $errstr, 30);
if (!$this->socket) {
$this->lastError = "Connection failed: $errstr ($errno)";
$this->log("ERROR: " . $this->lastError);
return false;
}
$this->log("Connection established, waiting for greeting...");
// Read server greeting
$response = $this->getResponse();
if (substr($response, 0, 3) !== '220') {
$this->lastError = "Invalid server response: $response";
$this->log("ERROR: " . $this->lastError);
return false;
}
$this->log("Server greeting OK");
return true;
}
private function disconnect() {
if ($this->socket) {
$this->sendCommand("QUIT");
fclose($this->socket);
$this->socket = null;
}
}
private function ehlo() {
$hostname = isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'localhost';
// Never let a spoofed Host/SERVER_NAME inject SMTP commands via CR/LF.
$hostname = preg_replace('/[^A-Za-z0-9.\-\[\]:]/', '', (string)$hostname);
if ($hostname === '') $hostname = 'localhost';
$response = $this->sendCommand("EHLO $hostname");
return substr($response, 0, 3) === '250';
}
private function startTls() {
$response = $this->sendCommand("STARTTLS");
if (substr($response, 0, 3) !== '220') {
$this->lastError = "STARTTLS failed: $response";
return false;
}
// Enable TLS encryption
$crypto = stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
if (!$crypto) {
$this->lastError = "Failed to enable TLS encryption";
return false;
}
return true;
}
private function authenticate() {
// AUTH LOGIN
$response = $this->sendCommand("AUTH LOGIN");
if (substr($response, 0, 3) !== '334') {
$this->lastError = "AUTH LOGIN failed: $response";
return false;
}
// Username (Base64)
$response = $this->sendCommand(base64_encode($this->username));
if (substr($response, 0, 3) !== '334') {
$this->lastError = "Username rejected: $response";
return false;
}
// Password (Base64)
$response = $this->sendCommand(base64_encode($this->password));
if (substr($response, 0, 3) !== '235') {
$this->lastError = "Authentication failed: $response";
return false;
}
return true;
}
private function normalizeRecipients($value) {
$rawItems = is_array($value) ? $value : explode(',', (string)$value);
$recipients = [];
foreach ($rawItems as $item) {
foreach (explode(',', (string)$item) as $email) {
$email = trim(str_replace(["\r", "\n", "\t"], '', (string)$email));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$recipients[] = $email;
}
}
}
return array_values(array_unique($recipients));
}
private function formatRecipientHeader(array $recipients) {
return implode(', ', array_map(function ($email) {
return '<' . $email . '>';
}, $recipients));
}
private function sendMail($to, $subject, $body, $fromEmail, $fromName, $replyTo, $bcc = []) {
$toRecipients = $this->normalizeRecipients($to);
$bccRecipients = $this->normalizeRecipients($bcc);
if (empty($toRecipients)) {
$this->lastError = 'No valid recipient address';
return false;
}
// MAIL FROM
$response = $this->sendCommand("MAIL FROM:<$fromEmail>");
if (substr($response, 0, 3) !== '250') {
$this->lastError = "MAIL FROM rejected: $response";
return false;
}
// BCC recipients are envelope recipients only and are not written to headers.
foreach (array_merge($toRecipients, $bccRecipients) as $recipient) {
$response = $this->sendCommand("RCPT TO:<$recipient>");
if (!in_array(substr($response, 0, 3), ['250', '251'], true)) {
$this->lastError = "RCPT TO rejected for $recipient: $response";
return false;
}
}
// DATA
$response = $this->sendCommand("DATA");
if (substr($response, 0, 3) !== '354') {
$this->lastError = "DATA rejected: $response";
return false;
}
// Email headers and body
$headers = [];
$headers[] = "Date: " . date('r');
$headers[] = "From: " . ($fromName ? "=?UTF-8?B?" . base64_encode($fromName) . "?= <$fromEmail>" : $fromEmail);
$headers[] = "To: " . $this->formatRecipientHeader($toRecipients);
$headers[] = "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=";
if ($replyTo) {
$headers[] = "Reply-To: <$replyTo>";
}
$headers[] = "MIME-Version: 1.0";
$headers[] = "Content-Type: text/plain; charset=UTF-8";
$headers[] = "Content-Transfer-Encoding: 8bit";
$headers[] = "X-Mailer: SmtpMailer/1.0";
$message = implode("\r\n", $headers) . "\r\n\r\n" . $body;
// Escape dots at beginning of lines (SMTP protocol)
$message = str_replace("\n.", "\n..", $message);
// Send message and terminate with .
fwrite($this->socket, $message . "\r\n.\r\n");
$response = $this->getResponse();
if (substr($response, 0, 3) !== '250') {
$this->lastError = "Email not accepted: $response";
return false;
}
return true;
}
private function sendCommand($command) {
// Don't log password
$logCmd = (strpos($command, base64_encode($this->password)) !== false)
? '[PASSWORD]'
: $command;
$this->log("SMTP > $logCmd");
fwrite($this->socket, $command . "\r\n");
return $this->getResponse();
}
private function getResponse() {
$response = '';
while ($line = fgets($this->socket, 515)) {
$response .= $line;
// Check if last line (4th character is a space)
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
$this->log("SMTP < " . trim($response));
return $response;
}
}

69
api/backup-cron.php Normal file
View File

@ -0,0 +1,69 @@
<?php
/**
* Public web-cron endpoint for scheduled backups.
*
* Protected by the secret token stored in content/settings.json under
* backup.web_cron_token. Intended for external schedulers such as
* cron-job.org or EasyCron on hosts that do not provide server cron jobs.
*/
header('Content-Type: application/json; charset=utf-8');
ini_set('html_errors', '0');
ini_set('display_errors', '0');
$configPath = __DIR__ . '/../admin/config.php';
if (!is_file($configPath)) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Raiju is not configured.']);
exit;
}
require_once $configPath;
require_once __DIR__ . '/../includes/backup-helper.php';
function webCronResponse($success, $message, $data = null, $status = 200) {
http_response_code($status);
echo json_encode([
'success' => $success,
'message' => $message,
'data' => $data,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$config = backupConfig();
$expectedToken = trim((string)($config['web_cron_token'] ?? ''));
$submittedToken = trim((string)($_GET['token'] ?? $_POST['token'] ?? ''));
if ($expectedToken === '' || $submittedToken === '' || !hash_equals($expectedToken, $submittedToken)) {
webCronResponse(false, 'Invalid web cron token.', null, 403);
}
if (empty($config['enabled'])) {
webCronResponse(true, 'Backups are disabled in settings.', ['status' => backupStatus()]);
}
if (($config['cron_mode'] ?? 'server') !== 'web') {
webCronResponse(true, 'Web cron is not selected in backup settings.', ['status' => backupStatus()]);
}
try {
$result = backupWithLock(function() {
return backupRun(time(), null, true);
});
} catch (BackupLockException $e) {
webCronResponse(false, $e->getMessage(), null, 423);
} catch (Throwable $e) {
webCronResponse(false, $e->getMessage(), null, 500);
}
$created = $result['created'] ?? [];
$ok = !empty($created['ok']);
$message = $ok
? 'Backup run complete.'
: ($created['message'] ?? 'Backup failed.');
webCronResponse($ok, $message, [
'result' => $result,
'status' => backupStatus(),
], $ok ? 200 : 500);

287
api/contact.php Normal file
View File

@ -0,0 +1,287 @@
<?php
/**
* Contact Form Handler
* Processes form submissions and sends emails via SMTP or PHP mail()
* Also saves all inquiries locally as backup
*/
header('Content-Type: application/json; charset=utf-8');
// Only accept POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
require_once __DIR__ . '/SmtpMailer.php';
require_once __DIR__ . '/../includes/form-protection.php';
// Load email configuration from settings.json or fallback to smtp-config.php
$emailConfig = null;
$settingsPath = __DIR__ . '/../content/settings.json';
if (file_exists($settingsPath)) {
$settings = json_decode(file_get_contents($settingsPath), true);
if (!empty($settings['email'])) {
$emailConfig = $settings['email'];
}
}
// Fallback: legacy smtp-config.php (for existing installations)
if (!$emailConfig && file_exists(__DIR__ . '/smtp-config.php')) {
require_once __DIR__ . '/smtp-config.php';
$emailConfig = [
'method' => 'smtp',
'recipientEmail' => defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : '',
'fromEmail' => defined('SMTP_FROM_EMAIL') ? SMTP_FROM_EMAIL : '',
'fromName' => defined('SMTP_FROM_NAME') ? SMTP_FROM_NAME : '',
'smtpHost' => defined('SMTP_HOST') ? SMTP_HOST : '',
'smtpPort' => defined('SMTP_PORT') ? SMTP_PORT : 587,
'smtpUsername' => defined('SMTP_USERNAME') ? SMTP_USERNAME : '',
'smtpPassword' => defined('SMTP_PASSWORD') ? SMTP_PASSWORD : '',
'smtpEncryption' => defined('SMTP_ENCRYPTION') ? SMTP_ENCRYPTION : 'tls',
];
}
// Path to mail backup file
$mailsFile = __DIR__ . '/../content/mails.json';
/**
* Remove newlines from string (header injection protection)
*/
function sanitizeHeaderValue($value) {
return str_replace(["\r", "\n", "\t"], '', trim($value));
}
function parseConfiguredEmailList($value): array {
$rawItems = is_array($value) ? $value : explode(',', (string)$value);
$emails = [];
foreach ($rawItems as $item) {
foreach (explode(',', (string)$item) as $email) {
$email = sanitizeHeaderValue($email);
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$emails[] = $email;
}
}
}
return array_values(array_unique($emails));
}
/**
* Save email locally as backup
*/
function saveMailBackup($filePath, $mailData) {
return raijuJsonUpdate($filePath, function (array &$mails) use ($mailData): void {
array_unshift($mails, $mailData);
$mails = array_slice($mails, 0, 500);
});
}
// Get and sanitize form data
$lang = $_POST['lang'] ?? 'de';
$name = sanitizeHeaderValue($_POST['name'] ?? '');
$email = sanitizeHeaderValue($_POST['email'] ?? '');
$phone = sanitizeHeaderValue($_POST['phone'] ?? '');
$occasion = sanitizeHeaderValue($_POST['occasion'] ?? '');
$date = sanitizeHeaderValue($_POST['date'] ?? '');
$message = trim($_POST['message'] ?? '');
$clientKey = raijuFormClientKey();
if (!raijuFormReserveRequest($clientKey)) {
http_response_code(429);
echo json_encode([
'success' => false,
'message' => $lang === 'de'
? 'Zu viele Versuche. Bitte versuchen Sie es später erneut.'
: 'Too many attempts. Please try again later.'
]);
exit;
}
// Honeypot check. Return a neutral success response so simple bots cannot
// distinguish this from a real submission, but do not store or send anything.
if (!empty($_POST['website'])) {
raijuFormRecordAttempt($clientKey, false);
echo json_encode([
'success' => true,
'message' => $lang === 'de'
? 'Vielen Dank für Ihre Nachricht!'
: 'Thank you for your message!'
]);
exit;
}
// Validation
$errors = [];
if (empty($name)) {
$errors[] = $lang === 'de' ? 'Name ist erforderlich' : 'Name is required';
}
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = $lang === 'de' ? 'Gültige E-Mail ist erforderlich' : 'Valid email is required';
}
if (empty($occasion)) {
$errors[] = $lang === 'de' ? 'Anlass ist erforderlich' : 'Occasion is required';
}
if (empty($message)) {
$errors[] = $lang === 'de' ? 'Nachricht ist erforderlich' : 'Message is required';
}
if (!empty($errors)) {
echo json_encode(['success' => false, 'message' => implode(', ', $errors)]);
exit;
}
if (raijuFormLooksLikeSpam([$name, $email, $phone, $occasion, $date, $message])) {
raijuFormRecordAttempt($clientKey, false);
echo json_encode([
'success' => false,
'message' => $lang === 'de'
? 'Die Nachricht konnte nicht gesendet werden. Bitte prüfen Sie Ihre Eingaben.'
: 'The message could not be sent. Please check your input.'
]);
exit;
}
$tokenResult = raijuValidateFormToken($_POST['form_token'] ?? null, 'contact');
if (empty($tokenResult['valid'])) {
raijuFormRecordAttempt($clientKey, false);
$newToken = raijuCreateFormToken('contact');
http_response_code(400);
echo json_encode([
'success' => false,
'formToken' => $newToken,
'message' => $lang === 'de'
? 'Das Formular ist abgelaufen oder wurde zu schnell gesendet. Bitte versuchen Sie es erneut.'
: 'The form expired or was submitted too quickly. Please try again.'
]);
exit;
}
// Compose email
$subject = ($lang === 'de' ? 'Anfrage über Website: ' : 'Website inquiry: ') . $occasion;
$body = $lang === 'de'
? "Neue Anfrage über das Kontaktformular\n"
: "New inquiry via contact form\n";
$body .= str_repeat('-', 40) . "\n\n";
$body .= ($lang === 'de' ? "Name: " : "Name: ") . $name . "\n";
$body .= ($lang === 'de' ? "E-Mail: " : "Email: ") . $email . "\n";
if ($phone) {
$body .= ($lang === 'de' ? "Telefon: " : "Phone: ") . $phone . "\n";
}
$body .= ($lang === 'de' ? "Anlass: " : "Occasion: ") . $occasion . "\n";
if ($date) {
$formattedDate = date('d.m.Y', strtotime($date));
$body .= ($lang === 'de' ? "Gewünschtes Datum: " : "Preferred date: ") . $formattedDate . "\n";
}
$body .= "\n" . ($lang === 'de' ? "Nachricht:\n" : "Message:\n");
$body .= str_repeat('-', 40) . "\n";
$body .= $message . "\n";
$body .= str_repeat('-', 40) . "\n";
// Send email when configured. Valid submissions are stored locally even when
// email delivery is disabled or misconfigured, so contact requests are not lost.
$sent = false;
$method = $emailConfig['method'] ?? 'inactive';
$toRecipients = parseConfiguredEmailList($emailConfig['recipientEmail'] ?? '');
$bccRecipients = parseConfiguredEmailList($emailConfig['bccEmail'] ?? '');
$to = implode(', ', $toRecipients);
$fromEmail = sanitizeHeaderValue($emailConfig['fromEmail'] ?? '') ?: ($toRecipients[0] ?? '');
$fromName = sanitizeHeaderValue($emailConfig['fromName'] ?? '');
$replyToEmail = filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '';
$isEmailConfigured = $emailConfig
&& $method !== 'inactive'
&& !empty($toRecipients);
if ($isEmailConfigured && $method === 'smtp') {
$mailer = new SmtpMailer(
$emailConfig['smtpHost'] ?? '',
intval($emailConfig['smtpPort'] ?? 587),
$emailConfig['smtpUsername'] ?? '',
$emailConfig['smtpPassword'] ?? '',
$emailConfig['smtpEncryption'] ?? 'tls'
);
$sent = $mailer->send(
$toRecipients,
$subject,
$body,
$fromEmail,
$fromName,
$replyToEmail,
$bccRecipients
);
} elseif ($isEmailConfigured && $method === 'sendmail') {
// PHP mail() — requires server IP to be whitelisted for sending
$headers = [];
$headers[] = 'From: ' . ($fromName ? "=?UTF-8?B?" . base64_encode($fromName) . "?= <$fromEmail>" : $fromEmail);
if ($replyToEmail) {
$headers[] = 'Reply-To: ' . $replyToEmail;
}
$headers[] = 'Content-Type: text/plain; charset=UTF-8';
$headers[] = 'X-Mailer: Raiju CMS';
$sent = @mail($to, '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers));
foreach ($bccRecipients as $bccRecipient) {
$sent = @mail($bccRecipient, '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers)) && $sent;
}
}
$status = $sent ? 'sent' : ($isEmailConfigured ? 'send_failed' : 'local_only');
$saved = saveMailBackup($mailsFile, [
'id' => uniqid('mail_'),
'timestamp' => date('c'),
'lang' => $lang,
'formId' => 'contact',
'formLabel' => $lang === 'de' ? 'Kontaktformular' : 'Contact form',
'name' => $name,
'email' => $email,
'phone' => $phone,
'occasion' => $occasion,
'date' => $date,
'message' => $message,
'fields' => [
['key' => 'name', 'label' => $lang === 'de' ? 'Name' : 'Name', 'value' => $name],
['key' => 'email', 'label' => $lang === 'de' ? 'E-Mail' : 'Email', 'value' => $email],
['key' => 'phone', 'label' => $lang === 'de' ? 'Telefon' : 'Phone', 'value' => $phone],
['key' => 'message', 'label' => $lang === 'de' ? 'Nachricht' : 'Message', 'value' => $message],
],
'status' => $status,
'read' => false,
'starred' => false
]);
if (!$saved) {
http_response_code(500);
echo json_encode([
'success' => false,
'message' => $lang === 'de'
? 'Die Nachricht konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.'
: 'The message could not be saved. Please try again later.'
]);
exit;
}
raijuFormRecordAttempt($clientKey, true);
$newToken = raijuCreateFormToken('contact');
echo json_encode([
'success' => true,
'status' => $status,
'formToken' => $newToken,
'message' => $lang === 'de'
? 'Vielen Dank für Ihre Nachricht!'
: 'Thank you for your message!'
]);

132
api/form-submit.php Normal file
View File

@ -0,0 +1,132 @@
<?php
/**
* Generic JSON-backed form submission endpoint.
*/
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit;
}
require_once __DIR__ . '/../includes/forms.php';
$lang = $_POST['lang'] ?? 'de';
$formId = raijuFormIdClean((string)($_POST['form_id'] ?? ''));
$form = $formId ? raijuFormLoad($formId) : null;
$clientKey = raijuFormClientKey();
function raijuFormSubmitResponse(bool $success, string $message, array $extra = [], int $status = 200): void
{
http_response_code($status);
echo json_encode(array_merge([
'success' => $success,
'message' => $message,
], $extra), JSON_UNESCAPED_UNICODE);
exit;
}
if (!$form || empty($form['enabled'])) {
raijuFormSubmitResponse(false, $lang === 'de' ? 'Formular nicht gefunden.' : 'Form not found.', [], 404);
}
if (!raijuFormReserveRequest($clientKey)) {
raijuFormSubmitResponse(false, $lang === 'de' ? 'Zu viele Versuche. Bitte versuchen Sie es später erneut.' : 'Too many attempts. Please try again later.', [], 429);
}
if (!empty($_POST['website'])) {
raijuFormRecordAttempt($clientKey, false);
raijuFormSubmitResponse(true, (string)($form['submit']['successText'] ?? 'Vielen Dank.'));
}
$tokenResult = raijuValidateFormToken($_POST['form_token'] ?? null, $form['id']);
if (empty($tokenResult['valid'])) {
raijuFormRecordAttempt($clientKey, false);
raijuFormSubmitResponse(false, $lang === 'de'
? 'Das Formular ist abgelaufen oder wurde zu schnell gesendet. Bitte versuchen Sie es erneut.'
: 'The form expired or was submitted too quickly. Please try again.', [
'formToken' => raijuCreateFormToken($form['id']),
], 400);
}
[$errors, $values] = raijuFormValidateSubmission($form, (string)$lang);
if ($errors) {
raijuFormRecordAttempt($clientKey, false);
raijuFormSubmitResponse(false, implode(', ', $errors), [
'formToken' => raijuCreateFormToken($form['id']),
]);
}
if (raijuFormLooksLikeSpam($values)) {
raijuFormRecordAttempt($clientKey, false);
raijuFormSubmitResponse(false, $lang === 'de'
? 'Die Nachricht konnte nicht gesendet werden. Bitte prüfen Sie Ihre Eingaben.'
: 'The message could not be sent. Please check your input.', [
'formToken' => raijuCreateFormToken($form['id']),
]);
}
$subject = raijuFormsApplySubjectTemplate((string)($form['submit']['subject'] ?? ''), $form, $values);
$body = raijuFormsBuildBody($form, $values);
$sent = false;
if (!empty($form['submit']['email'])) {
$sent = raijuFormsSendNotification($form, $values, $subject, $body);
}
$store = !array_key_exists('store', $form['submit']) || !empty($form['submit']['store']);
$saved = true;
if ($store) {
$fieldRows = [];
foreach ($form['fields'] as $field) {
if (in_array(($field['type'] ?? ''), ['heading', 'note', 'hidden'], true)) {
continue;
}
$fieldRows[] = [
'key' => $field['key'],
'label' => $field['label'],
'type' => $field['type'],
'value' => $values[$field['key']] ?? '',
];
}
$saved = raijuFormsSaveSubmission([
'id' => uniqid('mail_', true),
'formId' => $form['id'],
'formLabel' => $form['label'],
'name' => $values['name'] ?? ($values['email'] ?? $form['label']),
'email' => $values['email'] ?? '',
'phone' => $values['phone'] ?? '',
'occasion' => $subject,
'message' => $values['message'] ?? '',
'fields' => $fieldRows,
'timestamp' => date('c'),
'status' => $sent ? 'sent' : (!empty($form['submit']['email']) ? 'send_failed' : 'local_only'),
'read' => false,
'starred' => false,
]);
}
if (!$saved) {
raijuFormRecordAttempt($clientKey, false);
raijuFormSubmitResponse(false, $lang === 'de'
? 'Die Nachricht konnte nicht gespeichert werden. Bitte versuchen Sie es später erneut.'
: 'The message could not be saved. Please try again later.', [
'formToken' => raijuCreateFormToken($form['id']),
], 500);
}
// A successful response requires at least one actual delivery channel.
if (!$store && !$sent) {
raijuFormRecordAttempt($clientKey, false);
raijuFormSubmitResponse(false, $lang === 'de'
? 'Die Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.'
: 'The message could not be sent. Please try again later.', [
'formToken' => raijuCreateFormToken($form['id']),
], 503);
}
raijuFormRecordAttempt($clientKey, true);
raijuFormSubmitResponse(true, (string)($form['submit']['successText'] ?? 'Vielen Dank.'), [
'formToken' => raijuCreateFormToken($form['id']),
]);

61
api/form.php Normal file
View File

@ -0,0 +1,61 @@
<?php
/**
* Lazy public form renderer.
*
* Returns protected form HTML with fresh one-time tokens. The endpoint is
* intentionally small and whitelisted so arbitrary PHP cannot be included.
*/
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
header('Content-Type: text/plain; charset=utf-8');
echo 'Method not allowed';
exit;
}
require_once __DIR__ . '/../includes/form-protection.php';
require_once __DIR__ . '/../includes/forms.php';
$form = $_GET['form'] ?? '';
$lang = $_GET['lang'] ?? 'en';
$incomingBasePath = $_GET['basePath'] ?? ($_GET['basepath'] ?? '');
if (!preg_match('/^[a-z]{2}$/', $lang)) {
$lang = 'en';
}
$basePath = raijuSafeFormBasePath((string) $incomingBasePath);
$currentLang = $lang;
$_cfLazy = false;
define('RAIJU_RENDER_LAZY_FORM', true);
header('Content-Type: text/html; charset=utf-8');
header('X-Robots-Tag: noindex, nofollow');
if (raijuFormExists($form)) {
echo raijuFormRender($form, [
'basePath' => $basePath,
'lang' => $currentLang,
'lazy' => false,
]);
exit;
}
switch ($form) {
case 'contact':
include __DIR__ . '/../includes/contact-form.php';
break;
case 'pricing-contact':
$_selectedPackage = is_string($_GET['package'] ?? null)
? trim((string) $_GET['package'])
: '';
include __DIR__ . '/../includes/pricing-contact-form.php';
break;
default:
http_response_code(404);
echo 'Form not found';
break;
}

View File

@ -0,0 +1,15 @@
<?php
/**
* SMTP Configuration for email sending
* Copy this file to smtp-config.php and adjust the values.
*
* IMPORTANT: This file contains credentials and should NOT be publicly accessible!
*/
define('SMTP_HOST', 'mail.example.com');
define('SMTP_PORT', 587); // TLS Port (alternative: 465 for SSL)
define('SMTP_USERNAME', 'your@email.com');
define('SMTP_PASSWORD', 'your-password-here');
define('SMTP_FROM_EMAIL', 'your@email.com');
define('SMTP_FROM_NAME', 'My Website');
define('SMTP_ENCRYPTION', 'tls'); // 'tls' or 'ssl'

1110
architecture.md Normal file

File diff suppressed because it is too large Load Diff

20
assets/.htaccess Normal file
View File

@ -0,0 +1,20 @@
# Uploaded media is served as static files only. Even if an upload check ever
# regresses, a file placed here must never be executed as code. This is a
# second layer behind the extension checks in admin/api/media.php.
#
# Require all denied needs Apache 2.4 (mod_authz_core); RemoveHandler/RemoveType
# are core mod_mime. None of these directives error under PHP-FPM the way
# php_flag would, so this file is safe on shared hosting with AllowOverride All.
<FilesMatch "(?i)\.(php|php[0-9]?|phtml|phtm|pht|phar|phps|cgi|pl|py|shtml|asp|aspx|jsp|htaccess)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
RemoveHandler .php .php3 .php4 .php5 .php7 .phtml .phtm .pht .phar .phps .cgi .pl .py .shtml
RemoveType .php .php3 .php4 .php5 .php7 .phtml .phtm .pht .phar .phps .cgi .pl .py .shtml

View File

0
assets/audio/.gitkeep Normal file
View File

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