Raiju 1.0.0
This commit is contained in:
commit
44dadf1900
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal 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
80
.github/workflows/ci.yml
vendored
Normal 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
109
.gitignore
vendored
Normal 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
93
.htaccess
Normal 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
6
404.php
Normal 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
9
410.php
Normal 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
78
AGENTS.md
Normal 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
1960
AI-AGENT-GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
24
CHANGELOG-fork.md
Normal file
24
CHANGELOG-fork.md
Normal 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
38
CHANGELOG.md
Normal 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
22
CLAUDE.md
Normal 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
60
CONTRIBUTING.md
Normal 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
343
LICENSE
Normal 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
253
README.md
Normal 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
36
RELEASES.md
Normal 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
183
ROUTING.md
Normal 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
580
SKILLS.md
Normal 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
252
THIRD-PARTY-NOTICES.md
Normal 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
17
UPGRADING.md
Normal 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`.
|
||||
41
admin/api.php
Normal file
41
admin/api.php
Normal 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
307
admin/api/agent.php
Normal 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
1773
admin/api/ai.php
Normal file
File diff suppressed because it is too large
Load Diff
1000
admin/api/backups.php
Normal file
1000
admin/api/backups.php
Normal file
File diff suppressed because it is too large
Load Diff
2056
admin/api/bootstrap.php
Normal file
2056
admin/api/bootstrap.php
Normal file
File diff suppressed because it is too large
Load Diff
77
admin/api/contracts.php
Normal file
77
admin/api/contracts.php
Normal 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
78
admin/api/dashboard.php
Normal 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
338
admin/api/events.php
Normal 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
234
admin/api/forms.php
Normal 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
136
admin/api/icons.php
Normal 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
950
admin/api/media.php
Normal 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
67
admin/api/navigation.php
Normal 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
194
admin/api/news.php
Normal 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
530
admin/api/pages.php
Normal 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
163
admin/api/routes.php
Normal 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
603
admin/api/settings.php
Normal 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
242
admin/api/users.php
Normal 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
73
admin/config.example.php
Normal 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
3118
admin/dashboard.php
Normal file
File diff suppressed because it is too large
Load Diff
922
admin/dashboard/scripts/backups.php
Normal file
922
admin/dashboard/scripts/backups.php
Normal 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); });
|
||||
}
|
||||
})();
|
||||
65
admin/dashboard/scripts/bootstrap.php
Normal file
65
admin/dashboard/scripts/bootstrap.php
Normal 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); ?>';
|
||||
688
admin/dashboard/scripts/events.php
Normal file
688
admin/dashboard/scripts/events.php
Normal 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');
|
||||
});
|
||||
206
admin/dashboard/scripts/forms.php
Normal file
206
admin/dashboard/scripts/forms.php
Normal 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';
|
||||
}
|
||||
603
admin/dashboard/scripts/icons.php
Normal file
603
admin/dashboard/scripts/icons.php
Normal 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; ?>
|
||||
578
admin/dashboard/scripts/media.php
Normal file
578
admin/dashboard/scripts/media.php
Normal 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)">↑</button>
|
||||
<button class="btn btn-sm btn-secondary" onclick="moveSection(${index}, 1)">↓</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 || '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
545
admin/dashboard/scripts/messages.php
Normal file
545
admin/dashboard/scripts/messages.php
Normal 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();
|
||||
329
admin/dashboard/scripts/navigation.php
Normal file
329
admin/dashboard/scripts/navigation.php
Normal 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);
|
||||
}
|
||||
593
admin/dashboard/scripts/news.php
Normal file
593
admin/dashboard/scripts/news.php
Normal 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');
|
||||
}
|
||||
}
|
||||
2352
admin/dashboard/scripts/overview-ai.php
Normal file
2352
admin/dashboard/scripts/overview-ai.php
Normal file
File diff suppressed because it is too large
Load Diff
339
admin/dashboard/scripts/page-actions.php
Normal file
339
admin/dashboard/scripts/page-actions.php
Normal 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');
|
||||
2274
admin/dashboard/scripts/pages.php
Normal file
2274
admin/dashboard/scripts/pages.php
Normal file
File diff suppressed because it is too large
Load Diff
76
admin/dashboard/scripts/password.php
Normal file
76
admin/dashboard/scripts/password.php
Normal 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');
|
||||
}
|
||||
});
|
||||
})();
|
||||
48
admin/dashboard/scripts/reset.php
Normal file
48
admin/dashboard/scripts/reset.php
Normal 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');
|
||||
}
|
||||
});
|
||||
})();
|
||||
3069
admin/dashboard/scripts/settings.php
Normal file
3069
admin/dashboard/scripts/settings.php
Normal file
File diff suppressed because it is too large
Load Diff
405
admin/dashboard/scripts/system.php
Normal file
405
admin/dashboard/scripts/system.php
Normal 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));
|
||||
}
|
||||
442
admin/dashboard/scripts/users-navigation.php
Normal file
442
admin/dashboard/scripts/users-navigation.php
Normal 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
98
admin/fonts.css
Normal 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;
|
||||
}
|
||||
BIN
admin/fonts/atkinson-hyperlegible-next-variable-latin-ext.woff2
Normal file
BIN
admin/fonts/atkinson-hyperlegible-next-variable-latin-ext.woff2
Normal file
Binary file not shown.
BIN
admin/fonts/atkinson-hyperlegible-next-variable-latin.woff2
Normal file
BIN
admin/fonts/atkinson-hyperlegible-next-variable-latin.woff2
Normal file
Binary file not shown.
BIN
admin/fonts/geist-mono-variable.woff2
Normal file
BIN
admin/fonts/geist-mono-variable.woff2
Normal file
Binary file not shown.
BIN
admin/fonts/ibm-plex-sans-variable-latin-ext.woff2
Normal file
BIN
admin/fonts/ibm-plex-sans-variable-latin-ext.woff2
Normal file
Binary file not shown.
BIN
admin/fonts/ibm-plex-sans-variable-latin.woff2
Normal file
BIN
admin/fonts/ibm-plex-sans-variable-latin.woff2
Normal file
Binary file not shown.
BIN
admin/fonts/inter-variable-latin-ext.woff2
Normal file
BIN
admin/fonts/inter-variable-latin-ext.woff2
Normal file
Binary file not shown.
BIN
admin/fonts/inter-variable-latin.woff2
Normal file
BIN
admin/fonts/inter-variable-latin.woff2
Normal file
Binary file not shown.
BIN
admin/fonts/public-sans-variable-latin-ext.woff2
Normal file
BIN
admin/fonts/public-sans-variable-latin-ext.woff2
Normal file
Binary file not shown.
BIN
admin/fonts/public-sans-variable-latin.woff2
Normal file
BIN
admin/fonts/public-sans-variable-latin.woff2
Normal file
Binary file not shown.
768
admin/index.php
Normal file
768
admin/index.php
Normal 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("' . htmlspecialchars($loginCssImage, ENT_QUOTES, 'UTF-8') . '")' . $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">← <?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">← <?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">← <?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">← <?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="..">← <?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
1943
admin/lang/cs.json
Normal file
File diff suppressed because it is too large
Load Diff
2093
admin/lang/de.json
Normal file
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
213
admin/lang/editor-cs.json
Normal 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
231
admin/lang/editor-de.json
Normal 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
231
admin/lang/editor-en.json
Normal 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
213
admin/lang/editor-es.json
Normal 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
213
admin/lang/editor-fr.json
Normal 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
213
admin/lang/editor-it.json
Normal 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
213
admin/lang/editor-pl.json
Normal 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
213
admin/lang/editor-pt.json
Normal 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
213
admin/lang/editor-tr.json
Normal 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
2093
admin/lang/en.json
Normal file
File diff suppressed because it is too large
Load Diff
1943
admin/lang/es.json
Normal file
1943
admin/lang/es.json
Normal file
File diff suppressed because it is too large
Load Diff
1943
admin/lang/fr.json
Normal file
1943
admin/lang/fr.json
Normal file
File diff suppressed because it is too large
Load Diff
125
admin/lang/i18n.php
Normal file
125
admin/lang/i18n.php
Normal 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
1943
admin/lang/it.json
Normal file
File diff suppressed because it is too large
Load Diff
1943
admin/lang/pl.json
Normal file
1943
admin/lang/pl.json
Normal file
File diff suppressed because it is too large
Load Diff
1943
admin/lang/pt.json
Normal file
1943
admin/lang/pt.json
Normal file
File diff suppressed because it is too large
Load Diff
1943
admin/lang/tr.json
Normal file
1943
admin/lang/tr.json
Normal file
File diff suppressed because it is too large
Load Diff
680
admin/setup.php
Normal file
680
admin/setup.php
Normal 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' => '© [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
1476
admin/starter-content.php
Normal file
File diff suppressed because it is too large
Load Diff
12165
admin/style.css
Normal file
12165
admin/style.css
Normal file
File diff suppressed because it is too large
Load Diff
333
admin/users.php
Normal file
333
admin/users.php
Normal 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
302
api/SmtpMailer.php
Normal 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
69
api/backup-cron.php
Normal 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
287
api/contact.php
Normal 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
132
api/form-submit.php
Normal 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
61
api/form.php
Normal 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;
|
||||
}
|
||||
15
api/smtp-config.example.php
Normal file
15
api/smtp-config.example.php
Normal 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
1110
architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
20
assets/.htaccess
Normal file
20
assets/.htaccess
Normal 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
|
||||
0
assets/audio-trash/.gitkeep
Normal file
0
assets/audio-trash/.gitkeep
Normal file
0
assets/audio/.gitkeep
Normal file
0
assets/audio/.gitkeep
Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user