commit
This commit is contained in:
128
README.md
128
README.md
@@ -1,93 +1,45 @@
|
|||||||
# emailtemplate
|
# Mailadmin – Email Template System (R17 modular)
|
||||||
|
Stand: 2025-08-31
|
||||||
|
|
||||||
|
## Struktur
|
||||||
|
mailadmin/
|
||||||
|
inc/
|
||||||
|
config.example.php # Deine echte config.php kommt hierhin (NICHT in public/)
|
||||||
|
public/
|
||||||
|
index.php # Admin UI (helles Design)
|
||||||
|
api.php # JSON API (Dual-DB + Prefix)
|
||||||
|
tools/
|
||||||
|
config-doctor.php # Prüft inc/config.php
|
||||||
|
db-doctor.php # Prüft DB-Verbindung
|
||||||
|
assets/
|
||||||
|
css/toast.css
|
||||||
|
js/
|
||||||
|
toast.js
|
||||||
|
app.js # entry (type=module)
|
||||||
|
api.js # API wrapper
|
||||||
|
ui-tabs.js # Tabs
|
||||||
|
ui-list.js # Liste + Vorschau + Löschen + Editor öffnen
|
||||||
|
ui-create.js # „Neu …“ dialog
|
||||||
|
ui-editor.js # Editor-Dialog + Handshake
|
||||||
|
editor/
|
||||||
|
editor-core.php # LÄDT NUR LOKAL (kein CDN)
|
||||||
|
bridge-core.js
|
||||||
|
config.js # Bibliothek in Block-Leiste
|
||||||
|
|
||||||
|
## Vendor (lokal bereitstellen)
|
||||||
|
Lege die GrapesJS-Dateien lokal ab (kein CDN):
|
||||||
|
- mailadmin/public/vendor/grapesjs/grapes.min.css
|
||||||
|
- mailadmin/public/vendor/grapesjs/grapes.min.js
|
||||||
|
- mailadmin/public/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js
|
||||||
|
|
||||||
## Getting started
|
## Datenbank
|
||||||
|
Nutze `schema.sql` (inkl. *NULL‑fähiger* Fremdschlüssel & ON DELETE SET NULL).
|
||||||
|
Prefix standardmäßig: `emailtemplate_`.
|
||||||
|
|
||||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
## Schnellstart
|
||||||
|
1) `mailadmin/inc/config.php` anlegen (siehe `config.example.php`).
|
||||||
|
2) `schema.sql` in deiner Templates-Datenbank ausführen.
|
||||||
|
3) Vendor-Dateien in `public/vendor/...` kopieren.
|
||||||
|
4) `public/tools/config-doctor.php` & `public/api.php?action=health` prüfen.
|
||||||
|
5) `public/index.php` öffnen → „Neu …“, „Vorschau“, „Im E‑Mail‑Editor öffnen“ etc.
|
||||||
|
|
||||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
|
||||||
|
|
||||||
## Add your files
|
|
||||||
|
|
||||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
|
||||||
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
|
||||||
|
|
||||||
```
|
|
||||||
cd existing_repo
|
|
||||||
git remote add origin https://gitlab.int.kusche.berlin/emailtemplate/emailtemplate.git
|
|
||||||
git branch -M main
|
|
||||||
git push -uf origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integrate with your tools
|
|
||||||
|
|
||||||
- [ ] [Set up project integrations](https://gitlab.int.kusche.berlin/emailtemplate/emailtemplate/-/settings/integrations)
|
|
||||||
|
|
||||||
## Collaborate with your team
|
|
||||||
|
|
||||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
|
||||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
|
||||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
|
||||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
|
||||||
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
|
||||||
|
|
||||||
## Test and Deploy
|
|
||||||
|
|
||||||
Use the built-in continuous integration in GitLab.
|
|
||||||
|
|
||||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
|
||||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
|
||||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
|
||||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
|
||||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
# Editing this README
|
|
||||||
|
|
||||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
|
||||||
|
|
||||||
## Suggestions for a good README
|
|
||||||
|
|
||||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
|
||||||
|
|
||||||
## Name
|
|
||||||
Choose a self-explaining name for your project.
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
|
||||||
|
|
||||||
## Badges
|
|
||||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
|
||||||
|
|
||||||
## Visuals
|
|
||||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
State if you are open to contributions and what your requirements are for accepting them.
|
|
||||||
|
|
||||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
|
||||||
|
|
||||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
|
||||||
|
|
||||||
## Authors and acknowledgment
|
|
||||||
Show your appreciation to those who have contributed to the project.
|
|
||||||
|
|
||||||
## License
|
|
||||||
For open source projects, say how it is licensed.
|
|
||||||
|
|
||||||
## Project status
|
|
||||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
|
||||||
|
|||||||
1
README.txt
Normal file
1
README.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This archive contains the R17 modular Email Template System. Fill inc/config.php and vendor libs.
|
||||||
47
STATE.yaml
Normal file
47
STATE.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
state_version: 1
|
||||||
|
project: emailtemplate-suite
|
||||||
|
snapshot_label: "stand-2025-09-04"
|
||||||
|
editor:
|
||||||
|
bridge_core: "bridge-core.js"
|
||||||
|
grapesjs: "local vendor, no CDN"
|
||||||
|
blocks:
|
||||||
|
categories: ["Bausteine","Bibliothek","Bibliothek: Sections","Bibliothek: Blöcke","Default(last, closed)"]
|
||||||
|
features:
|
||||||
|
- "Snippets by value (HTML)"
|
||||||
|
- "Blocks/Sections als Referenzen (data-ref-*)"
|
||||||
|
- "Quelltext-Button (custom) aktiv"
|
||||||
|
- "Reload-Bibliothek-Button (Snippets) aktiv"
|
||||||
|
- "Light UI"
|
||||||
|
api:
|
||||||
|
file: "public/api.php"
|
||||||
|
features:
|
||||||
|
- "CRUD: templates, sections, blocks, snippets"
|
||||||
|
- "Items: template_items, section_items (sync)"
|
||||||
|
- "render: template|section ({{children}})"
|
||||||
|
- "health, debug=db"
|
||||||
|
db_profiles:
|
||||||
|
templates: true
|
||||||
|
project: optional
|
||||||
|
table_prefix: "emailtemplate_"
|
||||||
|
db:
|
||||||
|
schema_file: "schema.sql"
|
||||||
|
tables:
|
||||||
|
- emailtemplate_templates
|
||||||
|
- emailtemplate_sections
|
||||||
|
- emailtemplate_blocks
|
||||||
|
- emailtemplate_snippets
|
||||||
|
- emailtemplate_template_items
|
||||||
|
- emailtemplate_section_items
|
||||||
|
ui:
|
||||||
|
files: ["public/index.php","public/app.js","public/ui-list.js","public/ui-create.js","public/ui-editor.js"]
|
||||||
|
vendor: ["public/vendor/grapesjs/","public/vendor/grapesjs-preset-newsletter/"]
|
||||||
|
tools:
|
||||||
|
doctor: "public/tools/db-doctor.php"
|
||||||
|
compat_signature:
|
||||||
|
api: "ETS-API-2025-09-04"
|
||||||
|
bridge_core: "ETS-BRIDGE-2025-09-04"
|
||||||
|
notes:
|
||||||
|
- "DocumentRoot zeigt auf /public"
|
||||||
|
- "inc/config.php liegt außerhalb des Webroots"
|
||||||
|
- "kein Tailwind-CDN in Prod"
|
||||||
|
|
||||||
6
composer.json
Normal file
6
composer.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "ssh-w020abd8/staging",
|
||||||
|
"require": {
|
||||||
|
"tijsverkoyen/css-to-inline-styles": "^2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
139
composer.lock
generated
Normal file
139
composer.lock
generated
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "ce66bd5061d136b6edd6dea47ae9354d",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "symfony/css-selector",
|
||||||
|
"version": "v7.3.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/css-selector.git",
|
||||||
|
"reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2",
|
||||||
|
"reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\CssSelector\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jean-François Simon",
|
||||||
|
"email": "jeanfrancois.simon@sensiolabs.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Converts CSS selectors to XPath expressions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/css-selector/tree/v7.3.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-09-25T14:21:43+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
|
"version": "v2.3.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
|
||||||
|
"reference": "0d72ac1c00084279c1816675284073c5a337c20d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d",
|
||||||
|
"reference": "0d72ac1c00084279c1816675284073c5a337c20d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"symfony/css-selector": "^5.4 || ^6.0 || ^7.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^2.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^2.0",
|
||||||
|
"phpunit/phpunit": "^8.5.21 || ^9.5.10"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"TijsVerkoyen\\CssToInlineStyles\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Tijs Verkoyen",
|
||||||
|
"email": "css_to_inline_styles@verkoyen.eu",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.",
|
||||||
|
"homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
|
||||||
|
"source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0"
|
||||||
|
},
|
||||||
|
"time": "2024-12-21T16:25:41+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": {},
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {},
|
||||||
|
"platform-dev": {},
|
||||||
|
"plugin-api-version": "2.6.0"
|
||||||
|
}
|
||||||
4
config/domaindata.php
Normal file
4
config/domaindata.php
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
define('APP_DOMAIN_NAME', 'usbcheck.it');
|
||||||
|
define('APP_PREFIX', 'usbcheck');
|
||||||
121
config/fileload.php
Normal file
121
config/fileload.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
// 0) Umgebung / Domains / Error-Level laden
|
||||||
|
// → Diese Datei DEFINIERT die Konstanten wie
|
||||||
|
// APP_COOKIE_PREFIX, APP_COOKIE_DOMAIN, APP_ENV etc.
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
require_once __DIR__ . "/config.php";
|
||||||
|
|
||||||
|
|
||||||
|
// Diese Werte später ins Template schieben:
|
||||||
|
$GLOBALS['app_env'] = APP_ENV;
|
||||||
|
$GLOBALS['app_base_url'] = APP_URL_PRIMARY;
|
||||||
|
$GLOBALS['app_api_base'] = $apiBaseUrl;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
// set cookie / session parameters
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
if (!defined('CUSTOM_PREFIX')) {
|
||||||
|
define('CUSTOM_PREFIX', APP_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!defined('APP_COOKIE_PREFIX')) {
|
||||||
|
if(APP_ENV==="staging"){
|
||||||
|
define('APP_COOKIE_PREFIX', APP_PREFIX.'_stg'.'_');
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
define('APP_COOKIE_PREFIX', APP_PREFIX.'_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('APP_COOKIE_DOMAIN')) {
|
||||||
|
// Fallback: aktuelle Domain des Hosts
|
||||||
|
define('APP_COOKIE_DOMAIN', '.'.APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_PRIMARY_DOMAIN', APP_DOMAIN_PRIMARY);
|
||||||
|
}
|
||||||
|
if (!defined('APP_CLIENT_COOKIE_LIFETIME')) {
|
||||||
|
define('APP_CLIENT_COOKIE_LIFETIME', 365 * 24 * 60 * 60); // 1 Jahr
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Einheitliche Cookie-Namen (projektübergreifend steuerbar)
|
||||||
|
$sessionCookieName = APP_COOKIE_PREFIX . 'session';
|
||||||
|
$clientCookieName = APP_COOKIE_PREFIX . 'client';
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
// 1) PHP-Session starten
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
|
||||||
|
session_name($sessionCookieName);
|
||||||
|
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => APP_COOKIE_DOMAIN ?: '',
|
||||||
|
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
// 2) Persistente Client-ID (für Tracking über Besuche hinweg)
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
$clientId = $_COOKIE[$clientCookieName] ?? null;
|
||||||
|
|
||||||
|
// Erwartet wird: 64 Hex-Zeichen (32 Bytes)
|
||||||
|
if (
|
||||||
|
!is_string($clientId) ||
|
||||||
|
$clientId === '' ||
|
||||||
|
!preg_match('/^[a-f0-9]{64}$/', $clientId)
|
||||||
|
) {
|
||||||
|
// neue ID erzeugen
|
||||||
|
try {
|
||||||
|
$clientId = bin2hex(random_bytes(32)); // 32 bytes → 64 hex
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$clientId = bin2hex(openssl_random_pseudo_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookieOpts = [
|
||||||
|
'expires' => time() + APP_CLIENT_COOKIE_LIFETIME,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||||
|
'httponly' => false, // JS darf es lesen, wenn erwünscht
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty(APP_COOKIE_DOMAIN)) {
|
||||||
|
$cookieOpts['domain'] = APP_COOKIE_DOMAIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
setcookie($clientCookieName, $clientId, $cookieOpts);
|
||||||
|
$_COOKIE[$clientCookieName] = $clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// global verfügbar machen (NEUER NAME!)
|
||||||
|
$GLOBALS['cookie_client_id'] = $clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
// 3) Sprachlogik laden (bleibt sinnvoll zentral)
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
require_once __DIR__ . '/i18n.php';
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
// 4) Rest des Systems laden (DB, Funktionen, Hilfs-Libs)
|
||||||
|
// -----------------------------------------------------------
|
||||||
|
require_once __DIR__ . "/db.php";
|
||||||
|
require_once __DIR__ . '/../src/functions.php';
|
||||||
397
config/i18n.php
Normal file
397
config/i18n.php
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
<?php
|
||||||
|
// config/i18n.php
|
||||||
|
// Zentrale Sprachlogik für das gesamte Projekt
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
@session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die meta-Infos einer Sprachdatei.
|
||||||
|
* Erwartet Struktur:
|
||||||
|
* {
|
||||||
|
* "meta": { "code": "de", "label": "Deutsch", "flag": "🇩🇪", "enabled": true },
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Gibt NULL zurück, wenn:
|
||||||
|
* - Datei nicht lesbar
|
||||||
|
* - JSON ungültig
|
||||||
|
* - kein meta vorhanden
|
||||||
|
* - meta.enabled existiert und NICHT true ist
|
||||||
|
*/
|
||||||
|
function app_i18n_load_language_meta_from_file(string $file): ?array
|
||||||
|
{
|
||||||
|
$json = @file_get_contents($file);
|
||||||
|
if ($json === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $data['meta'] ?? null;
|
||||||
|
if (!is_array($meta)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur aktive Sprachen (enabled === true)
|
||||||
|
if (array_key_exists('enabled', $meta) && !$meta['enabled']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code aus meta, Fallback: Dateiname
|
||||||
|
$code = '';
|
||||||
|
if (!empty($meta['code'])) {
|
||||||
|
$code = strtolower(substr((string)$meta['code'], 0, 5));
|
||||||
|
} else {
|
||||||
|
$base = basename($file, '.json');
|
||||||
|
$code = strtolower($base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auf 2-Buchstaben-Codes normalisieren (de-DE → de)
|
||||||
|
if (strlen($code) > 2) {
|
||||||
|
$code = substr($code, 0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($code === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = isset($meta['label']) && $meta['label'] !== ''
|
||||||
|
? (string)$meta['label']
|
||||||
|
: strtoupper($code);
|
||||||
|
|
||||||
|
$flag = isset($meta['flag']) ? (string)$meta['flag'] : '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => $code,
|
||||||
|
'label' => $label,
|
||||||
|
'flag' => $flag,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle verfügbaren Sprachen aus /public/assets/i18n/*.json ermitteln.
|
||||||
|
* Verfügbar = JSON mit meta.enabled === true.
|
||||||
|
* EN wird garantiert hinzugefügt (Fallback), falls nicht gefunden.
|
||||||
|
*/
|
||||||
|
function app_i18n_detect_available_languages(): array
|
||||||
|
{
|
||||||
|
$baseDir = realpath(__DIR__ . '/../public/assets/i18n');
|
||||||
|
if ($baseDir === false) {
|
||||||
|
// Wenn gar kein Verzeichnis da ist: minimaler EN-Fallback
|
||||||
|
return [
|
||||||
|
'en' => [
|
||||||
|
'code' => 'en',
|
||||||
|
'label' => 'English',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = glob($baseDir . '/*.json') ?: [];
|
||||||
|
$langs = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$meta = app_i18n_load_language_meta_from_file($file);
|
||||||
|
if ($meta === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $meta['code'];
|
||||||
|
|
||||||
|
// Erste gültige Definition pro Code gewinnt
|
||||||
|
if (!isset($langs[$code])) {
|
||||||
|
$langs[$code] = $meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EN muss immer vorhanden sein (laut deiner Vorgabe)
|
||||||
|
if (!isset($langs['en'])) {
|
||||||
|
// Versuch: gibt es eine en.json, auch wenn enabled=false?
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$base = strtolower(basename($file, '.json'));
|
||||||
|
if ($base === 'en') {
|
||||||
|
$json = @file_get_contents($file);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
$meta = is_array($data['meta'] ?? null) ? $data['meta'] : [];
|
||||||
|
|
||||||
|
$label = isset($meta['label']) && $meta['label'] !== ''
|
||||||
|
? (string)$meta['label']
|
||||||
|
: 'English';
|
||||||
|
$flag = isset($meta['flag']) ? (string)$meta['flag'] : '';
|
||||||
|
|
||||||
|
$langs['en'] = [
|
||||||
|
'code' => 'en',
|
||||||
|
'label' => $label,
|
||||||
|
'flag' => $flag,
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn immer noch kein EN → minimaler Stub
|
||||||
|
if (!isset($langs['en'])) {
|
||||||
|
$langs['en'] = [
|
||||||
|
'code' => 'en',
|
||||||
|
'label' => 'English',
|
||||||
|
'flag' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($langs);
|
||||||
|
|
||||||
|
return $langs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browsersprache aus HTTP_ACCEPT_LANGUAGE extrahieren (2-Buchstaben),
|
||||||
|
* aber nur, wenn sie in $available existiert.
|
||||||
|
*/
|
||||||
|
function app_i18n_detect_browser_lang(array $available): ?string
|
||||||
|
{
|
||||||
|
$header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
|
||||||
|
if ($header === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode(',', $header);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$part = trim($part);
|
||||||
|
if ($part === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = strtolower(substr($part, 0, 2)); // "de-DE" → "de"
|
||||||
|
if (isset($available[$code])) {
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktuelle Sprache bestimmen:
|
||||||
|
* 1) ?lang=xx (wenn in $available)
|
||||||
|
* 2) Browsersprache (wenn in $available)
|
||||||
|
* 3) Fallback "en"
|
||||||
|
*/
|
||||||
|
function app_i18n_resolve_current_lang(array $available): string
|
||||||
|
{
|
||||||
|
// 1) URL-Parameter ?lang=xx
|
||||||
|
if (!empty($_GET['lang'])) {
|
||||||
|
$param = strtolower(substr($_GET['lang'], 0, 2));
|
||||||
|
if (isset($available[$param])) {
|
||||||
|
return $param;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Browsersprache
|
||||||
|
$browser = app_i18n_detect_browser_lang($available);
|
||||||
|
if ($browser !== null) {
|
||||||
|
return $browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Standard EN
|
||||||
|
if (isset($available['en'])) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sicherheitsfallback: erste verfügbare Sprache
|
||||||
|
$keys = array_keys($available);
|
||||||
|
return $keys[0] ?? 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------
|
||||||
|
// Bootstrap ausführen
|
||||||
|
// -----------------------------------------------------
|
||||||
|
|
||||||
|
$availableLangs = app_i18n_detect_available_languages();
|
||||||
|
$currentLang = app_i18n_resolve_current_lang($availableLangs);
|
||||||
|
|
||||||
|
// Global bereitstellen
|
||||||
|
$GLOBALS['availableLangs'] = $availableLangs;
|
||||||
|
$GLOBALS['lang'] = $currentLang;
|
||||||
|
|
||||||
|
// Optional in Session merken (muss nicht, schadet aber auch nicht)
|
||||||
|
$_SESSION['lang'] = $currentLang;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontend-Config für JS (usbConfig.i18n)
|
||||||
|
* → benutzt direkt die Meta-Daten aus den JSONs (code, label, flag)
|
||||||
|
*/
|
||||||
|
function app_i18n_get_frontend_config(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current' => $GLOBALS['lang'] ?? 'en',
|
||||||
|
'available' => $GLOBALS['availableLangs'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Komplette JSON-Struktur für eine Sprache laden.
|
||||||
|
* Nutzt einfachen Request-Cache, damit pro Sprache nur einmal von Platte gelesen wird.
|
||||||
|
*/
|
||||||
|
function app_i18n_load_lang_json(string $lang): array
|
||||||
|
{
|
||||||
|
static $cache = [];
|
||||||
|
|
||||||
|
$lang = strtolower(substr($lang, 0, 5));
|
||||||
|
|
||||||
|
if (isset($cache[$lang])) {
|
||||||
|
return $cache[$lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseDir = realpath(__DIR__ . '/../public/assets/i18n');
|
||||||
|
if ($baseDir === false) {
|
||||||
|
$cache[$lang] = [];
|
||||||
|
return $cache[$lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $baseDir . '/' . $lang . '.json';
|
||||||
|
if (!is_file($path)) {
|
||||||
|
// Fallback: en.json, falls vorhanden
|
||||||
|
$fallback = $baseDir . '/en.json';
|
||||||
|
if (is_file($fallback)) {
|
||||||
|
$json = @file_get_contents($fallback);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
$cache[$lang] = is_array($data) ? $data : [];
|
||||||
|
return $cache[$lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache[$lang] = [];
|
||||||
|
return $cache[$lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = @file_get_contents($path);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
$cache[$lang] = is_array($data) ? $data : [];
|
||||||
|
|
||||||
|
return $cache[$lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aus einem Label einen stabilen i18n-Key für Nav-Anker bauen.
|
||||||
|
* Beispiel: "So funktioniert USBCheck!" -> "nav_so_funktioniert_usbcheck"
|
||||||
|
*/
|
||||||
|
function app_i18n_make_anchor_key(string $label): string
|
||||||
|
{
|
||||||
|
// HTML-Entities entfernen (z. B. &)
|
||||||
|
$decoded = html_entity_decode($label, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
// Kleinbuchstaben
|
||||||
|
$decoded = mb_strtolower($decoded, 'UTF-8');
|
||||||
|
|
||||||
|
// Alles, was kein a-z oder 0-9 ist, durch Unterstrich ersetzen
|
||||||
|
$key = preg_replace('/[^a-z0-9]+/u', '_', $decoded);
|
||||||
|
|
||||||
|
// Mehrfache Unterstriche trimmen
|
||||||
|
$key = trim($key, '_');
|
||||||
|
|
||||||
|
if ($key === '') {
|
||||||
|
$key = 'item';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix, damit klar ist, dass es Navigationskeys sind
|
||||||
|
return 'nav_' . $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nav-Anker für eine Seite aus der Sprachdatei holen.
|
||||||
|
*
|
||||||
|
* Haupt-Variante im JSON:
|
||||||
|
*
|
||||||
|
* "pages": {
|
||||||
|
* "landing": {
|
||||||
|
* "anchors": {
|
||||||
|
* "how": "So funktioniert USBCheck",
|
||||||
|
* "problem": "Warum gefälschte USB-Sticks gefährlich sind",
|
||||||
|
* "features": "Funktionen",
|
||||||
|
* "security": "Sicherheit",
|
||||||
|
* "faq": "FAQ"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Optional explizit:
|
||||||
|
* "anchors": {
|
||||||
|
* "how": { "label": "So funktioniert USBCheck", "i18n": "nav_how" },
|
||||||
|
* "faq": { "i18n": "nav_faq" }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Rückgabe-Format:
|
||||||
|
* [
|
||||||
|
* [ 'href' => '#how', 'label' => 'So funktioniert USBCheck', 'i18n' => 'nav_so_funktioniert_usbcheck' ],
|
||||||
|
* [ 'href' => '#faq', 'label' => '', 'i18n' => 'nav_faq' ],
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
function app_get_nav_anchors(string $pageKey): array
|
||||||
|
{
|
||||||
|
$lang = $GLOBALS['lang'] ?? 'en';
|
||||||
|
$data = app_i18n_load_lang_json($lang);
|
||||||
|
|
||||||
|
$cfg = $data['pages'][$pageKey]['anchors'] ?? null;
|
||||||
|
if (!is_array($cfg)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$anchors = [];
|
||||||
|
|
||||||
|
foreach ($cfg as $id => $value) {
|
||||||
|
$id = trim((string)$id);
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$href = '#' . $id;
|
||||||
|
$label = '';
|
||||||
|
$i18n = '';
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
// String IMMER als Label übernehmen
|
||||||
|
$labelTrim = trim($value);
|
||||||
|
if ($labelTrim === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $labelTrim;
|
||||||
|
// i18n-Key automatisch aus dem Label ableiten
|
||||||
|
$i18n = app_i18n_make_anchor_key($labelTrim);
|
||||||
|
|
||||||
|
} elseif (is_array($value)) {
|
||||||
|
// Explizite Variante:
|
||||||
|
// "how": { "label": "...", "i18n": "nav_how" }
|
||||||
|
if (!empty($value['label'])) {
|
||||||
|
$label = trim((string)$value['label']);
|
||||||
|
}
|
||||||
|
if (!empty($value['i18n'])) {
|
||||||
|
$i18n = trim((string)$value['i18n']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($label === '' && $i18n === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn Label gesetzt, aber kein i18n: automatisch generieren
|
||||||
|
if ($label !== '' && $i18n === '') {
|
||||||
|
$i18n = app_i18n_make_anchor_key($label);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Weder String noch Array → ignorieren
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$anchors[] = [
|
||||||
|
'href' => $href,
|
||||||
|
'label' => $label,
|
||||||
|
'i18n' => $i18n,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $anchors;
|
||||||
|
}
|
||||||
34
config/prod/config.php
Normal file
34
config/prod/config.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
ini_set('display_startup_errors', 1);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once __DIR__ . "/domaindata.php";
|
||||||
|
|
||||||
|
// Umgebung (optional, aber hilfreich für Debugging / Logik)
|
||||||
|
define('APP_ENV', 'prod'); // oder 'prod', 'local', ...
|
||||||
|
if (!defined('ASSET_VERSION')) {
|
||||||
|
define('ASSET_VERSION', '2024-11-22'); // oder deine aktuelle Version
|
||||||
|
}
|
||||||
|
// Domain-Konfiguration (kann pro Umgebung angepasst werden)
|
||||||
|
if (!defined('APP_DOMAIN_PRIMARY')) {
|
||||||
|
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||||
|
}
|
||||||
|
if (!defined('APP_URL_PRIMARY')) {
|
||||||
|
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||||
|
}
|
||||||
|
if (!defined('APP_DOMAIN_FAKECHECK')) {
|
||||||
|
define('APP_DOMAIN_FAKECHECK', 'ismyusbfake.com');
|
||||||
|
}
|
||||||
|
if (!defined('APP_URL_FAKECHECK')) {
|
||||||
|
define('APP_URL_FAKECHECK', 'https://' . APP_DOMAIN_FAKECHECK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matomo Einstellungen
|
||||||
|
define('MATOMO_URL', 'https://matomo.my-statistics.info/');
|
||||||
|
define('MATOMO_ENABLED', true);
|
||||||
|
define('MATOMO_SITE_ID', 7);
|
||||||
|
$env = 'prod';
|
||||||
|
$baseUrl = 'https://'.APP_DOMAIN_NAME;
|
||||||
|
$apiBaseUrl = 'https://api.'.APP_DOMAIN_NAME;
|
||||||
|
|
||||||
28
config/prod/db.php
Normal file
28
config/prod/db.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// config/db.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$DB_HOST = 'localhost';
|
||||||
|
$DB_NAME = 'd0455ede';
|
||||||
|
$DB_USER = 'd0455ede';
|
||||||
|
$DB_PASS = 'fF8PhxfCibdLBrSxowIo'; // anpassen
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO(
|
||||||
|
"mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4",
|
||||||
|
$DB_USER,
|
||||||
|
$DB_PASS,
|
||||||
|
$options
|
||||||
|
);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// In Produktion Logging, keine Details ausgeben
|
||||||
|
http_response_code(500);
|
||||||
|
echo 'Database connection error.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
36
config/staging/config.php
Normal file
36
config/staging/config.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
ini_set('display_startup_errors', 1);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once __DIR__ . "/domaindata.php";
|
||||||
|
|
||||||
|
// Umgebung (optional, aber hilfreich für Debugging / Logik)
|
||||||
|
define('APP_ENV', 'staging'); // oder 'prod', 'local', ...
|
||||||
|
|
||||||
|
|
||||||
|
if (!defined('ASSET_VERSION')) {
|
||||||
|
define('ASSET_VERSION', time()); // oder deine aktuelle Version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain-Konfiguration (kann pro Umgebung angepasst werden)
|
||||||
|
if (!defined('APP_DOMAIN_PRIMARY')) {
|
||||||
|
define('APP_DOMAIN_PRIMARY', 'staging.'.APP_DOMAIN_NAME);
|
||||||
|
}
|
||||||
|
if (!defined('APP_URL_PRIMARY')) {
|
||||||
|
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||||
|
}
|
||||||
|
if (!defined('APP_DOMAIN_FAKECHECK')) {
|
||||||
|
define('APP_DOMAIN_FAKECHECK', 'staging.ismyusbfake.com');
|
||||||
|
}
|
||||||
|
if (!defined('APP_URL_FAKECHECK')) {
|
||||||
|
define('APP_URL_FAKECHECK', 'https://' . APP_DOMAIN_FAKECHECK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matomo Einstellungen
|
||||||
|
define('MATOMO_URL', 'https://matomo.my-statistics.info/');
|
||||||
|
define('MATOMO_ENABLED', false);
|
||||||
|
define('MATOMO_SITE_ID', 8);
|
||||||
|
$baseUrl = 'https://'.APP_DOMAIN_PRIMARY;
|
||||||
|
$apiBaseUrl = 'https://api.'.APP_DOMAIN_PRIMARY;
|
||||||
|
|
||||||
28
config/staging/db.php
Normal file
28
config/staging/db.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// config/db.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$DB_HOST = 'localhost';
|
||||||
|
$DB_NAME = 'd0455edf';
|
||||||
|
$DB_USER = 'd0455edf';
|
||||||
|
$DB_PASS = 'fF8PhxfCibdLBrSxowIo'; // anpassen
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO(
|
||||||
|
"mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4",
|
||||||
|
$DB_USER,
|
||||||
|
$DB_PASS,
|
||||||
|
$options
|
||||||
|
);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// In Produktion Logging, keine Details ausgeben
|
||||||
|
http_response_code(500);
|
||||||
|
echo 'Database connection error.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
636
inc/ApiKernel (Kopie).php
Normal file
636
inc/ApiKernel (Kopie).php
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Lade den AuthService
|
||||||
|
require_once __DIR__ . '/AuthService.php';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// ApiKernel.php (OPTIMIERT & BEREINIGT)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
class ApiKernel
|
||||||
|
{
|
||||||
|
// Klassen-Eigenschaften
|
||||||
|
private array $conf;
|
||||||
|
private ?PDO $pdo = null;
|
||||||
|
private array $in;
|
||||||
|
private string $action;
|
||||||
|
private array $tableMap;
|
||||||
|
private AuthService $authService;
|
||||||
|
|
||||||
|
// --- Initialisierung & Konstruktor (Unverändert) ---
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->conf = $this->loadConfig();
|
||||||
|
$this->cors();
|
||||||
|
$this->setInput();
|
||||||
|
$this->pdo = $this->getPdoTemplates();
|
||||||
|
$this->resolveAction();
|
||||||
|
$this->resolveTableMap();
|
||||||
|
$this->authService = new AuthService($this->conf, $this->pdo);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->fail('Initialization error', get_class($e) . ': ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Core Responder-Methoden (Unverändert) ---
|
||||||
|
|
||||||
|
public function respond($data, int $code = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
echo is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fail(string $msg, $detail = null, int $code = 400): void
|
||||||
|
{
|
||||||
|
$this->respond(['ok' => false, 'error' => $msg, 'detail' => $detail], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private Initialisierungs- & Utility-Methoden (Unverändert) ---
|
||||||
|
|
||||||
|
private function loadConfig(): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$paths = [
|
||||||
|
__DIR__ . '/config.php',
|
||||||
|
__DIR__ . '/../config.php',
|
||||||
|
__DIR__ . '/../../config.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($paths as $p) {
|
||||||
|
if (is_file($p)) {
|
||||||
|
$conf = @include $p;
|
||||||
|
if (is_array($conf)) return $conf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->fail('Invalid config.php', 'config.php not found or not returning array', 500);
|
||||||
|
}
|
||||||
|
private function cors(): void { /* ... Logik bleibt unverändert ... */
|
||||||
|
$cors = $this->conf['cors'] ?? '*';
|
||||||
|
if ($cors) {
|
||||||
|
header('Access-Control-Allow-Origin: ' . $cors);
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
}
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') $this->respond(['ok' => true]);
|
||||||
|
|
||||||
|
if (!empty($this->conf['auth']['cookie'])) {
|
||||||
|
$c = $this->conf['auth']['cookie'];
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
$params['lifetime'] = $c['lifetime'] ?? $params['lifetime'];
|
||||||
|
$params['path'] = $c['path'] ?? $params['path'];
|
||||||
|
$params['domain'] = $c['domain'] ?? $params['domain'];
|
||||||
|
$params['secure'] = $c['secure'] ?? $params['secure'];
|
||||||
|
$params['httponly'] = $c['httponly'] ?? $params['httponly'];
|
||||||
|
if (isset($c['samesite'])) $params['samesite'] = $c['samesite'];
|
||||||
|
session_set_cookie_params($params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private function setInput(): void { /* ... Logik bleibt unverändert ... */
|
||||||
|
$data = [];
|
||||||
|
$ct = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||||
|
if (stripos($ct, 'application/json') !== false) {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw !== false && $raw !== '') {
|
||||||
|
$js = json_decode($raw, true);
|
||||||
|
if (is_array($js)) $data = $js;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($_POST as $k => $v) $data[$k] = $v;
|
||||||
|
foreach ($_GET as $k => $v) if (!array_key_exists($k, $data)) $data[$k] = $v;
|
||||||
|
$this->in = $data;
|
||||||
|
}
|
||||||
|
private function getPdoTemplates(): PDO { /* ... Logik bleibt unverändert ... */
|
||||||
|
if (!isset($this->conf['templates']) || !is_array($this->conf['templates'])) {
|
||||||
|
$this->fail('Missing templates DB config', null, 500);
|
||||||
|
}
|
||||||
|
$c = $this->conf['templates'];
|
||||||
|
$host = $c['db_host'] ?? 'localhost';
|
||||||
|
$db = $c['db_name'] ?? ($c['database'] ?? '');
|
||||||
|
$user = $c['db_user'] ?? ($c['username'] ?? '');
|
||||||
|
$pass = $c['db_pass'] ?? ($c['password'] ?? '');
|
||||||
|
$charset = $c['db_charset'] ?? 'utf8mb4';
|
||||||
|
$port = $c['db_port'] ?? 3306;
|
||||||
|
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=$charset";
|
||||||
|
$opt = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
];
|
||||||
|
return new PDO($dsn, $user, $pass, $opt);
|
||||||
|
}
|
||||||
|
private function resolveAction(): void { /* ... Logik bleibt unverändert ... */
|
||||||
|
$action = $this->val($this->in, 'action', '');
|
||||||
|
$resource = $this->val($this->in, 'resource', null);
|
||||||
|
$allowedResources = ['templates', 'sections', 'blocks', 'snippets'];
|
||||||
|
if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) {
|
||||||
|
$verb = strtolower((string)$action);
|
||||||
|
if (in_array($verb, ['list', 'get', 'create', 'update', 'delete'], true)) $action = $resource . '.' . $verb;
|
||||||
|
}
|
||||||
|
$this->action = $action;
|
||||||
|
}
|
||||||
|
private function resolveTableMap(): void { /* ... Logik bleibt unverändert ... */
|
||||||
|
$tables = $this->conf['tables'] ?? [];
|
||||||
|
$this->tableMap = [
|
||||||
|
'templates' => $tables['templates'] ?? 'emailtemplate_templates',
|
||||||
|
'sections' => $tables['sections'] ?? 'emailtemplate_sections',
|
||||||
|
'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks',
|
||||||
|
'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function val(array $in, $keys, $default = null) { /* ... Logik bleibt unverändert ... */
|
||||||
|
if (!is_array($keys)) $keys = [$keys];
|
||||||
|
foreach ($keys as $k) if (array_key_exists($k, $in)) return $in[$k];
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
private function firstExisting(array $columns, array $candidates): ?string { /* ... Logik bleibt unverändert ... */
|
||||||
|
foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private function tableColumns(string $table): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$cols = [];
|
||||||
|
$stmt = $this->pdo->query("SHOW COLUMNS FROM `$table`");
|
||||||
|
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
|
||||||
|
return $cols;
|
||||||
|
}
|
||||||
|
private function primaryKey(string $table): ?string { /* ... Logik bleibt unverändert ... */
|
||||||
|
$stmt = $this->pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row['Column_name'] ?? null;
|
||||||
|
}
|
||||||
|
private function requireAuth(): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
return $this->authService->requireAuth();
|
||||||
|
}
|
||||||
|
private function pullId(array $src) { /* ... Logik bleibt unverändert ... */
|
||||||
|
$aliases = ['id', 'item_id', 'template_id', 'tpl_id', 'section_id', 'sec_id', 'block_id', 'blk_id', 'snippet_id', 'snip_id'];
|
||||||
|
foreach ($aliases as $a) if (isset($src[$a]) && $src[$a] !== '') return $src[$a];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private function tenantWhere(array $session): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$multi = $this->conf['multi'] ?? [];
|
||||||
|
$tenantCol = $multi['tenant_col'] ?? null;
|
||||||
|
$mapSess = $multi['map_session_to'] ?? 'id';
|
||||||
|
|
||||||
|
if (!$tenantCol) return ['', []];
|
||||||
|
if (!$session) return [' AND 1=0 ', []];
|
||||||
|
$val = $session[$mapSess] ?? null;
|
||||||
|
if ($val === null || $val === '') return [' AND 1=0 ', []];
|
||||||
|
return [" AND `$tenantCol` = :__tenant", [':__tenant' => $val]];
|
||||||
|
}
|
||||||
|
private function tenantAssign(array $session, array $columns): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$multi = $this->conf['multi'] ?? [];
|
||||||
|
$tenantCol = $multi['tenant_col'] ?? null;
|
||||||
|
$mapSess = $multi['map_session_to'] ?? 'id';
|
||||||
|
|
||||||
|
if (!$tenantCol || !in_array($tenantCol, $columns, true)) return [];
|
||||||
|
$val = $session[$mapSess] ?? null;
|
||||||
|
return ($val === null || $val === '') ? [] : [$tenantCol => $val];
|
||||||
|
}
|
||||||
|
private function resolveIdCol(string $kind): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
||||||
|
$cols = $this->tableColumns($t);
|
||||||
|
$idCol = $cfg['id'] ?? ($this->firstExisting($cols, ['id']) ?: $this->primaryKey($t));
|
||||||
|
if (!$idCol) $idCol = 'id';
|
||||||
|
return [$idCol, $cols];
|
||||||
|
}
|
||||||
|
private function parseHtmlToGjsComponents(string $html): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
if (trim($html) === '') return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'type' => 'html',
|
||||||
|
'content' => $html,
|
||||||
|
'removable' => true,
|
||||||
|
'draggable' => true,
|
||||||
|
'droppable' => true,
|
||||||
|
'copyable' => true,
|
||||||
|
'selectable' => true,
|
||||||
|
'editable' => false,
|
||||||
|
'traits' => [],
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 KORREKTUR: Bereinigungsmethode (von vorheriger Version übernommen)
|
||||||
|
private function cleanReferenceComponents(array $components): array {
|
||||||
|
foreach ($components as &$component) {
|
||||||
|
if (is_array($component) && isset($component['type'])) {
|
||||||
|
|
||||||
|
if ($component['type'] === 'library-reference') {
|
||||||
|
if (isset($component['content'])) {
|
||||||
|
$component['content'] = '';
|
||||||
|
}
|
||||||
|
if (isset($component['components'])) {
|
||||||
|
$component['components'] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($component['components']) && is_array($component['components'])) {
|
||||||
|
$component['components'] = $this->cleanReferenceComponents($component['components']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $components;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 🚀 NEUE CRUD HANDLER METHODEN (Logik aus run() extrahiert)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von LIST-Anfragen.
|
||||||
|
*/
|
||||||
|
private function handleList(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||||||
|
$q = trim((string)$this->val($this->in, 'q', ''));
|
||||||
|
$limit = max(1, (int)$this->val($this->in, 'limit', 500));
|
||||||
|
$offset = max(0, (int)$this->val($this->in, 'offset', 0));
|
||||||
|
|
||||||
|
$where = ' WHERE 1=1 ';
|
||||||
|
$params = [];
|
||||||
|
// Suchlogik (q)
|
||||||
|
if ($q !== '') {
|
||||||
|
$parts = ["`$nameCol` LIKE :q"];
|
||||||
|
if ($descCol) $parts[] = "`$descCol` LIKE :q";
|
||||||
|
if ($catCol) $parts[] = "`$catCol` LIKE :q";
|
||||||
|
$where .= " AND (" . implode(' OR ', $parts) . ") ";
|
||||||
|
$params[':q'] = '%' . $q . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filterlogik (parentFilters)
|
||||||
|
$parentFilters = [
|
||||||
|
'template_id' => $this->val($this->in, ['template_id', 'tpl_id'], null),
|
||||||
|
'section_id' => $this->val($this->in, ['section_id', 'sec_id'], null),
|
||||||
|
'block_id' => $this->val($this->in, ['block_id', 'blk_id'], null),
|
||||||
|
];
|
||||||
|
foreach ($parentFilters as $col => $v) {
|
||||||
|
if ($v === null || $v === '') continue;
|
||||||
|
if (in_array($col, $allCols, true)) { $where .= " AND `$col` = :$col "; $params[":$col"] = $v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant-Filter
|
||||||
|
[$tw, $tp] = $this->tenantWhere($auth);
|
||||||
|
$where .= $tw;
|
||||||
|
foreach ($tp as $k => $v) $params[$k] = $v;
|
||||||
|
|
||||||
|
$order = $updCol ? " ORDER BY `$updCol` DESC " : " ORDER BY `$nameCol` ASC ";
|
||||||
|
$sql = "SELECT * FROM `$t` $where $order LIMIT :off,:lim";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
|
||||||
|
// Bind parameters
|
||||||
|
foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$item = [
|
||||||
|
'id' => $r[$idCol] ?? null,
|
||||||
|
'name' => $r[$nameCol] ?? null,
|
||||||
|
];
|
||||||
|
if ($descCol && isset($r[$descCol])) $item['desc'] = $r[$descCol];
|
||||||
|
if ($catCol && isset($r[$catCol])) $item['category'] = $r[$catCol];
|
||||||
|
if ($updCol && isset($r[$updCol])) $item['updated_at'] = $r[$updCol];
|
||||||
|
|
||||||
|
// Lade HTML und JSON aus den korrekten Spalten
|
||||||
|
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
||||||
|
if ($htmlCol && isset($r[$htmlCol])) $item['html'] = (string)$r[$htmlCol];
|
||||||
|
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
||||||
|
if ($jsonCol && isset($r[$jsonCol])) $item['content'] = $r[$jsonCol];
|
||||||
|
|
||||||
|
$out[] = $item;
|
||||||
|
}
|
||||||
|
$this->respond(['ok' => true, 'kind' => $kind, 'items' => $out, 'data' => $out, 'count' => count($out), 'offset' => $offset, 'limit' => $limit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von GET-Anfragen.
|
||||||
|
*/
|
||||||
|
private function handleGet(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$id = $this->pullId($this->in);
|
||||||
|
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||||||
|
|
||||||
|
[$tw, $tp] = $this->tenantWhere($auth);
|
||||||
|
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->bindValue(':id', $id);
|
||||||
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) $this->fail('Not found', ['kind' => $kind, 'id' => $id], 404);
|
||||||
|
$rowOut = ['id' => $row[$idCol] ?? $id] + $row;
|
||||||
|
|
||||||
|
// Lade HTML und JSON aus den korrekten Spalten
|
||||||
|
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
||||||
|
$topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null;
|
||||||
|
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
||||||
|
$topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null;
|
||||||
|
|
||||||
|
$gjsComponents = [];
|
||||||
|
|
||||||
|
if ($topContent !== null) {
|
||||||
|
$decodedContent = json_decode($topContent, true);
|
||||||
|
if (is_array($decodedContent)) {
|
||||||
|
$gjsComponents = $decodedContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($gjsComponents) && $topHtml !== null) {
|
||||||
|
$gjsComponents = $this->parseHtmlToGjsComponents($topHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->respond([
|
||||||
|
'ok' => true,
|
||||||
|
'kind' => $kind,
|
||||||
|
'id' => $rowOut['id'],
|
||||||
|
'item' => $rowOut,
|
||||||
|
'data' => $rowOut,
|
||||||
|
'html' => $topHtml,
|
||||||
|
'content' => $topContent,
|
||||||
|
'gjs_components' => $gjsComponents
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von CREATE-Anfragen (inkl. JSON-Bereinigung).
|
||||||
|
*/
|
||||||
|
private function handleCreate(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||||||
|
|
||||||
|
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
|
||||||
|
if ($name === '') $this->fail('name required', null, 422);
|
||||||
|
|
||||||
|
$desc = (string)$this->val($this->in, ['description', 'desc'], null);
|
||||||
|
$cat = (string)$this->val($this->in, ['category', 'cat'], null);
|
||||||
|
$html = (string)$this->val($this->in, ['html', 'body', 'markup'], null);
|
||||||
|
$json = $this->val($this->in, ['content_json', 'json', 'content', 'structure_json'], null);
|
||||||
|
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
||||||
|
$templateId = $this->val($this->in, ['template_id', 'tpl_id'], null);
|
||||||
|
$sectionId = $this->val($this->in, ['section_id', 'sec_id'], null);
|
||||||
|
$blockId = $this->val($this->in, ['block_id', 'blk_id'], null);
|
||||||
|
|
||||||
|
$data = [$nameCol => $name];
|
||||||
|
if ($desc !== null && $descCol) $data[$descCol] = $desc;
|
||||||
|
if ($cat !== null && $catCol) $data[$catCol] = $cat;
|
||||||
|
|
||||||
|
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
|
||||||
|
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
|
||||||
|
|
||||||
|
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
|
||||||
|
|
||||||
|
// 1. JSON-Content behandeln
|
||||||
|
if ($json !== null) {
|
||||||
|
if ($jsonDbCol) {
|
||||||
|
$components = is_string($json) ? json_decode($json, true) : $json;
|
||||||
|
if (is_array($components)) {
|
||||||
|
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
|
||||||
|
$data[$jsonDbCol] = json_encode($components, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
$data[$jsonDbCol] = is_string($json) ? $json : '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
|
||||||
|
$this->fail(
|
||||||
|
'JSON content provided but no `json_content` column found',
|
||||||
|
['table' => $t, 'available_cols' => $allCols],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. HTML-Content speichern
|
||||||
|
if ($htmlDbCol && $html !== null) {
|
||||||
|
$data[$htmlDbCol] = $html;
|
||||||
|
}
|
||||||
|
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
|
||||||
|
|
||||||
|
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
||||||
|
if ($c && $settings !== null) $data[$c] = is_string($settings) ? $settings : json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if ($templateId !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $templateId;
|
||||||
|
if ($sectionId !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sectionId;
|
||||||
|
if ($blockId !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blockId;
|
||||||
|
|
||||||
|
$data = $data + $this->tenantAssign($_SESSION['auth'] ?? [], $allCols);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']);
|
||||||
|
if ($createdCol) $data[$createdCol] = $now;
|
||||||
|
if ($updCol) $data[$updCol] = $now;
|
||||||
|
|
||||||
|
$fields = array_keys($data);
|
||||||
|
$place = array_map(fn($c) => ":$c", $fields);
|
||||||
|
$sql = "INSERT INTO `$t` (" . implode(',', array_map(fn($c) => "`$c`", $fields)) . ") VALUES (" . implode(',', $place) . ")";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$newId = $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$out = ['id' => $newId, 'name' => $name];
|
||||||
|
if ($desc !== null) $out['desc'] = $desc;
|
||||||
|
if ($cat !== null) $out['category'] = $cat;
|
||||||
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $newId, 'item' => $out, 'data' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von UPDATE-Anfragen (inkl. JSON-Bereinigung).
|
||||||
|
*/
|
||||||
|
private function handleUpdate(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||||||
|
$id = $this->pullId($this->in);
|
||||||
|
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
$name = $this->val($this->in, ['name', 'title'], null);
|
||||||
|
$desc = $this->val($this->in, ['description', 'desc'], null);
|
||||||
|
$cat = $this->val($this->in, ['category', 'cat'], null);
|
||||||
|
$html = $this->val($this->in, ['html', 'body', 'markup'], null);
|
||||||
|
$json = $this->val($this->in, ['content_json', 'json', 'content', 'structure_json'], null);
|
||||||
|
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
||||||
|
|
||||||
|
if ($name !== null) $data[$nameCol] = (string)$name;
|
||||||
|
if ($desc !== null && $descCol) $data[$descCol] = (string)$desc;
|
||||||
|
if ($cat !== null && $catCol) $data[$catCol] = (string)$cat;
|
||||||
|
|
||||||
|
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
|
||||||
|
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
|
||||||
|
|
||||||
|
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
|
||||||
|
|
||||||
|
// 1. JSON-Content behandeln
|
||||||
|
if ($json !== null) {
|
||||||
|
if ($jsonDbCol) {
|
||||||
|
// Wenn JSON-Spalte existiert, JSON verarbeiten und speichern
|
||||||
|
$components = is_string($json) ? json_decode($json, true) : $json;
|
||||||
|
if (is_array($components)) {
|
||||||
|
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
|
||||||
|
$data[$jsonDbCol] = json_encode($components, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
$data[$jsonDbCol] = is_string($json) ? $json : '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
|
||||||
|
$this->fail(
|
||||||
|
'JSON content provided but no `json_content` column found',
|
||||||
|
['table' => $t, 'available_cols' => $allCols],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Den zugehörigen HTML-Output speichern (wird vom Editor immer mitgesendet, wenn JSON da ist)
|
||||||
|
if ($html !== null && $htmlDbCol) {
|
||||||
|
$data[$htmlDbCol] = (string)$html;
|
||||||
|
}
|
||||||
|
} elseif ($html !== null && $htmlDbCol) {
|
||||||
|
// Wenn NUR HTML gesendet wird (für minimale Änderungen), speichern wir nur HTML.
|
||||||
|
$data[$htmlDbCol] = (string)$html;
|
||||||
|
}
|
||||||
|
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
|
||||||
|
|
||||||
|
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
||||||
|
if ($settings !== null && $c) $data[$c] = is_string($settings) ? $settings : json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
$tpl = $this->val($this->in, ['template_id', 'tpl_id'], null);
|
||||||
|
if ($tpl !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $tpl;
|
||||||
|
$sec = $this->val($this->in, ['section_id', 'sec_id'], null);
|
||||||
|
if ($sec !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sec;
|
||||||
|
$blk = $this->val($this->in, ['block_id', 'blk_id'], null);
|
||||||
|
if ($blk !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blk;
|
||||||
|
|
||||||
|
if ($updCol) $data[$updCol] = date('Y-m-d H:i:s');
|
||||||
|
if (!$data) $this->fail('nothing to update', null, 422);
|
||||||
|
|
||||||
|
[$tw, $tp] = $this->tenantWhere($auth);
|
||||||
|
$set = [];
|
||||||
|
foreach (array_keys($data) as $c) $set[] = "`$c` = :$c";
|
||||||
|
$sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
|
||||||
|
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||||||
|
$stmt->bindValue(':__id', $id);
|
||||||
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'updated' => array_keys($data)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von DELETE-Anfragen.
|
||||||
|
*/
|
||||||
|
private function handleDelete(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$id = $this->pullId($this->in);
|
||||||
|
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||||||
|
|
||||||
|
[$tw, $tp] = $this->tenantWhere($auth);
|
||||||
|
$sql = "DELETE FROM `$t` WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->bindValue(':__id', $id);
|
||||||
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 💡 Öffentliche run()-Methode (DEUTLICH VEREINFACHT)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extrahiere den Ressourcen-Typ und die Operation (z.B. 'templates' und 'list')
|
||||||
|
[$kind, $operation] = explode('.', $this->action, 2) + [1 => ''];
|
||||||
|
|
||||||
|
switch ($this->action) {
|
||||||
|
case 'health':
|
||||||
|
$this->respond(['ok' => true, 'time' => date('c')]);
|
||||||
|
|
||||||
|
/* ---------- AUTH ---------- */
|
||||||
|
case 'auth.login':
|
||||||
|
$result = $this->authService->login($this->in);
|
||||||
|
$this->respond(['ok' => true] + $result);
|
||||||
|
break;
|
||||||
|
case 'auth.me':
|
||||||
|
if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401);
|
||||||
|
$this->respond(['ok' => true, 'user' => $_SESSION['auth']]);
|
||||||
|
break;
|
||||||
|
case 'auth.logout':
|
||||||
|
$this->authService->logout();
|
||||||
|
$this->respond(['ok' => true]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* ---------- CRUD HANDLER ---------- */
|
||||||
|
default:
|
||||||
|
if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets'])) {
|
||||||
|
switch ($operation) {
|
||||||
|
case 'list':
|
||||||
|
$this->handleList($kind);
|
||||||
|
break;
|
||||||
|
case 'get':
|
||||||
|
$this->handleGet($kind);
|
||||||
|
break;
|
||||||
|
case 'create':
|
||||||
|
$this->handleCreate($kind);
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
$this->handleUpdate($kind);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
$this->handleDelete($kind);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$this->fail('Unknown operation for resource: ' . $this->action, null, 404);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->fail('Unknown action', $this->action ?: 'missing', 404);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->fail('Server error', get_class($e) . ': ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
657
inc/ApiKernel.php
Normal file
657
inc/ApiKernel.php
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 💡 NEUE KORREKTUR: Starte Output Buffering so früh wie möglich, um Whitespace/Errors
|
||||||
|
// von inkludierten Dateien (AuthService.php, config.php) abzufangen.
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
// Lade den AuthService
|
||||||
|
require_once __DIR__ . '/AuthService.php';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// ApiKernel.php (OPTIMIERT & KORRIGIERT)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
class ApiKernel
|
||||||
|
{
|
||||||
|
// Klassen-Eigenschaften
|
||||||
|
private array $conf;
|
||||||
|
private ?PDO $pdo = null;
|
||||||
|
private array $in;
|
||||||
|
private string $action;
|
||||||
|
private array $tableMap;
|
||||||
|
private AuthService $authService;
|
||||||
|
|
||||||
|
// --- Initialisierung & Konstruktor (Optimiert) ---
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// ob_start() wurde an den Anfang der Datei verschoben und wird hier entfernt.
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->conf = $this->loadConfig();
|
||||||
|
$this->cors();
|
||||||
|
$this->setInput();
|
||||||
|
$this->pdo = $this->getPdoTemplates();
|
||||||
|
$this->resolveAction();
|
||||||
|
$this->resolveTableMap();
|
||||||
|
$this->authService = new AuthService($this->conf, $this->pdo);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Im Fehlerfall ruft fail() die respond() Methode auf, die den Header setzt und den Buffer leert.
|
||||||
|
$this->fail('Initialization error', get_class($e) . ': ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Core Responder-Methoden (KORRIGIERT) ---
|
||||||
|
|
||||||
|
public function respond($data, int $code = 200): void
|
||||||
|
{
|
||||||
|
// 1. Output-Puffer leeren, um jeglichen unbeabsichtigten Output zu verwerfen (z.B. PHP Notices).
|
||||||
|
if (ob_get_level() > 0) {
|
||||||
|
ob_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 💡 KRITISCHE KORREKTUR: Content-Type Header setzen.
|
||||||
|
// Dies ist der entscheidende Schritt, der dem Browser sagt: "Dies ist JSON!"
|
||||||
|
if (!headers_sent() && !isset($this->conf['no_content_type'])) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code($code);
|
||||||
|
echo is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fail(string $msg, $detail = null, int $code = 400): void
|
||||||
|
{
|
||||||
|
$this->respond(['ok' => false, 'error' => $msg, 'detail' => $detail], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private Initialisierungs- & Utility-Methoden (Unverändert) ---
|
||||||
|
|
||||||
|
private function loadConfig(): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$paths = [
|
||||||
|
__DIR__ . '/config.php',
|
||||||
|
__DIR__ . '/../config.php',
|
||||||
|
__DIR__ . '/../../config.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($paths as $p) {
|
||||||
|
if (is_file($p)) {
|
||||||
|
$conf = @include $p;
|
||||||
|
if (is_array($conf)) return $conf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->fail('Invalid config.php', 'config.php not found or not returning array', 500);
|
||||||
|
}
|
||||||
|
private function cors(): void { /* ... Logik bleibt unverändert ... */
|
||||||
|
$cors = $this->conf['cors'] ?? '*';
|
||||||
|
if ($cors) {
|
||||||
|
header('Access-Control-Allow-Origin: ' . $cors);
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
}
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') $this->respond(['ok' => true]);
|
||||||
|
|
||||||
|
if (!empty($this->conf['auth']['cookie'])) {
|
||||||
|
$c = $this->conf['auth']['cookie'];
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
$params['lifetime'] = $c['lifetime'] ?? $params['lifetime'];
|
||||||
|
$params['path'] = $c['path'] ?? $params['path'];
|
||||||
|
$params['domain'] = $c['domain'] ?? $params['domain'];
|
||||||
|
$params['secure'] = $c['secure'] ?? $params['secure'];
|
||||||
|
$params['httponly'] = $c['httponly'] ?? $params['httponly'];
|
||||||
|
if (isset($c['samesite'])) $params['samesite'] = $c['samesite'];
|
||||||
|
session_set_cookie_params($params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private function setInput(): void { /* ... Logik bleibt unverändert ... */
|
||||||
|
$data = [];
|
||||||
|
$ct = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||||
|
if (stripos($ct, 'application/json') !== false) {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw !== false && $raw !== '') {
|
||||||
|
$js = json_decode($raw, true);
|
||||||
|
if (is_array($js)) $data = $js;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($_POST as $k => $v) $data[$k] = $v;
|
||||||
|
foreach ($_GET as $k => $v) if (!array_key_exists($k, $data)) $data[$k] = $v;
|
||||||
|
$this->in = $data;
|
||||||
|
}
|
||||||
|
private function getPdoTemplates(): PDO { /* ... Logik bleibt unverändert ... */
|
||||||
|
if (!isset($this->conf['templates']) || !is_array($this->conf['templates'])) {
|
||||||
|
$this->fail('Missing templates DB config', null, 500);
|
||||||
|
}
|
||||||
|
$c = $this->conf['templates'];
|
||||||
|
$host = $c['db_host'] ?? 'localhost';
|
||||||
|
$db = $c['db_name'] ?? ($c['database'] ?? '');
|
||||||
|
$user = $c['db_user'] ?? ($c['username'] ?? '');
|
||||||
|
$pass = $c['db_pass'] ?? ($c['password'] ?? '');
|
||||||
|
$charset = $c['db_charset'] ?? 'utf8mb4';
|
||||||
|
$port = $c['db_port'] ?? 3306;
|
||||||
|
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=$charset";
|
||||||
|
$opt = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
];
|
||||||
|
return new PDO($dsn, $user, $pass, $opt);
|
||||||
|
}
|
||||||
|
private function resolveAction(): void { /* ... Logik bleibt unverändert ... */
|
||||||
|
$action = $this->val($this->in, 'action', '');
|
||||||
|
$resource = $this->val($this->in, 'resource', null);
|
||||||
|
$allowedResources = ['templates', 'sections', 'blocks', 'snippets'];
|
||||||
|
if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) {
|
||||||
|
$verb = strtolower((string)$action);
|
||||||
|
if (in_array($verb, ['list', 'get', 'create', 'update', 'delete'], true)) $action = $resource . '.' . $verb;
|
||||||
|
}
|
||||||
|
$this->action = $action;
|
||||||
|
}
|
||||||
|
private function resolveTableMap(): void { /* ... Logik bleibt unverändert ... */
|
||||||
|
$tables = $this->conf['tables'] ?? [];
|
||||||
|
$this->tableMap = [
|
||||||
|
'templates' => $tables['templates'] ?? 'emailtemplate_templates',
|
||||||
|
'sections' => $tables['sections'] ?? 'emailtemplate_sections',
|
||||||
|
'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks',
|
||||||
|
'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function val(array $in, $keys, $default = null) { /* ... Logik bleibt unverändert ... */
|
||||||
|
if (!is_array($keys)) $keys = [$keys];
|
||||||
|
foreach ($keys as $k) if (array_key_exists($k, $in)) return $in[$k];
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
private function firstExisting(array $columns, array $candidates): ?string { /* ... Logik bleibt unverändert ... */
|
||||||
|
foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private function tableColumns(string $table): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$cols = [];
|
||||||
|
$stmt = $this->pdo->query("SHOW COLUMNS FROM `$table`");
|
||||||
|
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
|
||||||
|
return $cols;
|
||||||
|
}
|
||||||
|
private function primaryKey(string $table): ?string { /* ... Logik bleibt unverändert ... */
|
||||||
|
$stmt = $this->pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row['Column_name'] ?? null;
|
||||||
|
}
|
||||||
|
private function requireAuth(): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
return $this->authService->requireAuth();
|
||||||
|
}
|
||||||
|
private function pullId(array $src) { /* ... Logik bleibt unverändert ... */
|
||||||
|
$aliases = ['id', 'item_id', 'template_id', 'tpl_id', 'section_id', 'sec_id', 'block_id', 'blk_id', 'snippet_id', 'snip_id'];
|
||||||
|
foreach ($aliases as $a) if (isset($src[$a]) && $src[$a] !== '') return $src[$a];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private function tenantWhere(array $session): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$multi = $this->conf['multi'] ?? [];
|
||||||
|
$tenantCol = $multi['tenant_col'] ?? null;
|
||||||
|
$mapSess = $multi['map_session_to'] ?? 'id';
|
||||||
|
|
||||||
|
if (!$tenantCol) return ['', []];
|
||||||
|
if (!$session) return [' AND 1=0 ', []];
|
||||||
|
$val = $session[$mapSess] ?? null;
|
||||||
|
if ($val === null || $val === '') return [' AND 1=0 ', []];
|
||||||
|
return [" AND `$tenantCol` = :__tenant", [':__tenant' => $val]];
|
||||||
|
}
|
||||||
|
private function tenantAssign(array $session, array $columns): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$multi = $this->conf['multi'] ?? [];
|
||||||
|
$tenantCol = $multi['tenant_col'] ?? null;
|
||||||
|
$mapSess = $multi['map_session_to'] ?? 'id';
|
||||||
|
|
||||||
|
if (!$tenantCol || !in_array($tenantCol, $columns, true)) return [];
|
||||||
|
$val = $session[$mapSess] ?? null;
|
||||||
|
return ($val === null || $val === '') ? [] : [$tenantCol => $val];
|
||||||
|
}
|
||||||
|
private function resolveIdCol(string $kind): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
||||||
|
$cols = $this->tableColumns($t);
|
||||||
|
$idCol = $cfg['id'] ?? ($this->firstExisting($cols, ['id']) ?: $this->primaryKey($t));
|
||||||
|
if (!$idCol) $idCol = 'id';
|
||||||
|
return [$idCol, $cols];
|
||||||
|
}
|
||||||
|
private function parseHtmlToGjsComponents(string $html): array { /* ... Logik bleibt unverändert ... */
|
||||||
|
if (trim($html) === '') return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'type' => 'html',
|
||||||
|
'content' => $html,
|
||||||
|
'removable' => true,
|
||||||
|
'draggable' => true,
|
||||||
|
'droppable' => true,
|
||||||
|
'copyable' => true,
|
||||||
|
'selectable' => true,
|
||||||
|
'editable' => false,
|
||||||
|
'traits' => [],
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 Bereinigungsmethode
|
||||||
|
private function cleanReferenceComponents(array $components): array {
|
||||||
|
foreach ($components as &$component) {
|
||||||
|
if (is_array($component) && isset($component['type'])) {
|
||||||
|
|
||||||
|
if ($component['type'] === 'library-reference') {
|
||||||
|
if (isset($component['content'])) {
|
||||||
|
$component['content'] = '';
|
||||||
|
}
|
||||||
|
if (isset($component['components'])) {
|
||||||
|
$component['components'] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($component['components']) && is_array($component['components'])) {
|
||||||
|
$component['components'] = $this->cleanReferenceComponents($component['components']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $components;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 🚀 CRUD HANDLER METHODEN
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von LIST-Anfragen.
|
||||||
|
*/
|
||||||
|
private function handleList(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||||||
|
$q = trim((string)$this->val($this->in, 'q', ''));
|
||||||
|
$limit = max(1, (int)$this->val($this->in, 'limit', 500));
|
||||||
|
$offset = max(0, (int)$this->val($this->in, 'offset', 0));
|
||||||
|
|
||||||
|
$where = ' WHERE 1=1 ';
|
||||||
|
$params = [];
|
||||||
|
// Suchlogik (q)
|
||||||
|
if ($q !== '') {
|
||||||
|
$parts = ["`$nameCol` LIKE :q"];
|
||||||
|
if ($descCol) $parts[] = "`$descCol` LIKE :q";
|
||||||
|
if ($catCol) $parts[] = "`$catCol` LIKE :q";
|
||||||
|
$where .= " AND (" . implode(' OR ', $parts) . ") ";
|
||||||
|
$params[':q'] = '%' . $q . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filterlogik (parentFilters)
|
||||||
|
$parentFilters = [
|
||||||
|
'template_id' => $this->val($this->in, ['template_id', 'tpl_id'], null),
|
||||||
|
'section_id' => $this->val($this->in, ['section_id', 'sec_id'], null),
|
||||||
|
'block_id' => $this->val($this->in, ['block_id', 'blk_id'], null),
|
||||||
|
];
|
||||||
|
foreach ($parentFilters as $col => $v) {
|
||||||
|
if ($v === null || $v === '') continue;
|
||||||
|
if (in_array($col, $allCols, true)) { $where .= " AND `$col` = :$col "; $params[":$col"] = $v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant-Filter
|
||||||
|
[$tw, $tp] = $this->tenantWhere($auth);
|
||||||
|
$where .= $tw;
|
||||||
|
foreach ($tp as $k => $v) $params[$k] = $v;
|
||||||
|
|
||||||
|
$order = $updCol ? " ORDER BY `$updCol` DESC " : " ORDER BY `$nameCol` ASC ";
|
||||||
|
$sql = "SELECT * FROM `$t` $where $order LIMIT :off,:lim";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
|
||||||
|
// Bind parameters
|
||||||
|
foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$item = [
|
||||||
|
'id' => $r[$idCol] ?? null,
|
||||||
|
'name' => $r[$nameCol] ?? null,
|
||||||
|
];
|
||||||
|
if ($descCol && isset($r[$descCol])) $item['desc'] = $r[$descCol];
|
||||||
|
if ($catCol && isset($r[$catCol])) $item['category'] = $r[$catCol];
|
||||||
|
if ($updCol && isset($r[$updCol])) $item['updated_at'] = $r[$updCol];
|
||||||
|
|
||||||
|
// Lade HTML und JSON aus den korrekten Spalten
|
||||||
|
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
||||||
|
if ($htmlCol && isset($r[$htmlCol])) $item['html'] = (string)$r[$htmlCol];
|
||||||
|
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
||||||
|
if ($jsonCol && isset($r[$jsonCol])) $item['content'] = $r[$jsonCol];
|
||||||
|
|
||||||
|
$out[] = $item;
|
||||||
|
}
|
||||||
|
$this->respond(['ok' => true, 'kind' => $kind, 'items' => $out, 'data' => $out, 'count' => count($out), 'offset' => $offset, 'limit' => $limit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von GET-Anfragen.
|
||||||
|
*/
|
||||||
|
private function handleGet(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$id = $this->pullId($this->in);
|
||||||
|
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||||||
|
|
||||||
|
[$tw, $tp] = $this->tenantWhere($auth);
|
||||||
|
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->bindValue(':id', $id);
|
||||||
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) $this->fail('Not found', ['kind' => $kind, 'id' => $id], 404);
|
||||||
|
$rowOut = ['id' => $row[$idCol] ?? $id] + $row;
|
||||||
|
|
||||||
|
// Lade HTML und JSON aus den korrekten Spalten
|
||||||
|
$htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']);
|
||||||
|
$topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null;
|
||||||
|
$jsonCol = $this->firstExisting($allCols, ['json_content']);
|
||||||
|
$topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null;
|
||||||
|
|
||||||
|
$gjsComponents = [];
|
||||||
|
|
||||||
|
if ($topContent !== null) {
|
||||||
|
$decodedContent = json_decode($topContent, true);
|
||||||
|
if (is_array($decodedContent)) {
|
||||||
|
$gjsComponents = $decodedContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($gjsComponents) && $topHtml !== null) {
|
||||||
|
$gjsComponents = $this->parseHtmlToGjsComponents($topHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->respond([
|
||||||
|
'ok' => true,
|
||||||
|
'kind' => $kind,
|
||||||
|
'id' => $rowOut['id'],
|
||||||
|
'item' => $rowOut,
|
||||||
|
'data' => $rowOut,
|
||||||
|
'html' => $topHtml,
|
||||||
|
'content' => $topContent,
|
||||||
|
'gjs_components' => $gjsComponents
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von CREATE-Anfragen (inkl. JSON-Bereinigung).
|
||||||
|
*/
|
||||||
|
private function handleCreate(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||||||
|
|
||||||
|
$name = trim((string)$this->val($this->in, ['name', 'title'], ''));
|
||||||
|
if ($name === '') $this->fail('name required', null, 422);
|
||||||
|
|
||||||
|
$desc = (string)$this->val($this->in, ['description', 'desc'], null);
|
||||||
|
$cat = (string)$this->val($this->in, ['category', 'cat'], null);
|
||||||
|
$html = (string)$this->val($this->in, ['html', 'body', 'markup'], null);
|
||||||
|
$json = $this->val($this->in, ['content_json', 'json', 'content', 'structure_json'], null);
|
||||||
|
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
||||||
|
$templateId = $this->val($this->in, ['template_id', 'tpl_id'], null);
|
||||||
|
$sectionId = $this->val($this->in, ['section_id', 'sec_id'], null);
|
||||||
|
$blockId = $this->val($this->in, ['block_id', 'blk_id'], null);
|
||||||
|
|
||||||
|
$data = [$nameCol => $name];
|
||||||
|
if ($desc !== null && $descCol) $data[$descCol] = $desc;
|
||||||
|
if ($cat !== null && $catCol) $data[$catCol] = $cat;
|
||||||
|
|
||||||
|
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
|
||||||
|
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
|
||||||
|
|
||||||
|
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
|
||||||
|
|
||||||
|
// 1. JSON-Content behandeln
|
||||||
|
if ($json !== null) {
|
||||||
|
if ($jsonDbCol) {
|
||||||
|
$components = is_string($json) ? json_decode($json, true) : $json;
|
||||||
|
if (is_array($components)) {
|
||||||
|
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
|
||||||
|
$data[$jsonDbCol] = json_encode($components, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
$data[$jsonDbCol] = is_string($json) ? $json : '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
|
||||||
|
$this->fail(
|
||||||
|
'JSON content provided but no `json_content` column found',
|
||||||
|
['table' => $t, 'available_cols' => $allCols],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. HTML-Content speichern
|
||||||
|
if ($htmlDbCol && $html !== null) {
|
||||||
|
$data[$htmlDbCol] = $html;
|
||||||
|
}
|
||||||
|
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
|
||||||
|
|
||||||
|
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
||||||
|
if ($c && $settings !== null) $data[$c] = is_string($settings) ? $settings : json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if ($templateId !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $templateId;
|
||||||
|
if ($sectionId !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sectionId;
|
||||||
|
if ($blockId !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blockId;
|
||||||
|
|
||||||
|
$data = $data + $this->tenantAssign($_SESSION['auth'] ?? [], $allCols);
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']);
|
||||||
|
if ($createdCol) $data[$createdCol] = $now;
|
||||||
|
if ($updCol) $data[$updCol] = $now;
|
||||||
|
|
||||||
|
$fields = array_keys($data);
|
||||||
|
$place = array_map(fn($c) => ":$c", $fields);
|
||||||
|
$sql = "INSERT INTO `$t` (" . implode(',', array_map(fn($c) => "`$c`", $fields)) . ") VALUES (" . implode(',', $place) . ")";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$newId = $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
$out = ['id' => $newId, 'name' => $name];
|
||||||
|
if ($desc !== null) $out['desc'] = $desc;
|
||||||
|
if ($cat !== null) $out['category'] = $cat;
|
||||||
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $newId, 'item' => $out, 'data' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von UPDATE-Anfragen (inkl. JSON-Bereinigung).
|
||||||
|
*/
|
||||||
|
private function handleUpdate(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$cfg = $this->conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? $this->firstExisting($allCols, ['description', 'desc', 'descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? $this->firstExisting($allCols, ['category', 'cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? $this->firstExisting($allCols, ['updated_at', 'updated', 'updatedAt']);
|
||||||
|
$id = $this->pullId($this->in);
|
||||||
|
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
$name = $this->val($this->in, ['name', 'title'], null);
|
||||||
|
$desc = $this->val($this->in, ['description', 'desc'], null);
|
||||||
|
$cat = $this->val($this->in, ['category', 'cat'], null);
|
||||||
|
$html = $this->val($this->in, ['html', 'body', 'markup'], null);
|
||||||
|
$json = $this->val($this->in, ['content_json', 'json', 'content', 'structure_json'], null);
|
||||||
|
$settings = $this->val($this->in, ['settings_json', 'settings'], null);
|
||||||
|
|
||||||
|
if ($name !== null) $data[$nameCol] = (string)$name;
|
||||||
|
if ($desc !== null && $descCol) $data[$descCol] = (string)$desc;
|
||||||
|
if ($cat !== null && $catCol) $data[$catCol] = (string)$cat;
|
||||||
|
|
||||||
|
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
|
||||||
|
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
|
||||||
|
|
||||||
|
// --- LOGIK mit ERWEITERTER PRÜFUNG START ---
|
||||||
|
|
||||||
|
// 1. JSON-Content behandeln
|
||||||
|
if ($json !== null) {
|
||||||
|
if ($jsonDbCol) {
|
||||||
|
// Wenn JSON-Spalte existiert, JSON verarbeiten und speichern
|
||||||
|
$components = is_string($json) ? json_decode($json, true) : $json;
|
||||||
|
if (is_array($components)) {
|
||||||
|
$components = $this->cleanReferenceComponents($components); // BEREINIGUNG
|
||||||
|
$data[$jsonDbCol] = json_encode($components, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
$data[$jsonDbCol] = is_string($json) ? $json : '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// FALLBACK: Wenn JSON gesendet wurde, aber keine json_content Spalte existiert, FEHLER ausgeben
|
||||||
|
$this->fail(
|
||||||
|
'JSON content provided but no `json_content` column found',
|
||||||
|
['table' => $t, 'available_cols' => $allCols],
|
||||||
|
422
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Den zugehörigen HTML-Output speichern (wird vom Editor immer mitgesendet, wenn JSON da ist)
|
||||||
|
if ($html !== null && $htmlDbCol) {
|
||||||
|
$data[$htmlDbCol] = (string)$html;
|
||||||
|
}
|
||||||
|
} elseif ($html !== null && $htmlDbCol) {
|
||||||
|
// Wenn NUR HTML gesendet wird (für minimale Änderungen), speichern wir nur HTML.
|
||||||
|
$data[$htmlDbCol] = (string)$html;
|
||||||
|
}
|
||||||
|
// --- LOGIK mit ERWEITERTER PRÜFUNG ENDE ---
|
||||||
|
|
||||||
|
$c = $this->firstExisting($allCols, ['settings_json', 'settings']);
|
||||||
|
if ($settings !== null && $c) $data[$c] = is_string($settings) ? $settings : json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
$tpl = $this->val($this->in, ['template_id', 'tpl_id'], null);
|
||||||
|
if ($tpl !== null && in_array('template_id', $allCols, true)) $data['template_id'] = $tpl;
|
||||||
|
$sec = $this->val($this->in, ['section_id', 'sec_id'], null);
|
||||||
|
if ($sec !== null && in_array('section_id', $allCols, true)) $data['section_id'] = $sec;
|
||||||
|
$blk = $this->val($this->in, ['block_id', 'blk_id'], null);
|
||||||
|
if ($blk !== null && in_array('block_id', $allCols, true)) $data['block_id'] = $blk;
|
||||||
|
|
||||||
|
if ($updCol) $data[$updCol] = date('Y-m-d H:i:s');
|
||||||
|
if (!$data) $this->fail('nothing to update', null, 422);
|
||||||
|
|
||||||
|
[$tw, $tp] = $this->tenantWhere($auth);
|
||||||
|
$set = [];
|
||||||
|
foreach (array_keys($data) as $c) $set[] = "`$c` = :$c";
|
||||||
|
$sql = "UPDATE `$t` SET " . implode(',', $set) . " WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
|
||||||
|
foreach ($data as $k => $v) $stmt->bindValue(":$k", $v);
|
||||||
|
$stmt->bindValue(':__id', $id);
|
||||||
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'updated' => array_keys($data)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allgemeine Methode zur Handhabung von DELETE-Anfragen.
|
||||||
|
*/
|
||||||
|
private function handleDelete(string $kind): void
|
||||||
|
{
|
||||||
|
$auth = $this->requireAuth();
|
||||||
|
$t = $this->tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $this->resolveIdCol($kind);
|
||||||
|
$id = $this->pullId($this->in);
|
||||||
|
if ($id === null || $id === '') $this->fail('id required', null, 422);
|
||||||
|
|
||||||
|
[$tw, $tp] = $this->tenantWhere($auth);
|
||||||
|
$sql = "DELETE FROM `$t` WHERE `$idCol` = :__id" . $tw . " LIMIT 1";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->bindValue(':__id', $id);
|
||||||
|
foreach ($tp as $k => $v) $stmt->bindValue($k, $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $id, 'deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 💡 Öffentliche run()-Methode (KORRIGIERT)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// 💡 KORREKTUR: Der Content-Type Header wird hier entfernt, da er jetzt in respond()
|
||||||
|
// zentralisiert wurde, um sicherzustellen, dass er auch bei Fehlern im Konstruktor oder
|
||||||
|
// im try-Block korrekt gesetzt wird.
|
||||||
|
// header('Content-Type: application/json; charset=utf-8'); // DIESE ZEILE ENTFERNT
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extrahiere den Ressourcen-Typ und die Operation (z.B. 'templates' und 'list')
|
||||||
|
[$kind, $operation] = explode('.', $this->action, 2) + [1 => ''];
|
||||||
|
|
||||||
|
switch ($this->action) {
|
||||||
|
case 'health':
|
||||||
|
$this->respond(['ok' => true, 'time' => date('c')]);
|
||||||
|
|
||||||
|
/* ---------- AUTH ---------- */
|
||||||
|
case 'auth.login':
|
||||||
|
$result = $this->authService->login($this->in);
|
||||||
|
$this->respond(['ok' => true] + $result);
|
||||||
|
break;
|
||||||
|
case 'auth.me':
|
||||||
|
if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401);
|
||||||
|
$this->respond(['ok' => true, 'user' => $_SESSION['auth']]);
|
||||||
|
break;
|
||||||
|
case 'auth.logout':
|
||||||
|
$this->authService->logout();
|
||||||
|
$this->respond(['ok' => true]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* ---------- CRUD HANDLER ---------- */
|
||||||
|
default:
|
||||||
|
if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets'])) {
|
||||||
|
switch ($operation) {
|
||||||
|
case 'list':
|
||||||
|
$this->handleList($kind);
|
||||||
|
break;
|
||||||
|
case 'get':
|
||||||
|
$this->handleGet($kind);
|
||||||
|
break;
|
||||||
|
case 'create':
|
||||||
|
$this->handleCreate($kind);
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
$this->handleUpdate($kind);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
$this->handleDelete($kind);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$this->fail('Unknown operation for resource: ' . $this->action, null, 404);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->fail('Unknown action', $this->action ?: 'missing', 404);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->fail('Server error', get_class($e) . ': ' . $e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
inc/AuthService.php
Normal file
105
inc/AuthService.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// AuthService.php: Kapselt die gesamte Authentifizierungslogik.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
class AuthService
|
||||||
|
{
|
||||||
|
private array $conf;
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
// Abhängigkeiten (Konfiguration und PDO) werden per Konstruktor übergeben
|
||||||
|
public function __construct(array $conf, PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->conf = $conf;
|
||||||
|
$this->pdo = $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private Utility Methoden ---
|
||||||
|
|
||||||
|
private function fail(string $msg, $detail = null, int $code = 400): void
|
||||||
|
{
|
||||||
|
// Wir müssen hier direkt antworten, da wir das Fail-Verhalten des Kernels benötigen.
|
||||||
|
// Im ApiKernel werden wir die respond/fail-Methoden als public lassen,
|
||||||
|
// um sie hier injizieren zu können, oder wir lassen sie hier im Global Scope
|
||||||
|
// (WENN Sie die ursprünglichen globalen Funktionen respond/fail wieder zulassen).
|
||||||
|
// Für eine saubere Kapselung injizieren wir die Respond-Logik.
|
||||||
|
// HIER verwenden wir eine einfache JSON-Antwort, da die fail-Methode
|
||||||
|
// normalerweise den gesamten Kernel stoppt. Wir nutzen exit.
|
||||||
|
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode(['ok'=>false,'error'=>$msg,'detail'=>$detail], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifyPassword(string $input, string $stored, array $authDbConf): bool
|
||||||
|
{
|
||||||
|
if (preg_match('~^\$2[aby]\$~', $stored) || strpos($stored, '$argon2') === 0) return password_verify($input, $stored);
|
||||||
|
$legacy = strtolower($authDbConf['legacy'] ?? '');
|
||||||
|
if ($legacy === 'md5') return hash_equals($stored, md5($input));
|
||||||
|
if ($legacy === 'sha1') return hash_equals($stored, sha1($input));
|
||||||
|
if (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored);
|
||||||
|
return hash_equals($stored, $input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public Service Methoden ---
|
||||||
|
|
||||||
|
public function requireAuth(): array
|
||||||
|
{
|
||||||
|
if (empty($_SESSION['auth'])) $this->fail('Not authenticated', null, 401);
|
||||||
|
return $_SESSION['auth'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): bool
|
||||||
|
{
|
||||||
|
$_SESSION = [];
|
||||||
|
if (session_id() !== '') session_destroy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(array $in): array
|
||||||
|
{
|
||||||
|
$authDb = $this->conf['auth']['db'] ?? [];
|
||||||
|
$colUser = $authDb['col_user'] ?? 'email';
|
||||||
|
$colPass = $authDb['col_pass'] ?? 'password';
|
||||||
|
$colName = $authDb['col_name'] ?? 'name';
|
||||||
|
$colId = $authDb['col_id'] ?? 'id';
|
||||||
|
$colStatus = $authDb['col_status']?? null;
|
||||||
|
$activeValues = $authDb['active_values'] ?? ['active','1',1];
|
||||||
|
$table = $authDb['table'] ?? 'emailtemplate_users';
|
||||||
|
|
||||||
|
$identifier = trim((string)($in['username'] ?? $in['user'] ?? $in['email'] ?? $in['login'] ?? ''));
|
||||||
|
$password = (string)($in['password'] ?? $in['pass'] ?? $in['pwd'] ?? '');
|
||||||
|
|
||||||
|
if ($identifier === '' || $password === '') $this->fail('username/password required', null, 422);
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `$colUser` = :u LIMIT 1");
|
||||||
|
$stmt->execute([':u'=>$identifier]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$row) $this->fail('Invalid credentials', null, 401);
|
||||||
|
|
||||||
|
if ($colStatus && isset($row[$colStatus])) {
|
||||||
|
if (!in_array($row[$colStatus], $activeValues, true)) {
|
||||||
|
$this->fail('Account inactive', null, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = (string)($row[$colPass] ?? '');
|
||||||
|
if ($stored === '' || !$this->verifyPassword($password, $stored, $authDb)) {
|
||||||
|
$this->fail('Invalid credentials', null, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['auth'] = [
|
||||||
|
'id' => $row[$colId] ?? null,
|
||||||
|
'name' => $row[$colName] ?? ($row[$colUser] ?? $identifier),
|
||||||
|
'email' => $row[$colUser] ?? $identifier,
|
||||||
|
'at' => time(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$token = base64_encode(hash('sha256', ($_SESSION['auth']['id'] ?? $identifier).'|'.session_id(), true));
|
||||||
|
return ['user'=>$_SESSION['auth'], 'token'=>$token];
|
||||||
|
}
|
||||||
|
}
|
||||||
138
inc/OUTDATED auth_helpers.php
Normal file
138
inc/OUTDATED auth_helpers.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
// inc/auth_helpers.php – robustes Session-Handling (2025-09-05)
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** ===== Session ===== */
|
||||||
|
function auth_start_session(array $cfg): void {
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
$name = $cfg['auth']['session_name'] ?? 'et_session';
|
||||||
|
session_name($name);
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
$cookiePath = $cfg['auth']['cookie_path'] ?? '/';
|
||||||
|
$cookieDomain = $cfg['auth']['cookie_domain'] ?? '';
|
||||||
|
$cookieSecure = $cfg['auth']['cookie_secure'] ?? true;
|
||||||
|
$cookieHttpOnly = $cfg['auth']['cookie_httponly'] ?? true;
|
||||||
|
$cookieSameSite = $cfg['auth']['cookie_samesite'] ?? 'Lax';
|
||||||
|
|
||||||
|
// PHP 7.3+ array API
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => $cookiePath,
|
||||||
|
'domain' => $cookieDomain,
|
||||||
|
'secure' => $cookieSecure,
|
||||||
|
'httponly' => $cookieHttpOnly,
|
||||||
|
'samesite' => $cookieSameSite,
|
||||||
|
]);
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ===== Auth Core ===== */
|
||||||
|
function auth_login(PDO $pdoCustomers, array $cfg, string $email, string $password): array {
|
||||||
|
auth_start_session($cfg);
|
||||||
|
|
||||||
|
$sql = "SELECT cu.id, cu.customer_id, cu.email, cu.password_hash, cu.role,
|
||||||
|
c.slug AS customer_slug, c.plan, c.status
|
||||||
|
FROM customer_users cu
|
||||||
|
JOIN customers c ON c.id = cu.customer_id
|
||||||
|
WHERE cu.email = :email AND cu.is_active = 1
|
||||||
|
LIMIT 1";
|
||||||
|
$st = $pdoCustomers->prepare($sql);
|
||||||
|
$st->execute([':email' => $email]);
|
||||||
|
$u = $st->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$u || !password_verify($password, $u['password_hash'])) {
|
||||||
|
return ['ok' => false, 'error' => 'invalid_credentials'];
|
||||||
|
}
|
||||||
|
if (($u['status'] ?? 'active') !== 'active') {
|
||||||
|
return ['ok' => false, 'error' => 'customer_inactive'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// neue Session-ID, alte wird invalidiert
|
||||||
|
session_regenerate_id(true);
|
||||||
|
|
||||||
|
$_SESSION['user'] = [
|
||||||
|
'id' => (int)$u['id'],
|
||||||
|
'email' => $u['email'],
|
||||||
|
'role' => $u['role'],
|
||||||
|
'customer_id' => (int)$u['customer_id'],
|
||||||
|
'customer_slug' => $u['customer_slug'],
|
||||||
|
'plan' => $u['plan'],
|
||||||
|
];
|
||||||
|
return ['ok' => true, 'user' => $_SESSION['user']];
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_logout(array $cfg): void {
|
||||||
|
auth_start_session($cfg);
|
||||||
|
|
||||||
|
// Sessiondaten löschen
|
||||||
|
$_SESSION = [];
|
||||||
|
|
||||||
|
// Cookie-Parameter aus der aktiven Session
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
$name = session_name();
|
||||||
|
|
||||||
|
// Kandidaten für Domain/Path, um "falsch" gesetzte Cookies sicher zu treffen
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
$cfgDomain = $cfg['auth']['cookie_domain'] ?? '';
|
||||||
|
$paths = array_values(array_unique([$params['path'] ?? '/', '/', '']));
|
||||||
|
$domains = array_values(array_unique([
|
||||||
|
$params['domain'] ?? '',
|
||||||
|
$cfgDomain,
|
||||||
|
$host,
|
||||||
|
ltrim($host, '.'),
|
||||||
|
(strpos($host, '.') !== false ? '.' . ltrim($host, '.') : $host),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Alle Varianten invalidieren (secure/httponly wie gesetzt)
|
||||||
|
foreach ($paths as $p) {
|
||||||
|
foreach ($domains as $d) {
|
||||||
|
if ($d === null) continue;
|
||||||
|
setcookie($name, '', time() - 3600, $p, $d, $params['secure'] ?? true, $params['httponly'] ?? true);
|
||||||
|
}
|
||||||
|
// zusätzlich: ohne Domain (trifft Host-spezifische Cookies)
|
||||||
|
setcookie($name, '', time() - 3600, $p, '', $params['secure'] ?? true, $params['httponly'] ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session beenden
|
||||||
|
session_destroy();
|
||||||
|
session_write_close();
|
||||||
|
|
||||||
|
// In Staging aggressiv: Browser bitten, Cookies zu löschen (nicht jeder Browser respektiert das sofort)
|
||||||
|
if (($cfg['env'] ?? 'prod') === 'staging') {
|
||||||
|
header('Clear-Site-Data: "cookies"', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auth_require(array $cfg): void {
|
||||||
|
auth_start_session($cfg);
|
||||||
|
if (empty($_SESSION['user'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_role(array $cfg, array $roles): void {
|
||||||
|
auth_start_session($cfg);
|
||||||
|
$r = $_SESSION['user']['role'] ?? null;
|
||||||
|
if (!$r || !in_array($r, $roles, true)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'forbidden']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function current_user(array $cfg): ?array {
|
||||||
|
auth_start_session($cfg);
|
||||||
|
return $_SESSION['user'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function current_customer_id(array $cfg): ?int {
|
||||||
|
$u = current_user($cfg);
|
||||||
|
return $u['customer_id'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
232
inc/OUTDATED bootstrap.php
Normal file
232
inc/OUTDATED bootstrap.php
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 🚨 KRITISCHE STARTSEQUENZ / BOOTSTRAP (VERSION MIT FUNKTIONEN) 🚨
|
||||||
|
// Alle Helfer sind jetzt reguläre, globale Funktionen, um Scope-Probleme zu vermeiden.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// 1. Composer Autoload
|
||||||
|
$composerAutoload = __DIR__ . '/../../vendor/autoload.php';
|
||||||
|
if (is_file($composerAutoload)) {
|
||||||
|
require_once $composerAutoload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Session Start (Muss VOR dem Senden des Session-Cookies/Headers erfolgen)
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Globale Helferfunktionen (Die in api.php aufgerufen werden) ---
|
||||||
|
|
||||||
|
function respond($data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
echo is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(string $msg, $detail = null, int $code = 400): void {
|
||||||
|
respond(['ok'=>false,'error'=>$msg,'detail'=>$detail], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESTLICHE HELFER (MÜSSEN KEINE GLOBALS VERWENDEN)
|
||||||
|
function load_config(): array {
|
||||||
|
$paths = [
|
||||||
|
__DIR__ . '/config.php',
|
||||||
|
__DIR__ . '/../config.php',
|
||||||
|
__DIR__ . '/../../config.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($paths as $p) {
|
||||||
|
if (is_file($p)) {
|
||||||
|
$conf = @include $p;
|
||||||
|
if (is_array($conf)) return $conf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail('Invalid config.php', 'config.php not found or not returning array', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cors(array $conf): void {
|
||||||
|
$cors = $conf['cors'] ?? '*';
|
||||||
|
if ($cors) {
|
||||||
|
header('Access-Control-Allow-Origin: ' . $cors);
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
}
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') respond(['ok'=>true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_input(): array {
|
||||||
|
$data = [];
|
||||||
|
$ct = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||||
|
if (stripos($ct, 'application/json') !== false) {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw !== false && $raw !== '') {
|
||||||
|
$js = json_decode($raw, true);
|
||||||
|
if (is_array($js)) $data = $js;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($_POST as $k=>$v) $data[$k]=$v;
|
||||||
|
foreach ($_GET as $k=>$v) if (!array_key_exists($k,$data)) $data[$k]=$v;
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function val(array $in, $keys, $default=null) {
|
||||||
|
if (!is_array($keys)) $keys = [$keys];
|
||||||
|
foreach ($keys as $k) if (array_key_exists($k,$in)) return $in[$k];
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function first_existing(array $columns, array $candidates): ?string {
|
||||||
|
foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdo_templates(array $conf): PDO {
|
||||||
|
if (!isset($conf['templates']) || !is_array($conf['templates'])) {
|
||||||
|
fail('Missing templates DB config', null, 500);
|
||||||
|
}
|
||||||
|
$c = $conf['templates'];
|
||||||
|
$host = $c['db_host'] ?? 'localhost';
|
||||||
|
$db = $c['db_name'] ?? ($c['database'] ?? '');
|
||||||
|
$user = $c['db_user'] ?? ($c['username'] ?? '');
|
||||||
|
$pass = $c['db_pass'] ?? ($c['password'] ?? '');
|
||||||
|
$charset = $c['db_charset'] ?? 'utf8mb4';
|
||||||
|
$port = $c['db_port'] ?? 3306;
|
||||||
|
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=$charset";
|
||||||
|
$opt = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
];
|
||||||
|
return new PDO($dsn, $user, $pass, $opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify_password(string $input, string $stored, array $authDbConf): bool {
|
||||||
|
if (preg_match('~^\$2[aby]\$~', $stored) || strpos($stored, '$argon2') === 0) return password_verify($input, $stored);
|
||||||
|
$legacy = strtolower($authDbConf['legacy'] ?? '');
|
||||||
|
if ($legacy === 'md5') return hash_equals($stored, md5($input));
|
||||||
|
if ($legacy === 'sha1') return hash_equals($stored, sha1($input));
|
||||||
|
if (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored);
|
||||||
|
return hash_equals($stored, $input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function table_columns(PDO $pdo, string $table): array {
|
||||||
|
$cols = [];
|
||||||
|
$stmt = $pdo->query("SHOW COLUMNS FROM `$table`");
|
||||||
|
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
|
||||||
|
return $cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primary_key(PDO $pdo, string $table): ?string {
|
||||||
|
$stmt = $pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row['Column_name'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Neue, reguläre Funktionen (ersetzen Closures) ---
|
||||||
|
|
||||||
|
function requireAuth(): array {
|
||||||
|
// Muss auf globale $_SESSION zugreifen
|
||||||
|
if (empty($_SESSION['auth'])) fail('Not authenticated', null, 401);
|
||||||
|
return $_SESSION['auth'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pullId(array $src) {
|
||||||
|
$aliases = ['id','item_id','template_id','tpl_id','section_id','sec_id','block_id','blk_id','snippet_id','snip_id'];
|
||||||
|
foreach ($aliases as $a) if (isset($src[$a]) && $src[$a] !== '') return $src[$a];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantWhere(array $session): array {
|
||||||
|
// Muss auf globale $conf zugreifen, um $tenantCol und $mapSess zu erhalten
|
||||||
|
global $conf;
|
||||||
|
$multi = $conf['multi'] ?? [];
|
||||||
|
$tenantCol = $multi['tenant_col'] ?? null;
|
||||||
|
$mapSess = $multi['map_session_to'] ?? 'id';
|
||||||
|
|
||||||
|
if (!$tenantCol) return ['', []];
|
||||||
|
if (!$session) return [' AND 1=0 ', []];
|
||||||
|
$val = $session[$mapSess] ?? null;
|
||||||
|
if ($val===null || $val==='') return [' AND 1=0 ', []];
|
||||||
|
return [" AND `$tenantCol` = :__tenant", [':__tenant'=>$val]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantAssign(array $session, array $columns): array {
|
||||||
|
// Muss auf globale $conf zugreifen
|
||||||
|
global $conf;
|
||||||
|
$multi = $conf['multi'] ?? [];
|
||||||
|
$tenantCol = $multi['tenant_col'] ?? null;
|
||||||
|
$mapSess = $multi['map_session_to'] ?? 'id';
|
||||||
|
|
||||||
|
if (!$tenantCol || !in_array($tenantCol, $columns, true)) return [];
|
||||||
|
$val = $session[$mapSess] ?? null;
|
||||||
|
return ($val===null || $val==='') ? [] : [$tenantCol => $val];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIdCol(string $kind): array {
|
||||||
|
// Muss auf globale $conf, $pdo, und $tableMap zugreifen
|
||||||
|
global $conf, $pdo, $tableMap;
|
||||||
|
|
||||||
|
$t = $tableMap[$kind];
|
||||||
|
$cfg = $conf['columns'][$kind] ?? [];
|
||||||
|
$cols = table_columns($pdo, $t);
|
||||||
|
$idCol = $cfg['id'] ?? (in_array('id', $cols, true) ? 'id' : primary_key($pdo, $t));
|
||||||
|
if (!$idCol) $idCol = 'id';
|
||||||
|
return [$idCol, $cols];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Haupt-Setup-Logik (Setzt die globalen Variablen) ---
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Deklariere alle Variablen, die im Router von api.php benötigt werden, als global
|
||||||
|
global $conf, $pdo, $in, $action, $tableMap;
|
||||||
|
|
||||||
|
// 1. Globale Konfiguration und CORS
|
||||||
|
$conf = load_config();
|
||||||
|
cors($conf);
|
||||||
|
|
||||||
|
// 2. Cookie-Parameter setzen
|
||||||
|
if (!empty($conf['auth']['cookie'])) {
|
||||||
|
$c = $conf['auth']['cookie'];
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
$params['lifetime'] = $c['lifetime'] ?? $params['lifetime'];
|
||||||
|
$params['path'] = $c['path'] ?? $params['path'];
|
||||||
|
$params['domain'] = $c['domain'] ?? $params['domain'];
|
||||||
|
$params['secure'] = $c['secure'] ?? $params['secure'];
|
||||||
|
$params['httponly'] = $c['httponly'] ?? $params['httponly'];
|
||||||
|
if (isset($c['samesite'])) $params['samesite'] = $c['samesite'];
|
||||||
|
session_set_cookie_params($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Input-Daten abrufen
|
||||||
|
$in = get_input();
|
||||||
|
|
||||||
|
// 4. Datenbankverbindung herstellen
|
||||||
|
$pdo = pdo_templates($conf);
|
||||||
|
|
||||||
|
// 5. Action / Resource auflösen
|
||||||
|
$action = val($in, 'action', '');
|
||||||
|
$resource = val($in, 'resource', null);
|
||||||
|
$allowedResources = ['templates','sections','blocks','snippets'];
|
||||||
|
if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) {
|
||||||
|
$verb = strtolower((string)$action);
|
||||||
|
if (in_array($verb, ['list','get','create','update','delete'], true)) $action = $resource.'.'.$verb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Tabellenzuweisungen
|
||||||
|
$tables = $conf['tables'] ?? [];
|
||||||
|
$tableMap = [
|
||||||
|
'templates' => $tables['templates'] ?? 'emailtemplate_templates',
|
||||||
|
'sections' => $tables['sections'] ?? 'emailtemplate_sections',
|
||||||
|
'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks',
|
||||||
|
'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets',
|
||||||
|
];
|
||||||
|
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Fehler während der Initialisierung abfangen
|
||||||
|
fail('Initialization error', get_class($e).': '.$e->getMessage(), 500);
|
||||||
|
}
|
||||||
36
inc/OUTDATED config.example.php
Normal file
36
inc/OUTDATED config.example.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'templates' => [
|
||||||
|
'db_host' => '127.0.0.1',
|
||||||
|
'db_port' => 3306,
|
||||||
|
'db_name' => 'YOUR_DB_NAME',
|
||||||
|
'db_user' => 'YOUR_DB_USER',
|
||||||
|
'db_pass' => 'YOUR_DB_PASS',
|
||||||
|
'db_charset' => 'utf8mb4',
|
||||||
|
'prefix' => 'emailtemplate_',
|
||||||
|
],
|
||||||
|
'project' => [
|
||||||
|
'db_host' => '127.0.0.1',
|
||||||
|
'db_port' => 3306,
|
||||||
|
'db_name' => 'YOUR_PROJECT_DB',
|
||||||
|
'db_user' => 'YOUR_PROJECT_USER',
|
||||||
|
'db_pass' => 'YOUR_PROJECT_PASS',
|
||||||
|
'db_charset' => 'utf8mb4',
|
||||||
|
],
|
||||||
|
|
||||||
|
// SMTP / Testversand
|
||||||
|
'smtp' => [
|
||||||
|
'host' => getenv('SMTP_HOST') ?: '',
|
||||||
|
'port' => getenv('SMTP_PORT') ?: 587,
|
||||||
|
'user' => getenv('SMTP_USER') ?: '',
|
||||||
|
'pass' => getenv('SMTP_PASS') ?: '',
|
||||||
|
'secure' => getenv('SMTP_SECURE') ?: 'tls',
|
||||||
|
'from_email' => getenv('SMTP_FROM_EMAIL') ?: 'no-reply@example.com',
|
||||||
|
'from_name' => getenv('SMTP_FROM_NAME') ?: 'EmailTemplate',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Export-API: statische API-Keys
|
||||||
|
'export' => [
|
||||||
|
'api_keys' => explode(',', getenv('EXPORT_API_KEYS') ?: 'dev-key-123'),
|
||||||
|
],
|
||||||
|
];
|
||||||
259
inc/api_kernel_log.txt
Normal file
259
inc/api_kernel_log.txt
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[2025-10-31 01:21:54] --- Get::blocks - Raw JSON from DB ---
|
||||||
|
Array
|
||||||
|
(
|
||||||
|
[topContent] => {"dataSources":[],"assets":[],"styles":[{"selectors":[],"selectorsAdd":"*","style":{"box-sizing":"border-box"}},{"selectors":[],"selectorsAdd":"body","style":{"margin-top":"0px","margin-right":"0px","margin-bottom":"0px","margin-left":"0px"}}],"pages":[{"frames":[{"component":{"type":"wrapper","stylable":["background","background-color","background-image","background-repeat","background-attachment","background-position","background-size"],"components":[{"type":"library-reference","content":"Alles Neu macht der Mai","lib-kind":"snippets","lib-id":1}],"head":{"type":"head"},"docEl":{"tagName":"html"}},"id":"P4uy9DBKbT5yTO4c"}],"type":"main","id":"dJ5hyxgFUxsCWgbi"}],"symbols":[]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
[2025-10-31 01:21:54] --- Get::blocks - Decoded JSON ---
|
||||||
|
Array
|
||||||
|
(
|
||||||
|
[decodedContent] => Array
|
||||||
|
(
|
||||||
|
[dataSources] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
[assets] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
[styles] => Array
|
||||||
|
(
|
||||||
|
[0] => Array
|
||||||
|
(
|
||||||
|
[selectors] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
[selectorsAdd] => *
|
||||||
|
[style] => Array
|
||||||
|
(
|
||||||
|
[box-sizing] => border-box
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[1] => Array
|
||||||
|
(
|
||||||
|
[selectors] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
[selectorsAdd] => body
|
||||||
|
[style] => Array
|
||||||
|
(
|
||||||
|
[margin-top] => 0px
|
||||||
|
[margin-right] => 0px
|
||||||
|
[margin-bottom] => 0px
|
||||||
|
[margin-left] => 0px
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[pages] => Array
|
||||||
|
(
|
||||||
|
[0] => Array
|
||||||
|
(
|
||||||
|
[frames] => Array
|
||||||
|
(
|
||||||
|
[0] => Array
|
||||||
|
(
|
||||||
|
[component] => Array
|
||||||
|
(
|
||||||
|
[type] => wrapper
|
||||||
|
[stylable] => Array
|
||||||
|
(
|
||||||
|
[0] => background
|
||||||
|
[1] => background-color
|
||||||
|
[2] => background-image
|
||||||
|
[3] => background-repeat
|
||||||
|
[4] => background-attachment
|
||||||
|
[5] => background-position
|
||||||
|
[6] => background-size
|
||||||
|
)
|
||||||
|
|
||||||
|
[components] => Array
|
||||||
|
(
|
||||||
|
[0] => Array
|
||||||
|
(
|
||||||
|
[type] => library-reference
|
||||||
|
[content] => Alles Neu macht der Mai
|
||||||
|
[lib-kind] => snippets
|
||||||
|
[lib-id] => 1
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[head] => Array
|
||||||
|
(
|
||||||
|
[type] => head
|
||||||
|
)
|
||||||
|
|
||||||
|
[docEl] => Array
|
||||||
|
(
|
||||||
|
[tagName] => html
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[id] => P4uy9DBKbT5yTO4c
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[type] => main
|
||||||
|
[id] => dJ5hyxgFUxsCWgbi
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[symbols] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
[2025-10-31 01:21:54] --- Get::blocks - Final Gjs Components ---
|
||||||
|
Array
|
||||||
|
(
|
||||||
|
[count] => 5
|
||||||
|
[first_component] => N/A
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
[2025-10-31 01:21:54] --- Get::blocks - Raw JSON from DB ---
|
||||||
|
Array
|
||||||
|
(
|
||||||
|
[topContent] => {"dataSources":[],"assets":[],"styles":[{"selectors":[],"selectorsAdd":"*","style":{"box-sizing":"border-box"}},{"selectors":[],"selectorsAdd":"body","style":{"margin-top":"0px","margin-right":"0px","margin-bottom":"0px","margin-left":"0px"}}],"pages":[{"frames":[{"component":{"type":"wrapper","stylable":["background","background-color","background-image","background-repeat","background-attachment","background-position","background-size"],"components":[{"type":"library-reference","content":"Alles Neu macht der Mai","lib-kind":"snippets","lib-id":1}],"head":{"type":"head"},"docEl":{"tagName":"html"}},"id":"P4uy9DBKbT5yTO4c"}],"type":"main","id":"dJ5hyxgFUxsCWgbi"}],"symbols":[]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
[2025-10-31 01:21:54] --- Get::blocks - Decoded JSON ---
|
||||||
|
Array
|
||||||
|
(
|
||||||
|
[decodedContent] => Array
|
||||||
|
(
|
||||||
|
[dataSources] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
[assets] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
[styles] => Array
|
||||||
|
(
|
||||||
|
[0] => Array
|
||||||
|
(
|
||||||
|
[selectors] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
[selectorsAdd] => *
|
||||||
|
[style] => Array
|
||||||
|
(
|
||||||
|
[box-sizing] => border-box
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[1] => Array
|
||||||
|
(
|
||||||
|
[selectors] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
[selectorsAdd] => body
|
||||||
|
[style] => Array
|
||||||
|
(
|
||||||
|
[margin-top] => 0px
|
||||||
|
[margin-right] => 0px
|
||||||
|
[margin-bottom] => 0px
|
||||||
|
[margin-left] => 0px
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[pages] => Array
|
||||||
|
(
|
||||||
|
[0] => Array
|
||||||
|
(
|
||||||
|
[frames] => Array
|
||||||
|
(
|
||||||
|
[0] => Array
|
||||||
|
(
|
||||||
|
[component] => Array
|
||||||
|
(
|
||||||
|
[type] => wrapper
|
||||||
|
[stylable] => Array
|
||||||
|
(
|
||||||
|
[0] => background
|
||||||
|
[1] => background-color
|
||||||
|
[2] => background-image
|
||||||
|
[3] => background-repeat
|
||||||
|
[4] => background-attachment
|
||||||
|
[5] => background-position
|
||||||
|
[6] => background-size
|
||||||
|
)
|
||||||
|
|
||||||
|
[components] => Array
|
||||||
|
(
|
||||||
|
[0] => Array
|
||||||
|
(
|
||||||
|
[type] => library-reference
|
||||||
|
[content] => Alles Neu macht der Mai
|
||||||
|
[lib-kind] => snippets
|
||||||
|
[lib-id] => 1
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[head] => Array
|
||||||
|
(
|
||||||
|
[type] => head
|
||||||
|
)
|
||||||
|
|
||||||
|
[docEl] => Array
|
||||||
|
(
|
||||||
|
[tagName] => html
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[id] => P4uy9DBKbT5yTO4c
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[type] => main
|
||||||
|
[id] => dJ5hyxgFUxsCWgbi
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
[symbols] => Array
|
||||||
|
(
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
[2025-10-31 01:21:54] --- Get::blocks - Final Gjs Components ---
|
||||||
|
Array
|
||||||
|
(
|
||||||
|
[count] => 5
|
||||||
|
[first_component] => N/A
|
||||||
|
)
|
||||||
76
inc/config.php
Normal file
76
inc/config.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
return [
|
||||||
|
'templates' => [
|
||||||
|
'db_host' => getenv('DB_TPL_HOST') ?: 'localhost',
|
||||||
|
'db_name' => getenv('DB_TPL_NAME') ?: 'd044ae9e',
|
||||||
|
'db_user' => getenv('DB_TPL_USER') ?: 'd044ae9e',
|
||||||
|
'db_pass' => getenv('DB_TPL_PASS') ?: '9BVUn)Töcü@ÖVÜfgO8!J',
|
||||||
|
'db_charset' => 'utf8',
|
||||||
|
'prefix' => getenv('DB_TPL_PREFIX') ?: 'emailtemplate_',
|
||||||
|
|
||||||
|
],
|
||||||
|
'project' => [
|
||||||
|
'db_host' => getenv('DB_TPL_HOST') ?: 'w0207fd0.kasserver.com',
|
||||||
|
'db_name' => getenv('DB_TPL_NAME') ?: 'd0444c25',
|
||||||
|
'db_user' => getenv('DB_TPL_USER') ?: 'd0444c25',
|
||||||
|
'db_pass' => getenv('DB_TPL_PASS') ?: '/7ü9+§ÄfkiQvGPr§2Op7',
|
||||||
|
'db_charset' => 'utf8',
|
||||||
|
],
|
||||||
|
'cors' => getenv('CORS_ORIGIN') ?: '*',
|
||||||
|
'env' => 'staging',
|
||||||
|
'base_url' => 'https://staging.emailtemplate.it',
|
||||||
|
'auth' => [
|
||||||
|
'session_name' => 'et_session',
|
||||||
|
'cookie_domain' => 'staging.emailtemplate.it',
|
||||||
|
'cookie_secure' => true,
|
||||||
|
'cookie_httponly'=> true,
|
||||||
|
'cookie_samesite'=> 'Lax',
|
||||||
|
'db' => [
|
||||||
|
'table' => 'customer_users',
|
||||||
|
'col_user' => 'email', // alternativ: 'username'
|
||||||
|
'col_pass' => 'password_hash',
|
||||||
|
'col_name' => 'name', // optional
|
||||||
|
'col_id' => 'id', // optional
|
||||||
|
'col_status' => 'is_active', // optional
|
||||||
|
'active_values'=> ['active','1',1], // optional
|
||||||
|
'legacy' => 'md5' // optional: 'md5' | 'sha1' | 'plain' (sonst bcrypt/argon2)
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
'smtp' => [
|
||||||
|
'host' => 'smtp.example.com',
|
||||||
|
'port' => 587,
|
||||||
|
'user' => 'smtp-user',
|
||||||
|
'pass' => 'smtp-pass',
|
||||||
|
'secure' => 'tls', // oder 'ssl'
|
||||||
|
'from_email' => 'no-reply@example.com',
|
||||||
|
'from_name' => 'EmailTemplate',
|
||||||
|
],
|
||||||
|
'export' => [
|
||||||
|
'api_keys' => ['dev-key-123', 'noch-ein-key'], // füge hier deine Keys ein
|
||||||
|
],
|
||||||
|
'multi' => [
|
||||||
|
// Spalte in ALLEN Content-Tabellen, die dem Besitzer/Mandanten entspricht:
|
||||||
|
'tenant_col' => 'customer_id', // <— falls es bei dir z. B. 'owner_id' heißt: entsprechend anpassen.
|
||||||
|
// Welche Session-Info darauf gemappt wird:
|
||||||
|
'map_session_to' => 'id', // 'id' (Default) | 'email' | 'name'
|
||||||
|
],
|
||||||
|
|
||||||
|
// optional: abweichende Tabellennamen/Spalten:
|
||||||
|
'tables' => [
|
||||||
|
'templates' => 'emailtemplate_templates',
|
||||||
|
'sections' => 'emailtemplate_sections',
|
||||||
|
'blocks' => 'emailtemplate_blocks',
|
||||||
|
'snippets' => 'emailtemplate_snippets',
|
||||||
|
],
|
||||||
|
'columns' => [
|
||||||
|
// Nur anpassen, wenn deine Spaltennamen abweichen
|
||||||
|
'templates' => ['id'=>'id','name'=>'name','desc'=>null,'cat'=>null,'upd'=>'updated_at'],
|
||||||
|
'sections' => ['id'=>'id','name'=>'name','cat'=>null,'upd'=>'updated_at'],
|
||||||
|
'blocks' => ['id'=>'id','name'=>'name','cat'=>'category','upd'=>'updated_at'],
|
||||||
|
'snippets' => ['id'=>'id','name'=>'name','cat'=>'category','upd'=>'updated_at'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
525
public/api (Kopie).php
Normal file
525
public/api (Kopie).php
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 🚨 KRITISCHE STARTSEQUENZ 🚨
|
||||||
|
// Diese Blöcke MÜSSEN VOR jeder Ausgabe erfolgen.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// 1. Composer Autoload (Sie haben dies selbst eingebunden)
|
||||||
|
// ACHTUNG: Der Pfad wurde auf das Standard-Vendor-Verzeichnis korrigiert: 'vendor'
|
||||||
|
$composerAutoload = __DIR__ . '/../vendor/autoload.php';
|
||||||
|
if (is_file($composerAutoload)) {
|
||||||
|
require_once $composerAutoload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Session Start (Muss VOR dem Senden des Session-Cookies/Headers erfolgen)
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
/* ===================== Utilities ===================== */
|
||||||
|
function respond($data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
echo is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(string $msg, $detail = null, int $code = 400): void {
|
||||||
|
respond(['ok'=>false,'error'=>$msg,'detail'=>$detail], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function load_config(): array {
|
||||||
|
$paths = [
|
||||||
|
__DIR__ . '/../inc/config.php',
|
||||||
|
__DIR__ . '/inc/config.php',
|
||||||
|
__DIR__ . '/config.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($paths as $p) {
|
||||||
|
if (is_file($p)) {
|
||||||
|
$conf = @include $p;
|
||||||
|
if (is_array($conf)) return $conf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail('Invalid config.php', 'config.php not found or not returning array', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cors(array $conf): void {
|
||||||
|
$cors = $conf['cors'] ?? '*';
|
||||||
|
if ($cors) {
|
||||||
|
header('Access-Control-Allow-Origin: ' . $cors);
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
}
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') respond(['ok'=>true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_input(): array {
|
||||||
|
$data = [];
|
||||||
|
$ct = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||||
|
if (stripos($ct, 'application/json') !== false) {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw !== false && $raw !== '') {
|
||||||
|
$js = json_decode($raw, true);
|
||||||
|
if (is_array($js)) $data = $js;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($_POST as $k=>$v) $data[$k]=$v;
|
||||||
|
foreach ($_GET as $k=>$v) if (!array_key_exists($k,$data)) $data[$k]=$v;
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function val(array $in, $keys, $default=null) {
|
||||||
|
if (!is_array($keys)) $keys = [$keys];
|
||||||
|
foreach ($keys as $k) if (array_key_exists($k,$in)) return $in[$k];
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline-Styling für E-Mail-Templates (nutzt TijsVerkoyen/CssToInlineStyles)
|
||||||
|
*
|
||||||
|
* Diese Funktion steht nur bereit, wird aber noch nicht verwendet.
|
||||||
|
* @param string $html
|
||||||
|
* @param string|null $css
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function inline_css(string $html, ?string $css = null): string {
|
||||||
|
// 1. Klasse existiert nicht
|
||||||
|
if (!class_exists('\TijsVerkoyen\CssToInlineStyles\CssToInlineStyles')) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Direkte Instanziierung und Aufruf in einer einzigen Zeile
|
||||||
|
$result = (new \TijsVerkoyen\CssToInlineStyles\CssToInlineStyles($html, $css))->convert();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== DB helpers ===================== */
|
||||||
|
function pdo_templates(array $conf): PDO {
|
||||||
|
if (!isset($conf['templates']) || !is_array($conf['templates'])) {
|
||||||
|
fail('Missing templates DB config', null, 500);
|
||||||
|
}
|
||||||
|
$c = $conf['templates'];
|
||||||
|
$host = $c['db_host'] ?? 'localhost';
|
||||||
|
$db = $c['db_name'] ?? ($c['database'] ?? '');
|
||||||
|
$user = $c['db_user'] ?? ($c['username'] ?? '');
|
||||||
|
$pass = $c['db_pass'] ?? ($c['password'] ?? '');
|
||||||
|
$charset = $c['db_charset'] ?? 'utf8mb4';
|
||||||
|
$port = $c['db_port'] ?? 3306;
|
||||||
|
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=$charset";
|
||||||
|
$opt = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
];
|
||||||
|
return new PDO($dsn, $user, $pass, $opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify_password(string $input, string $stored, array $authDbConf): bool {
|
||||||
|
if (preg_match('~^\$2[aby]\$~', $stored) || strpos($stored, '$argon2') === 0) return password_verify($input, $stored);
|
||||||
|
$legacy = strtolower($authDbConf['legacy'] ?? '');
|
||||||
|
if ($legacy === 'md5') return hash_equals($stored, md5($input));
|
||||||
|
if ($legacy === 'sha1') return hash_equals($stored, sha1($input));
|
||||||
|
if ($legacy === 'plain')return hash_equals($stored, $input);
|
||||||
|
if (password_get_info($stored)['algo'] !== 0) return password_verify($input, $stored);
|
||||||
|
return hash_equals($stored, $input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ schema helpers ============ */
|
||||||
|
function table_columns(PDO $pdo, string $table): array {
|
||||||
|
$cols = [];
|
||||||
|
$stmt = $pdo->query("SHOW COLUMNS FROM `$table`");
|
||||||
|
foreach ($stmt->fetchAll() as $r) $cols[] = $r['Field'];
|
||||||
|
return $cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primary_key(PDO $pdo, string $table): ?string {
|
||||||
|
$stmt = $pdo->prepare("SHOW KEYS FROM `$table` WHERE Key_name = 'PRIMARY'");
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row['Column_name'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function first_existing(array $columns, array $candidates): ?string {
|
||||||
|
foreach ($candidates as $c) if (in_array($c, $columns, true)) return $c;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Boot ===================== */
|
||||||
|
try {
|
||||||
|
$conf = load_config();
|
||||||
|
cors($conf);
|
||||||
|
// if (session_status() === PHP_SESSION_NONE) session_start();
|
||||||
|
if (!empty($conf['auth']['cookie'])) {
|
||||||
|
$c = $conf['auth']['cookie'];
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
$params['lifetime'] = $c['lifetime'] ?? $params['lifetime'];
|
||||||
|
$params['path'] = $c['path'] ?? $params['path'];
|
||||||
|
$params['domain'] = $c['domain'] ?? $params['domain'];
|
||||||
|
$params['secure'] = $c['secure'] ?? $params['secure'];
|
||||||
|
$params['httponly'] = $c['httponly'] ?? $params['httponly'];
|
||||||
|
if (isset($c['samesite'])) $params['samesite'] = $c['samesite'];
|
||||||
|
session_set_cookie_params($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$in = get_input();
|
||||||
|
|
||||||
|
/* ---- Compat: ?resource=blocks&action=list -> blocks.list ---- */
|
||||||
|
$action = val($in, 'action', '');
|
||||||
|
$resource = val($in, 'resource', null);
|
||||||
|
$allowedResources = ['templates','sections','blocks','snippets'];
|
||||||
|
if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) {
|
||||||
|
$verb = strtolower((string)$action);
|
||||||
|
if (in_array($verb, ['list','get','create','update','delete'], true)) $action = $resource.'.'.$verb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Multi-tenant ---- */
|
||||||
|
$multi = $conf['multi'] ?? [];
|
||||||
|
$tenantCol = $multi['tenant_col'] ?? null;
|
||||||
|
$mapSess = $multi['map_session_to'] ?? 'id'; // 'id'|'email'|'name'
|
||||||
|
|
||||||
|
$tenantWhere = function(array $session) use ($tenantCol, $mapSess) {
|
||||||
|
if (!$tenantCol) return ['', []];
|
||||||
|
if (!$session) return [' AND 1=0 ', []];
|
||||||
|
$val = $session[$mapSess] ?? null;
|
||||||
|
if ($val===null || $val==='') return [' AND 1=0 ', []];
|
||||||
|
return [" AND `$tenantCol` = :__tenant", [':__tenant'=>$val]];
|
||||||
|
};
|
||||||
|
|
||||||
|
$tenantAssign = function(array $session, array $columns) use ($tenantCol, $mapSess) {
|
||||||
|
if (!$tenantCol || !in_array($tenantCol, $columns, true)) return [];
|
||||||
|
$val = $session[$mapSess] ?? null;
|
||||||
|
return ($val===null || $val==='') ? [] : [$tenantCol => $val];
|
||||||
|
};
|
||||||
|
|
||||||
|
$requireAuth = function() {
|
||||||
|
if (empty($_SESSION['auth'])) fail('Not authenticated', null, 401);
|
||||||
|
return $_SESSION['auth'];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- config tables/columns ---- */
|
||||||
|
$tables = $conf['tables'] ?? [];
|
||||||
|
$tableMap = [
|
||||||
|
'templates' => $tables['templates'] ?? 'emailtemplate_templates',
|
||||||
|
'sections' => $tables['sections'] ?? 'emailtemplate_sections',
|
||||||
|
'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks',
|
||||||
|
'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets',
|
||||||
|
];
|
||||||
|
|
||||||
|
$colsDefault = [
|
||||||
|
'id' => 'id',
|
||||||
|
'name' => 'name',
|
||||||
|
'desc' => 'description',
|
||||||
|
'cat' => 'category',
|
||||||
|
'upd' => 'updated_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
$pdo = pdo_templates($conf);
|
||||||
|
|
||||||
|
/* helper: resolve id column for a table */
|
||||||
|
$resolveIdCol = function(string $kind) use ($conf, $colsDefault, $tableMap, $pdo) {
|
||||||
|
$t = $tableMap[$kind];
|
||||||
|
$cfg = $conf['columns'][$kind] ?? [];
|
||||||
|
$cols = table_columns($pdo, $t);
|
||||||
|
$idCol = $cfg['id'] ?? (in_array('id', $cols, true) ? 'id' : primary_key($pdo, $t));
|
||||||
|
if (!$idCol) $idCol = 'id'; // fallback
|
||||||
|
return [$idCol, $cols];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* helper: accept many id aliases */
|
||||||
|
$pullId = function(array $src) {
|
||||||
|
$aliases = ['id','item_id','template_id','tpl_id','section_id','sec_id','block_id','blk_id','snippet_id','snip_id'];
|
||||||
|
foreach ($aliases as $a) if (isset($src[$a]) && $src[$a] !== '') return $src[$a];
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===================== Router ===================== */
|
||||||
|
switch ($action) {
|
||||||
|
case 'health':
|
||||||
|
respond(['ok'=>true,'time'=>date('c')]);
|
||||||
|
|
||||||
|
/* ---------- AUTH ---------- */
|
||||||
|
case 'auth.login': {
|
||||||
|
$identifier = trim((string)val($in, ['username','user','email','login'], ''));
|
||||||
|
$password = (string)val($in, ['password','pass','pwd'], '');
|
||||||
|
if ($identifier === '' || $password === '') fail('username/password required', null, 422);
|
||||||
|
$authDb = $conf['auth']['db'] ?? [];
|
||||||
|
$table = $authDb['table'] ?? 'emailtemplate_users';
|
||||||
|
$colUser = $authDb['col_user'] ?? 'email';
|
||||||
|
$colPass = $authDb['col_pass'] ?? 'password';
|
||||||
|
$colName = $authDb['col_name'] ?? 'name';
|
||||||
|
$colId = $authDb['col_id'] ?? 'id';
|
||||||
|
$colStatus = $authDb['col_status']?? null;
|
||||||
|
$activeValues = $authDb['active_values'] ?? ['active','1',1];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM `$table` WHERE `$colUser` = :u LIMIT 1");
|
||||||
|
|
||||||
|
$stmt->execute([':u'=>$identifier]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) fail('Invalid credentials', null, 401);
|
||||||
|
if ($colStatus && isset($row[$colStatus])) {
|
||||||
|
if (!in_array($row[$colStatus], $activeValues, true)) {
|
||||||
|
fail('Account inactive', null, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = (string)($row[$colPass] ?? '');
|
||||||
|
if ($stored === '' || !verify_password($password, $stored, $authDb)) {
|
||||||
|
fail('Invalid credentials', null, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['auth'] = [
|
||||||
|
'id' => $row[$colId] ?? null,
|
||||||
|
'name' => $row[$colName] ?? ($row[$colUser] ?? $identifier),
|
||||||
|
'email' => $row[$colUser] ?? $identifier,
|
||||||
|
'at' => time(),
|
||||||
|
];
|
||||||
|
$token = base64_encode(hash('sha256', ($_SESSION['auth']['id'] ?? $identifier).'|'.session_id(), true));
|
||||||
|
respond(['ok'=>true,'user'=>$_SESSION['auth'],'token'=>$token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'auth.me':
|
||||||
|
if (empty($_SESSION['auth'])) fail('Not authenticated', null, 401);
|
||||||
|
respond(['ok'=>true,'user'=>$_SESSION['auth']]);
|
||||||
|
case 'auth.logout':
|
||||||
|
$_SESSION = [];
|
||||||
|
if (session_id() !== '') session_destroy();
|
||||||
|
|
||||||
|
respond(['ok'=>true]);
|
||||||
|
|
||||||
|
/* ---------- LIST (mit Parent-Filtern) ---------- */
|
||||||
|
case 'templates.list':
|
||||||
|
case 'sections.list':
|
||||||
|
case 'blocks.list':
|
||||||
|
case 'snippets.list': {
|
||||||
|
$auth = $requireAuth();
|
||||||
|
$kind = explode('.', $action)[0];
|
||||||
|
$t = $tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $resolveIdCol($kind);
|
||||||
|
$cfg = $conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? (in_array('name',$allCols,true) ? 'name' : $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? first_existing($allCols, ['description','desc','descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? first_existing($allCols, ['category','cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? first_existing($allCols, ['updated_at','updated','updatedAt']);
|
||||||
|
$q = trim((string)val($in,'q',''));
|
||||||
|
$limit = max(1, (int)val($in,'limit', 500));
|
||||||
|
$offset = max(0, (int)val($in,'offset',0));
|
||||||
|
$where = ' WHERE 1=1 ';
|
||||||
|
$params = [];
|
||||||
|
if ($q !== '') {
|
||||||
|
$parts = ["`$nameCol` LIKE :q"];
|
||||||
|
if ($descCol) $parts[] = "`$descCol` LIKE :q";
|
||||||
|
if ($catCol) $parts[] = "`$catCol` LIKE :q";
|
||||||
|
$where .= " AND (".implode(' OR ', $parts).") ";
|
||||||
|
$params[':q'] = '%'.$q.'%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent-Filtern (falls Spalten existieren)
|
||||||
|
$parentFilters = [
|
||||||
|
'template_id' => val($in, ['template_id','tpl_id'], null),
|
||||||
|
'section_id' => val($in, ['section_id','sec_id'], null),
|
||||||
|
'block_id' => val($in, ['block_id','blk_id'], null),
|
||||||
|
];
|
||||||
|
foreach ($parentFilters as $col => $v) {
|
||||||
|
if ($v === null || $v === '') continue;
|
||||||
|
if (in_array($col, $allCols, true)) { $where .= " AND `$col` = :$col "; $params[":$col"] = $v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mandant
|
||||||
|
[$tw,$tp] = $tenantWhere($auth);
|
||||||
|
$where .= $tw; foreach ($tp as $k=>$v) $params[$k]=$v;
|
||||||
|
$order = $updCol ? " ORDER BY `$updCol` DESC " : " ORDER BY `$nameCol` ASC ";
|
||||||
|
$sql = "SELECT * FROM `$t` $where $order LIMIT :off,:lim";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
foreach ($params as $k=>$v) $stmt->bindValue($k,$v,is_int($v)?PDO::PARAM_INT:PDO::PARAM_STR);
|
||||||
|
$stmt->bindValue(':off',$offset,PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':lim',$limit,PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$item = [
|
||||||
|
'id' => $r[$idCol] ?? null,
|
||||||
|
'name' => $r[$nameCol] ?? null,
|
||||||
|
];
|
||||||
|
if ($descCol && isset($r[$descCol])) $item['desc'] = $r[$descCol];
|
||||||
|
if ($catCol && isset($r[$catCol])) $item['category'] = $r[$catCol];
|
||||||
|
if ($updCol && isset($r[$updCol])) $item['updated_at'] = $r[$updCol];
|
||||||
|
$out[] = $item;
|
||||||
|
}
|
||||||
|
respond(['ok'=>true,'kind'=>$kind,'items'=>$out,'data'=>$out,'count'=>count($out),'offset'=>$offset,'limit'=>$limit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- GET (JETZT mit top-level html/content) ---------- */
|
||||||
|
case 'templates.get':
|
||||||
|
case 'sections.get':
|
||||||
|
case 'blocks.get':
|
||||||
|
case 'snippets.get': {
|
||||||
|
$auth = $requireAuth();
|
||||||
|
$kind = explode('.', $action)[0];
|
||||||
|
$t = $tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $resolveIdCol($kind);
|
||||||
|
$id = $pullId($in);
|
||||||
|
if ($id === null || $id === '') fail('id required', null, 422);
|
||||||
|
[$tw,$tp] = $tenantWhere($auth);
|
||||||
|
$sql = "SELECT * FROM `$t` WHERE `$idCol` = :id".$tw." LIMIT 1";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->bindValue(':id', $id);
|
||||||
|
foreach ($tp as $k=>$v) $stmt->bindValue($k,$v);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
if (!$row) fail('Not found', ['kind'=>$kind,'id'=>$id], 404);
|
||||||
|
$rowOut = ['id' => $row[$idCol] ?? $id] + $row;
|
||||||
|
|
||||||
|
// NEU: Spalten für HTML/JSON erkennen und top-level ausgeben
|
||||||
|
$htmlCol = first_existing($allCols, ['html','body','markup']);
|
||||||
|
$jsonCol = first_existing($allCols, ['content_json','json','content','structure_json']);
|
||||||
|
$topHtml = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : null;
|
||||||
|
$topContent = ($jsonCol && isset($row[$jsonCol])) ? $row[$jsonCol] : null;
|
||||||
|
|
||||||
|
respond([
|
||||||
|
'ok'=>true,
|
||||||
|
'kind'=>$kind,
|
||||||
|
'id'=>$rowOut['id'],
|
||||||
|
'item'=>$rowOut,
|
||||||
|
'data'=>$rowOut,
|
||||||
|
'html'=>$topHtml, // <— wichtig für Vorschau
|
||||||
|
'content'=>$topContent // optional (z. B. GrapesJS JSON)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- CREATE ---------- */
|
||||||
|
case 'templates.create':
|
||||||
|
case 'sections.create':
|
||||||
|
case 'blocks.create':
|
||||||
|
case 'snippets.create': {
|
||||||
|
$auth = $requireAuth();
|
||||||
|
$kind = explode('.', $action)[0];
|
||||||
|
$t = $tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $resolveIdCol($kind);
|
||||||
|
$cfg = $conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? (in_array('name',$allCols,true) ? 'name' : $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? first_existing($allCols, ['description','desc','descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? first_existing($allCols, ['category','cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? first_existing($allCols, ['updated_at','updated','updatedAt']);
|
||||||
|
$name = trim((string)val($in, ['name','title'], ''));
|
||||||
|
if ($name === '') fail('name required', null, 422);
|
||||||
|
$desc = (string)val($in, ['description','desc'], null);
|
||||||
|
$cat = (string)val($in, ['category','cat'], null);
|
||||||
|
$html = (string)val($in, ['html','body','markup'], null);
|
||||||
|
$json = val($in, ['content_json','json','content','structure_json'], null);
|
||||||
|
$settings = val($in, ['settings_json','settings'], null);
|
||||||
|
$templateId = val($in, ['template_id','tpl_id'], null);
|
||||||
|
$sectionId = val($in, ['section_id','sec_id'], null);
|
||||||
|
$blockId = val($in, ['block_id','blk_id'], null);
|
||||||
|
$data = [ $nameCol => $name ];
|
||||||
|
if ($desc !== null && $descCol) $data[$descCol] = $desc;
|
||||||
|
if ($cat !== null && $catCol) $data[$catCol] = $cat;
|
||||||
|
$c = first_existing($allCols, ['html','body','markup']); if ($c && $html !== null) $data[$c]=$html;
|
||||||
|
$c = first_existing($allCols, ['content_json','json','content','structure_json']); if ($c && $json !== null) $data[$c]= is_string($json)?$json:json_encode($json, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
$c = first_existing($allCols, ['settings_json','settings']); if ($c && $settings !== null) $data[$c]= is_string($settings)?$settings:json_encode($settings, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($templateId !== null && in_array('template_id',$allCols,true)) $data['template_id']=$templateId;
|
||||||
|
if ($sectionId !== null && in_array('section_id',$allCols,true)) $data['section_id']=$sectionId;
|
||||||
|
if ($blockId !== null && in_array('block_id',$allCols,true)) $data['block_id']=$blockId;
|
||||||
|
$data = $data + $tenantAssign($_SESSION['auth'] ?? [], $allCols);
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$createdCol = first_existing($allCols, ['created_at','created','createdAt']);
|
||||||
|
if ($createdCol) $data[$createdCol] = $now;
|
||||||
|
if ($updCol) $data[$updCol] = $now;
|
||||||
|
$fields = array_keys($data);
|
||||||
|
$place = array_map(fn($c)=>":$c", $fields);
|
||||||
|
$sql = "INSERT INTO `$t` (".implode(',', array_map(fn($c)=>"`$c`",$fields)).") VALUES (".implode(',', $place).")";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
foreach ($data as $k=>$v) $stmt->bindValue(":$k", $v);
|
||||||
|
$stmt->execute();
|
||||||
|
$newId = $pdo->lastInsertId();
|
||||||
|
$out = ['id'=>$newId,'name'=>$name];
|
||||||
|
if ($desc !== null) $out['desc']=$desc;
|
||||||
|
if ($cat !== null) $out['category']=$cat;
|
||||||
|
respond(['ok'=>true,'kind'=>$kind,'id'=>$newId,'item'=>$out,'data'=>$out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- UPDATE ---------- */
|
||||||
|
case 'templates.update':
|
||||||
|
case 'sections.update':
|
||||||
|
case 'blocks.update':
|
||||||
|
case 'snippets.update': {
|
||||||
|
$auth = $requireAuth();
|
||||||
|
$kind = explode('.', $action)[0];
|
||||||
|
$t = $tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $resolveIdCol($kind);
|
||||||
|
$cfg = $conf['columns'][$kind] ?? [];
|
||||||
|
$nameCol = $cfg['name'] ?? (in_array('name',$allCols,true) ? 'name' : $idCol);
|
||||||
|
$descCol = $cfg['desc'] ?? first_existing($allCols, ['description','desc','descr']);
|
||||||
|
$catCol = $cfg['cat'] ?? first_existing($allCols, ['category','cat']);
|
||||||
|
$updCol = $cfg['upd'] ?? first_existing($allCols, ['updated_at','updated','updatedAt']);
|
||||||
|
$id = $pullId($in);
|
||||||
|
if ($id === null || $id === '') fail('id required', null, 422);
|
||||||
|
$data = [];
|
||||||
|
$name = val($in, ['name','title'], null);
|
||||||
|
$desc = val($in, ['description','desc'], null);
|
||||||
|
$cat = val($in, ['category','cat'], null);
|
||||||
|
$html = val($in, ['html','body','markup'], null);
|
||||||
|
$json = val($in, ['content_json','json','content','structure_json'], null);
|
||||||
|
$settings = val($in, ['settings_json','settings'], null);
|
||||||
|
if ($name !== null) $data[$nameCol] = (string)$name;
|
||||||
|
if ($desc !== null && $descCol) $data[$descCol] = (string)$desc;
|
||||||
|
if ($cat !== null && $catCol) $data[$catCol] = (string)$cat;
|
||||||
|
$c = first_existing($allCols, ['html','body','markup']); if ($html !== null && $c) $data[$c]=(string)$html;
|
||||||
|
$c = first_existing($allCols, ['content_json','json','content','structure_json']); if ($json !== null && $c) $data[$c]= is_string($json)?$json:json_encode($json, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
$c = first_existing($allCols, ['settings_json','settings']); if ($settings !== null && $c) $data[$c]= is_string($settings)?$settings:json_encode($settings, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
$tpl = val($in, ['template_id','tpl_id'], null); if ($tpl !== null && in_array('template_id',$allCols,true)) $data['template_id']=$tpl;
|
||||||
|
$sec = val($in, ['section_id','sec_id'], null); if ($sec !== null && in_array('section_id',$allCols,true)) $data['section_id']=$sec;
|
||||||
|
$blk = val($in, ['block_id','blk_id'], null); if ($blk !== null && in_array('block_id',$allCols,true)) $data['block_id']=$blk;
|
||||||
|
if ($updCol) $data[$updCol] = date('Y-m-d H:i:s');
|
||||||
|
if (!$data) fail('nothing to update', null, 422);
|
||||||
|
[$tw,$tp] = $tenantWhere($auth);
|
||||||
|
$set = []; foreach (array_keys($data) as $c) $set[] = "`$c` = :$c";
|
||||||
|
$sql = "UPDATE `$t` SET ".implode(',',$set)." WHERE `$idCol` = :__id".$tw." LIMIT 1";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
foreach ($data as $k=>$v) $stmt->bindValue(":$k", $v);
|
||||||
|
$stmt->bindValue(':__id', $id);
|
||||||
|
foreach ($tp as $k=>$v) $stmt->bindValue($k,$v);
|
||||||
|
$stmt->execute();
|
||||||
|
respond(['ok'=>true,'kind'=>$kind,'id'=>$id,'updated'=>array_keys($data)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- DELETE ---------- */
|
||||||
|
case 'templates.delete':
|
||||||
|
case 'sections.delete':
|
||||||
|
case 'blocks.delete':
|
||||||
|
case 'snippets.delete': {
|
||||||
|
$auth = $requireAuth();
|
||||||
|
$kind = explode('.', $action)[0];
|
||||||
|
$t = $tableMap[$kind];
|
||||||
|
[$idCol, $allCols] = $resolveIdCol($kind);
|
||||||
|
$id = $pullId($in);
|
||||||
|
if ($id === null || $id === '') fail('id required', null, 422);
|
||||||
|
[$tw,$tp] = $tenantWhere($auth);
|
||||||
|
$sql = "DELETE FROM `$t` WHERE `$idCol` = :__id".$tw." LIMIT 1";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->bindValue(':__id', $id);
|
||||||
|
foreach ($tp as $k=>$v) $stmt->bindValue($k,$v);
|
||||||
|
$stmt->execute();
|
||||||
|
respond(['ok'=>true,'kind'=>$kind,'id'=>$id,'deleted'=>true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Platzhalter für kommende Features ---------- */
|
||||||
|
case 'render.preview':
|
||||||
|
fail('Not implemented', 'Preview wird im Feature-Patch geliefert', 501);
|
||||||
|
case 'templates.test_send':
|
||||||
|
fail('Not implemented', 'Testversand wird im Feature-Patch geliefert', 501);
|
||||||
|
case 'export.render':
|
||||||
|
fail('Not implemented', 'Export-API wird im Feature-Patch geliefert', 501);
|
||||||
|
default:
|
||||||
|
fail('Unknown action', $action ?: 'missing', 404);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
fail('Server error', get_class($e).': '.$e->getMessage(), 500);
|
||||||
|
}
|
||||||
1017
public/api-original.php
Normal file
1017
public/api-original.php
Normal file
File diff suppressed because it is too large
Load Diff
15
public/api.php
Normal file
15
public/api.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 1. Composer Autoload (Falls nicht schon im Webserver-Setup enthalten)
|
||||||
|
$composerAutoload = __DIR__ . '/../vendor/autoload.php';
|
||||||
|
if (is_file($composerAutoload)) {
|
||||||
|
require_once $composerAutoload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lade die Service-Klasse (API-Kernel)
|
||||||
|
require_once __DIR__ . '/../inc/ApiKernel.php';
|
||||||
|
|
||||||
|
// 3. Erstelle eine Instanz und führe sie aus
|
||||||
|
$api = new ApiKernel();
|
||||||
|
$api->run();
|
||||||
492
public/api.php.txt
Normal file
492
public/api.php.txt
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
<?php
|
||||||
|
// api.php — Multi-User API mit Legacy-Kompatibilität
|
||||||
|
// Stand: 2025-09-05
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
header('X-API-Version: 2025-09-05');
|
||||||
|
|
||||||
|
/* ------------------------- Fehler als JSON ------------------------- */
|
||||||
|
set_error_handler(static function ($severity, $message, $file, $line) {
|
||||||
|
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||||
|
});
|
||||||
|
set_exception_handler(static function (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal',
|
||||||
|
'type' => get_class($e),
|
||||||
|
'msg' => $e->getMessage(),
|
||||||
|
'file' => basename($e->getFile()),
|
||||||
|
'line' => $e->getLine(),
|
||||||
|
], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------- Helpers ------------------------- */
|
||||||
|
function json_out(array $data, int $code = 200): void {
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
function in_json(): array {
|
||||||
|
$raw = file_get_contents('php://input') ?: '';
|
||||||
|
if ($raw === '') return [];
|
||||||
|
$j = json_decode($raw, true);
|
||||||
|
return is_array($j) ? $j : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Config laden ------------------------- */
|
||||||
|
// Parent /inc/config.php (wie von dir beschrieben)
|
||||||
|
$cfgFile = dirname(__DIR__) . '/inc/config.php';
|
||||||
|
if (!is_file($cfgFile)) {
|
||||||
|
json_out(['ok'=>false,'error'=>'config_missing','hint'=>'config.php nicht gefunden','path'=>$cfgFile], 500);
|
||||||
|
}
|
||||||
|
$CFG = include $cfgFile;
|
||||||
|
if (!is_array($CFG)) {
|
||||||
|
json_out(['ok'=>false,'error'=>'config_invalid','hint'=>'config.php muss ein Array zurückgeben'], 500);
|
||||||
|
}
|
||||||
|
$ENV = $CFG['env'] ?? 'prod';
|
||||||
|
|
||||||
|
/* ------------------------- PDO-Factories ------------------------- */
|
||||||
|
function pdo_from_cfg(?array $dbc): ?PDO {
|
||||||
|
if (!$dbc) return null;
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;dbname=%s;charset=%s',
|
||||||
|
$dbc['db_host'] ?? 'localhost',
|
||||||
|
$dbc['db_name'] ?? '',
|
||||||
|
$dbc['db_charset']?? 'utf8mb4'
|
||||||
|
);
|
||||||
|
return new PDO($dsn, $dbc['db_user'] ?? '', $dbc['db_pass'] ?? '', [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$pdoTpl = pdo_from_cfg($CFG['templates'] ?? null); // Template-Daten
|
||||||
|
$pdoCust = pdo_from_cfg(($CFG['customers'] ?? null) ?: ($CFG['templates'] ?? null)); // Kunden/Users
|
||||||
|
|
||||||
|
$TPL_DB = $CFG['templates']['db_name'] ?? null; // für information_schema
|
||||||
|
|
||||||
|
function has_column(PDO $pdo, ?string $db, string $table, string $col): bool {
|
||||||
|
if (!$db) return false;
|
||||||
|
$st = $pdo->prepare("SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA=:db AND TABLE_NAME=:t AND COLUMN_NAME=:c
|
||||||
|
LIMIT 1");
|
||||||
|
$st->execute([':db'=>$db, ':t'=>$table, ':c'=>$col]);
|
||||||
|
return (bool)$st->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Auth (Helper oder Fallback) ------------------------- */
|
||||||
|
$authFile = dirname(__DIR__) . '/inc/auth_helpers.php';
|
||||||
|
$useHelpers = is_file($authFile);
|
||||||
|
if ($useHelpers) {
|
||||||
|
require_once $authFile; // stellt auth_start_session(), auth_require(), auth_logout(), require_role() bereit
|
||||||
|
} else {
|
||||||
|
// interner Fallback – kompatibel zu deinen Erwartungen
|
||||||
|
function auth_start_session(array $CFG): void {
|
||||||
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
session_set_cookie_params([
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
'secure' => $secure,
|
||||||
|
'path' => rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/'
|
||||||
|
]);
|
||||||
|
session_name('et_session');
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
}
|
||||||
|
function auth_require(array $CFG): void {
|
||||||
|
auth_start_session($CFG);
|
||||||
|
if (empty($_SESSION['user'])) {
|
||||||
|
json_out(['ok'=>false,'error'=>'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function auth_logout(array $CFG): void {
|
||||||
|
auth_start_session($CFG);
|
||||||
|
$_SESSION = [];
|
||||||
|
if (ini_get('session.use_cookies')) {
|
||||||
|
$p = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time()-42000, $p['path'], $p['domain'] ?? '', $p['secure'], $p['httponly']);
|
||||||
|
}
|
||||||
|
session_destroy();
|
||||||
|
}
|
||||||
|
function require_role(array $CFG, array $roles): void {
|
||||||
|
auth_start_session($CFG);
|
||||||
|
$r = $_SESSION['user']['role'] ?? null;
|
||||||
|
if (!$r || !in_array($r, $roles, true)) json_out(['ok'=>false,'error'=>'forbidden'], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Routing + Legacy-Mapping ------------------------- */
|
||||||
|
$action = $_GET['action'] ?? $_POST['action'] ?? null;
|
||||||
|
|
||||||
|
// Alt: ?resource=blocks&action=list|get|create|update|delete|sync
|
||||||
|
if (!empty($_GET['resource'])) {
|
||||||
|
$res = (string)$_GET['resource'];
|
||||||
|
$act = (string)($_GET['action'] ?? '');
|
||||||
|
$allowed = ['templates','sections','blocks','snippets','assets','template_items','section_items'];
|
||||||
|
if (in_array($res, $allowed, true)) {
|
||||||
|
if ($act === 'list') $action = $res.'.list';
|
||||||
|
if ($act === 'get') $action = $res.'.get';
|
||||||
|
if ($act === 'create') $action = $res.'.create';
|
||||||
|
if ($act === 'update') $action = $res.'.update';
|
||||||
|
if ($act === 'delete') $action = $res.'.delete';
|
||||||
|
if ($act === 'sync') $action = $res.'.sync';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Meta/Health ------------------------- */
|
||||||
|
if ($action === 'health' || $action === 'ping') json_out(['ok'=>true,'env'=>$ENV,'time'=>date('c')]);
|
||||||
|
if ($action === 'version') json_out(['ok'=>true,'version'=>'2025-09-05','env'=>$ENV]);
|
||||||
|
|
||||||
|
/* ------------------------- Diagnose (leichtgewichtig) ------------------------- */
|
||||||
|
if ($action === 'debug.diag') {
|
||||||
|
$diag = [
|
||||||
|
'php' => PHP_VERSION,
|
||||||
|
'pdo' => extension_loaded('pdo'),
|
||||||
|
'pdo_mysql'=> extension_loaded('pdo_mysql'),
|
||||||
|
'cfg' => ['templates'=>!!$pdoTpl, 'customers'=>!!$pdoCust],
|
||||||
|
];
|
||||||
|
json_out(['ok'=>true,'diag'=>$diag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- STAGING: User-Debug ------------------------- */
|
||||||
|
if (in_array($action, ['debug.users','debug.users.check','debug.users.setpass','debug.users.peek'], true)) {
|
||||||
|
if ($ENV !== 'staging') json_out(['ok'=>false,'error'=>'forbidden'], 403);
|
||||||
|
if (!$pdoCust) json_out(['ok'=>false,'error'=>'customers_db_not_configured'], 500);
|
||||||
|
|
||||||
|
if ($action === 'debug.users') {
|
||||||
|
$email = isset($_GET['email']) ? trim((string)$_GET['email']) : '';
|
||||||
|
if ($email !== '') {
|
||||||
|
$st = $pdoCust->prepare("SELECT id, customer_id, email, role, is_active, created_at, updated_at
|
||||||
|
FROM customer_users WHERE email=:email");
|
||||||
|
$st->execute([':email'=>$email]);
|
||||||
|
$rows = $st->fetchAll();
|
||||||
|
} else {
|
||||||
|
$st = $pdoCust->query("SELECT id, customer_id, email, role, is_active, created_at, updated_at
|
||||||
|
FROM customer_users ORDER BY id DESC LIMIT 50");
|
||||||
|
$rows = $st->fetchAll();
|
||||||
|
}
|
||||||
|
json_out(['ok'=>true,'items'=>$rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'debug.users.check') {
|
||||||
|
$in = in_json();
|
||||||
|
$email = trim((string)($in['email'] ?? ''));
|
||||||
|
$pass = (string)($in['password'] ?? '');
|
||||||
|
if ($email==='' || $pass==='') json_out(['ok'=>false,'error'=>'missing_params'], 400);
|
||||||
|
$st = $pdoCust->prepare("SELECT id, customer_id, email, password_hash, role, is_active FROM customer_users
|
||||||
|
WHERE email=:email LIMIT 1");
|
||||||
|
$st->execute([':email'=>$email]);
|
||||||
|
$u = $st->fetch();
|
||||||
|
if (!$u) json_out(['ok'=>true,'exists'=>false,'password_match'=>false]);
|
||||||
|
json_out(['ok'=>true,'exists'=>true,'password_match'=>password_verify($pass,$u['password_hash'])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'debug.users.setpass') {
|
||||||
|
$in = in_json();
|
||||||
|
$email = trim((string)($in['email'] ?? ''));
|
||||||
|
$pass = (string)($in['password'] ?? '');
|
||||||
|
if ($email==='' || $pass==='') json_out(['ok'=>false,'error'=>'missing_params'], 400);
|
||||||
|
$st = $pdoCust->prepare("SELECT id FROM customer_users WHERE email=:email LIMIT 1");
|
||||||
|
$st->execute([':email'=>$email]);
|
||||||
|
$u = $st->fetch();
|
||||||
|
if (!$u) json_out(['ok'=>false,'error'=>'user_not_found'], 404);
|
||||||
|
$hash = password_hash($pass, PASSWORD_DEFAULT);
|
||||||
|
$upd = $pdoCust->prepare("UPDATE customer_users SET password_hash=:h, is_active=1 WHERE id=:id");
|
||||||
|
$upd->execute([':h'=>$hash, ':id'=>$u['id']]);
|
||||||
|
json_out(['ok'=>true,'set'=>true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'debug.users.peek') {
|
||||||
|
$email = isset($_GET['email']) ? trim((string)$_GET['email']) : '';
|
||||||
|
if ($email==='') json_out(['ok'=>false,'error'=>'missing_email'], 400);
|
||||||
|
$st = $pdoCust->prepare("SELECT id, customer_id, email, LENGTH(password_hash) len
|
||||||
|
FROM customer_users WHERE email=:email");
|
||||||
|
$st->execute([':email'=>$email]);
|
||||||
|
json_out(['ok'=>true,'user'=>$st->fetch()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- AUTH: Login / Logout / Me ------------------------- */
|
||||||
|
if ($action === 'auth.login') {
|
||||||
|
$in = in_json();
|
||||||
|
$email = trim(strtolower((string)($in['email'] ?? '')));
|
||||||
|
$pass = (string)($in['password'] ?? '');
|
||||||
|
if ($email==='' || $pass==='') json_out(['ok'=>false,'error'=>'missing_credentials'], 400);
|
||||||
|
if (!$pdoCust) json_out(['ok'=>false,'error'=>'customers_db_not_configured'], 500);
|
||||||
|
|
||||||
|
// Mehrfachkunden mit gleicher Mail erlauben → best match über password_verify
|
||||||
|
$st = $pdoCust->prepare("SELECT cu.id, cu.customer_id, cu.email, cu.password_hash, cu.role, cu.is_active,
|
||||||
|
c.slug AS customer_slug, c.plan, c.status
|
||||||
|
FROM customer_users cu
|
||||||
|
JOIN customers c ON c.id = cu.customer_id
|
||||||
|
WHERE cu.email=:email");
|
||||||
|
$st->execute([':email'=>$email]);
|
||||||
|
$rows = $st->fetchAll();
|
||||||
|
|
||||||
|
$match = null;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ((int)$r['is_active'] === 1 && !empty($r['password_hash']) && password_verify($pass, $r['password_hash'])) {
|
||||||
|
$match = $r; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$match) json_out(['ok'=>false,'error'=>'invalid_credentials'], 401);
|
||||||
|
if (($match['status'] ?? 'active') !== 'active') json_out(['ok'=>false,'error'=>'customer_inactive'], 403);
|
||||||
|
|
||||||
|
auth_start_session($CFG);
|
||||||
|
$_SESSION['user'] = [
|
||||||
|
'id' => (int)$match['id'],
|
||||||
|
'email' => $match['email'],
|
||||||
|
'role' => $match['role'],
|
||||||
|
'customer_id' => (int)$match['customer_id'],
|
||||||
|
'customer_slug' => $match['customer_slug'],
|
||||||
|
'plan' => $match['plan'] ?? null,
|
||||||
|
];
|
||||||
|
json_out(['ok'=>true,'user'=>$_SESSION['user']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'auth.logout') { auth_logout($CFG); json_out(['ok'=>true]); }
|
||||||
|
|
||||||
|
if ($action === 'auth.me') {
|
||||||
|
auth_start_session($CFG);
|
||||||
|
json_out(['ok'=>!empty($_SESSION['user']), 'user'=>$_SESSION['user'] ?? null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- ab hier: geschützt ------------------------- */
|
||||||
|
$public = ['auth.login','auth.logout','auth.me','health','ping','version','debug.diag','debug.users','debug.users.check','debug.users.setpass','debug.users.peek'];
|
||||||
|
if (!in_array($action, $public, true)) auth_require($CFG);
|
||||||
|
|
||||||
|
$customerId = (int)($_SESSION['user']['customer_id'] ?? 0);
|
||||||
|
|
||||||
|
/* ------------------------- Templates ------------------------- */
|
||||||
|
if ($action === 'templates.list') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$st = $pdoTpl->prepare("SELECT id, name, updated_at
|
||||||
|
FROM emailtemplate_templates
|
||||||
|
WHERE customer_id = :cid
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 1000");
|
||||||
|
$st->execute([':cid'=>$customerId]);
|
||||||
|
json_out(['ok'=>true,'items'=>$st->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'templates.get') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0);
|
||||||
|
if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400);
|
||||||
|
$hasHtml = has_column($pdoTpl, $TPL_DB, 'emailtemplate_templates', 'html');
|
||||||
|
$cols = $hasHtml ? "id, customer_id, name, html, updated_at" : "id, customer_id, name, NULL AS html, updated_at";
|
||||||
|
$st = $pdoTpl->prepare("SELECT $cols FROM emailtemplate_templates WHERE id=:id AND customer_id=:cid LIMIT 1");
|
||||||
|
$st->execute([':id'=>$id, ':cid'=>$customerId]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if (!$row) json_out(['ok'=>false,'error'=>'not_found'], 404);
|
||||||
|
json_out(['ok'=>true,'item'=>$row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'templates.create') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$in = in_json();
|
||||||
|
$name = trim((string)($in['name'] ?? ''));
|
||||||
|
$html = (string)($in['html'] ?? '');
|
||||||
|
if ($name==='') json_out(['ok'=>false,'error'=>'name_required'], 400);
|
||||||
|
$hasHtml = has_column($pdoTpl, $TPL_DB, 'emailtemplate_templates', 'html');
|
||||||
|
|
||||||
|
if ($hasHtml) {
|
||||||
|
$st = $pdoTpl->prepare("INSERT INTO emailtemplate_templates (customer_id,name,html,created_at,updated_at)
|
||||||
|
VALUES (:cid,:name,:html,NOW(),NOW())");
|
||||||
|
$st->execute([':cid'=>$customerId, ':name'=>$name, ':html'=>$html]);
|
||||||
|
} else {
|
||||||
|
$st = $pdoTpl->prepare("INSERT INTO emailtemplate_templates (customer_id,name,created_at,updated_at)
|
||||||
|
VALUES (:cid,:name,NOW(),NOW())");
|
||||||
|
$st->execute([':cid'=>$customerId, ':name'=>$name]);
|
||||||
|
}
|
||||||
|
json_out(['ok'=>true,'id'=>(int)$pdoTpl->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'templates.update') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$in = in_json();
|
||||||
|
$id = (int)($in['id'] ?? 0);
|
||||||
|
$name = array_key_exists('name',$in) ? trim((string)$in['name']) : null;
|
||||||
|
$html = array_key_exists('html',$in) ? (string)$in['html'] : null;
|
||||||
|
if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400);
|
||||||
|
|
||||||
|
$hasHtml = has_column($pdoTpl, $TPL_DB, 'emailtemplate_templates', 'html');
|
||||||
|
$sets=[]; $p=[':id'=>$id, ':cid'=>$customerId];
|
||||||
|
if ($name!==null) { $sets[]="name=:name"; $p[':name']=$name; }
|
||||||
|
if ($hasHtml && $html!==null) { $sets[]="html=:html"; $p[':html']=$html; }
|
||||||
|
if (!$sets) json_out(['ok'=>false,'error'=>'nothing_to_update'], 400);
|
||||||
|
|
||||||
|
$sql = "UPDATE emailtemplate_templates SET ".implode(',',$sets).", updated_at=NOW() WHERE id=:id AND customer_id=:cid";
|
||||||
|
$st = $pdoTpl->prepare($sql);
|
||||||
|
$st->execute($p);
|
||||||
|
json_out(['ok'=>true,'updated'=>$st->rowCount()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'templates.delete') {
|
||||||
|
require_role($CFG, ['owner','admin']);
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$in = in_json(); $id=(int)($in['id'] ?? 0);
|
||||||
|
if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400);
|
||||||
|
$pdoTpl->beginTransaction();
|
||||||
|
try {
|
||||||
|
$pdoTpl->prepare("DELETE FROM emailtemplate_template_items WHERE template_id=:id AND customer_id=:cid")->execute([':id'=>$id, ':cid'=>$customerId]);
|
||||||
|
$pdoTpl->prepare("DELETE FROM emailtemplate_templates WHERE id=:id AND customer_id=:cid")->execute([':id'=>$id, ':cid'=>$customerId]);
|
||||||
|
$pdoTpl->commit();
|
||||||
|
json_out(['ok'=>true]);
|
||||||
|
} catch (Throwable $e) { $pdoTpl->rollBack(); throw $e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Sections ------------------------- */
|
||||||
|
if ($action === 'sections.list') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$templateId = isset($_GET['template_id']) ? (int)$_GET['template_id'] : 0;
|
||||||
|
if ($templateId>0) {
|
||||||
|
$st = $pdoTpl->prepare("SELECT id, template_id, name, z_index, type, updated_at
|
||||||
|
FROM emailtemplate_sections
|
||||||
|
WHERE customer_id=:cid AND template_id=:tid
|
||||||
|
ORDER BY z_index ASC, id ASC
|
||||||
|
LIMIT 1000");
|
||||||
|
$st->execute([':cid'=>$customerId, ':tid'=>$templateId]);
|
||||||
|
} else {
|
||||||
|
$st = $pdoTpl->prepare("SELECT id, template_id, name, z_index, type, updated_at
|
||||||
|
FROM emailtemplate_sections
|
||||||
|
WHERE customer_id=:cid
|
||||||
|
ORDER BY template_id ASC, z_index ASC, id ASC
|
||||||
|
LIMIT 1000");
|
||||||
|
$st->execute([':cid'=>$customerId]);
|
||||||
|
}
|
||||||
|
json_out(['ok'=>true,'items'=>$st->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'sections.get') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0);
|
||||||
|
if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400);
|
||||||
|
$st = $pdoTpl->prepare("SELECT id, customer_id, template_id, name, z_index, type, updated_at
|
||||||
|
FROM emailtemplate_sections
|
||||||
|
WHERE id=:id AND customer_id=:cid
|
||||||
|
LIMIT 1");
|
||||||
|
$st->execute([':id'=>$id, ':cid'=>$customerId]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if (!$row) json_out(['ok'=>false,'error'=>'not_found'], 404);
|
||||||
|
json_out(['ok'=>true,'item'=>$row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Blocks ------------------------- */
|
||||||
|
if ($action === 'blocks.list') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$st = $pdoTpl->prepare("SELECT id, name, category, updated_at
|
||||||
|
FROM emailtemplate_blocks
|
||||||
|
WHERE customer_id=:cid
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT 1000");
|
||||||
|
$st->execute([':cid'=>$customerId]);
|
||||||
|
json_out(['ok'=>true,'items'=>$st->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'blocks.get') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0);
|
||||||
|
if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400);
|
||||||
|
$st = $pdoTpl->prepare("SELECT id, customer_id, name, category, updated_at
|
||||||
|
FROM emailtemplate_blocks
|
||||||
|
WHERE id=:id AND customer_id=:cid
|
||||||
|
LIMIT 1");
|
||||||
|
$st->execute([':id'=>$id, ':cid'=>$customerId]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if (!$row) json_out(['ok'=>false,'error'=>'not_found'], 404);
|
||||||
|
json_out(['ok'=>true,'item'=>$row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'blocks.create') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$in=in_json(); $name=trim((string)($in['name']??'')); $cat=trim((string)($in['category']??''));
|
||||||
|
if ($name==='') json_out(['ok'=>false,'error'=>'name_required'], 400);
|
||||||
|
$st=$pdoTpl->prepare("INSERT INTO emailtemplate_blocks (customer_id,name,category,created_at,updated_at)
|
||||||
|
VALUES (:cid,:name,COALESCE(NULLIF(:cat,''),'Default'),NOW(),NOW())");
|
||||||
|
$st->execute([':cid'=>$customerId, ':name'=>$name, ':cat'=>$cat]);
|
||||||
|
json_out(['ok'=>true,'id'=>(int)$pdoTpl->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'blocks.update') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$in=in_json(); $id=(int)($in['id']??0); $name=trim((string)($in['name']??'')); $cat=trim((string)($in['category']??''));
|
||||||
|
if ($id<=0 || $name==='') json_out(['ok'=>false,'error'=>'invalid_params'], 400);
|
||||||
|
$st=$pdoTpl->prepare("UPDATE emailtemplate_blocks
|
||||||
|
SET name=:name, category=COALESCE(NULLIF(:cat,''),category), updated_at=NOW()
|
||||||
|
WHERE id=:id AND customer_id=:cid");
|
||||||
|
$st->execute([':name'=>$name, ':cat'=>$cat, ':id'=>$id, ':cid'=>$customerId]);
|
||||||
|
json_out(['ok'=>true,'updated'=>$st->rowCount()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'blocks.delete') {
|
||||||
|
require_role($CFG, ['owner','admin']);
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$in=in_json(); $id=(int)($in['id']??0);
|
||||||
|
if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400);
|
||||||
|
$st=$pdoTpl->prepare("DELETE FROM emailtemplate_blocks WHERE id=:id AND customer_id=:cid");
|
||||||
|
$st->execute([':id'=>$id, ':cid'=>$customerId]);
|
||||||
|
json_out(['ok'=>true,'deleted'=>$st->rowCount()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Snippets ------------------------- */
|
||||||
|
if ($action === 'snippets.list') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$st=$pdoTpl->prepare("SELECT id, name, category, updated_at
|
||||||
|
FROM emailtemplate_snippets
|
||||||
|
WHERE customer_id=:cid
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT 1000");
|
||||||
|
$st->execute([':cid'=>$customerId]);
|
||||||
|
json_out(['ok'=>true,'items'=>$st->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'snippets.get') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : (int)($_POST['id'] ?? 0);
|
||||||
|
if ($id<=0) json_out(['ok'=>false,'error'=>'missing_id'], 400);
|
||||||
|
$st=$pdoTpl->prepare("SELECT id, customer_id, name, category, updated_at
|
||||||
|
FROM emailtemplate_snippets
|
||||||
|
WHERE id=:id AND customer_id=:cid
|
||||||
|
LIMIT 1");
|
||||||
|
$st->execute([':id'=>$id, ':cid'=>$customerId]);
|
||||||
|
$row=$st->fetch();
|
||||||
|
if (!$row) json_out(['ok'=>false,'error'=>'not_found'], 404);
|
||||||
|
json_out(['ok'=>true,'item'=>$row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Assets (READ) ------------------------- */
|
||||||
|
if ($action === 'assets.list') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$st=$pdoTpl->prepare("SELECT id, name, type, mime_type, size_bytes, public_url, updated_at
|
||||||
|
FROM emailtemplate_assets
|
||||||
|
WHERE customer_id=:cid
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 200");
|
||||||
|
$st->execute([':cid'=>$customerId]);
|
||||||
|
json_out(['ok'=>true,'items'=>$st->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Editor-Referenzen (Placeholders) ------------------------- */
|
||||||
|
if ($action === 'template_items.sync') { json_out(['ok'=>true]); }
|
||||||
|
if ($action === 'section_items.sync') { json_out(['ok'=>true]); }
|
||||||
|
|
||||||
|
/* ------------------------- Render (Fallback) ------------------------- */
|
||||||
|
if ($action === 'render.preview') {
|
||||||
|
if (!$pdoTpl) json_out(['ok'=>false,'error'=>'templates_db_not_configured'], 500);
|
||||||
|
$in = in_json();
|
||||||
|
$templateId = (int)($in['template_id'] ?? 0);
|
||||||
|
if ($templateId<=0) json_out(['ok'=>false,'error'=>'template_id_required'], 400);
|
||||||
|
$st = $pdoTpl->prepare("SELECT id, name FROM emailtemplate_templates WHERE id=:id AND customer_id=:cid LIMIT 1");
|
||||||
|
$st->execute([':id'=>$templateId, ':cid'=>$customerId]);
|
||||||
|
$tpl = $st->fetch();
|
||||||
|
if (!$tpl) json_out(['ok'=>false,'error'=>'not_found'], 404);
|
||||||
|
$html = "<!-- preview {$tpl['name']} (#{$tpl['id']}) -->\n<div style=\"padding:16px;font:14px/1.4 system-ui\">Preview okay.</div>";
|
||||||
|
json_out(['ok'=>true, 'template'=>$tpl, 'html'=>$html]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- Fallback ------------------------- */
|
||||||
|
json_out(['ok'=>false,'error'=>'unknown_action','action'=>$action], 404);
|
||||||
|
|
||||||
167
public/assets/css/admin.css
Normal file
167
public/assets/css/admin.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Admin Theme (SCOPED)
|
||||||
|
- wirkt NUR unter <body class="page-admin"> bzw. .page-login
|
||||||
|
- keine globalen Resets (html/body/a/…)
|
||||||
|
- GrapesJS (gjs-…) wird nicht angetastet
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---- Farb- & UI-Variablen ---- */
|
||||||
|
body.page-admin,
|
||||||
|
body.page-login {
|
||||||
|
--bg: #f7f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #222222;
|
||||||
|
--muted: #666666;
|
||||||
|
--border: #e7e7ee;
|
||||||
|
--accent: #5b7cff;
|
||||||
|
--accent-600:#3f5ff7;
|
||||||
|
--danger: #e74c3c;
|
||||||
|
--ok: #2ecc71;
|
||||||
|
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Links / Text ---- */
|
||||||
|
.page-admin a,
|
||||||
|
.page-login a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.page-admin .muted,
|
||||||
|
.page-login .muted { color: var(--muted); }
|
||||||
|
|
||||||
|
/* ---- Container ---- */
|
||||||
|
.page-admin .container { max-width: 1200px; margin: 20px auto; padding: 0 16px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
ADMIN (Dashboard, Listen, Editor-Rahmen)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---- Topbar ---- */
|
||||||
|
.page-admin .topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 10px; padding: 10px 16px; background: rgba(255,255,255,.9);
|
||||||
|
backdrop-filter: saturate(140%) blur(6px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky; top: 0; z-index: 50;
|
||||||
|
}
|
||||||
|
.page-admin .brand { font-size: 16px; margin: 0; font-weight: 600; }
|
||||||
|
.page-admin .topbar .right { display: flex; gap: 8px; align-items: center; }
|
||||||
|
|
||||||
|
/* ---- Buttons (leicht, neutral) ---- */
|
||||||
|
.page-admin .btn {
|
||||||
|
display:inline-flex; align-items:center; gap:.5rem;
|
||||||
|
padding:.38rem .75rem; border-radius:.7rem;
|
||||||
|
border:1px solid var(--border); background:#fff;
|
||||||
|
font-size:.9rem; cursor:pointer; transition:.15s background-color, .15s border-color, .15s box-shadow;
|
||||||
|
}
|
||||||
|
.page-admin .btn:hover { background:#f8fafc; }
|
||||||
|
.page-admin .btn-primary { border-color: var(--accent); background: var(--accent); color:#fff; }
|
||||||
|
.page-admin .btn-primary:hover { background: var(--accent-600); border-color: var(--accent-600); }
|
||||||
|
.page-admin .btn-danger { border-color:#fecaca; color:#b91c1c; }
|
||||||
|
.page-admin .btn-danger:hover { background:#fef2f2; }
|
||||||
|
|
||||||
|
/* ---- Chips ---- */
|
||||||
|
.page-admin .chip {
|
||||||
|
display:inline-flex; align-items:center; gap:.35rem;
|
||||||
|
padding:.15rem .55rem; border-radius:999px;
|
||||||
|
background:#f1f5f9; color:#334155; font-size:.75rem; border:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.page-admin .chip .dot { width:.5rem; height:.5rem; border-radius:999px; background:#64748b; }
|
||||||
|
|
||||||
|
/* ---- Cards & Rows ---- */
|
||||||
|
.page-admin .card {
|
||||||
|
background: var(--panel); border:1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 16px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.page-admin .row {
|
||||||
|
display:flex; gap:12px; align-items:center; justify-content:space-between; flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.page-admin .row .left,
|
||||||
|
.page-admin .row .right { display:flex; gap:10px; align-items:center; }
|
||||||
|
|
||||||
|
/* ---- Tabs (Header-Tabs) ---- */
|
||||||
|
.page-admin .tab {
|
||||||
|
padding:8px 12px; border:1px solid var(--border);
|
||||||
|
border-radius:999px; background:#fff; cursor:pointer; font-size:.9rem;
|
||||||
|
}
|
||||||
|
.page-admin .tab.active { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||||
|
|
||||||
|
/* ---- Tabellen/Listen ---- */
|
||||||
|
.page-admin .list {
|
||||||
|
width:100%; border-collapse: separate; border-spacing: 0; background: #fff;
|
||||||
|
border:1px solid var(--border); border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.page-admin .list thead th {
|
||||||
|
text-align:left; padding:10px 12px; background:#fafafe; color:#374151; font-weight:600; font-size:.92rem;
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.page-admin .list tbody td {
|
||||||
|
padding:10px 12px; border-bottom:1px solid var(--border); vertical-align: top; font-size:.92rem;
|
||||||
|
}
|
||||||
|
.page-admin .list tbody tr:hover td { background:#fafafa; }
|
||||||
|
.page-admin .empty {
|
||||||
|
text-align:center; padding:28px; color:var(--muted); font-size:.95rem; background:#fff; border:1px dashed var(--border); border-radius:12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Formulare ---- */
|
||||||
|
.page-admin input[type="text"],
|
||||||
|
.page-admin input[type="email"],
|
||||||
|
.page-admin input[type="number"],
|
||||||
|
.page-admin input[type="search"],
|
||||||
|
.page-admin textarea,
|
||||||
|
.page-admin select {
|
||||||
|
width:100%; border:1px solid var(--border); border-radius:10px; padding:10px 12px; margin:8px 0; background:#fff;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
.page-admin textarea { min-height: 110px; resize: vertical; }
|
||||||
|
.page-admin label { display:block; margin:12px 0 6px; color:#334155; font-size:.92rem; }
|
||||||
|
|
||||||
|
/* ---- Dialoge / Backdrop ---- */
|
||||||
|
.page-admin dialog::backdrop { background: rgba(15,23,42,.35); }
|
||||||
|
.page-admin .dialog-head {
|
||||||
|
display:flex; align-items:center; gap:10px; justify-content:space-between;
|
||||||
|
padding:10px 14px; background:var(--panel); border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Preview Dialog ---- */
|
||||||
|
.page-admin .previewDialog { width:min(900px,95vw); border:none; border-radius:12px; padding:0; overflow:hidden; }
|
||||||
|
.page-admin .previewHead { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; background:var(--panel); border-bottom:1px solid var(--border); }
|
||||||
|
.page-admin .previewBody { height:min(70vh,700px); }
|
||||||
|
.page-admin .previewBody iframe { width:100%; height:100%; border:0; background:#fafafa; }
|
||||||
|
|
||||||
|
/* ---- Utility ---- */
|
||||||
|
.page-admin .truncate { max-width:22rem; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||||
|
.page-admin .hidden { display:none !important; }
|
||||||
|
.page-admin .muted-12 { font-size:12px; color:var(--muted); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
LOGIN (Karte in der Mitte)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.page-login .loginWrap { display:grid; place-items:center; min-height: 55vh; }
|
||||||
|
.page-login .loginCard {
|
||||||
|
width:min(420px,95vw); background: var(--panel); border:1px solid var(--border);
|
||||||
|
border-radius:16px; box-shadow:0 10px 30px rgba(2,6,23,.06); padding:28px;
|
||||||
|
}
|
||||||
|
.page-login h1 { margin:0 0 8px; font-size:20px; }
|
||||||
|
.page-login p { margin:0 0 18px; color:#475569; }
|
||||||
|
.page-login label { display:block; margin:12px 0 6px; color:#334155; }
|
||||||
|
.page-login input[type="email"],
|
||||||
|
.page-login input[type="text"],
|
||||||
|
.page-login input[type="password"] {
|
||||||
|
width:100%; padding:12px; border:1px solid #cbd5e1; border-radius:10px; font-size:15px; background:#fff;
|
||||||
|
}
|
||||||
|
.page-login .btn-login {
|
||||||
|
width:100%; margin-top:16px; padding:12px; border:0; border-radius:12px;
|
||||||
|
background:#111827; color:#fff; font-weight:600; cursor:pointer;
|
||||||
|
}
|
||||||
|
.page-login .mini { margin-top:10px; text-align:center; }
|
||||||
|
.page-login .hint { font-size:12px; color:var(--muted); }
|
||||||
|
|
||||||
|
/* ABSOLUTER UI-FIX: Versteckt die hartnäckige Bibliothek-Kategorie */
|
||||||
|
.gjs-block-category[data-id="Bibliothek"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
25
public/assets/css/app.css
Normal file
25
public/assets/css/app.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* Auth-Guard: bis zum erfolgreichen auth.me nichts anzeigen */
|
||||||
|
html.auth-pending header,
|
||||||
|
html.auth-pending main {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
|
||||||
|
.btn{
|
||||||
|
display:inline-flex;align-items:center;gap:.5rem;
|
||||||
|
padding:.35rem .7rem;border-radius:.7rem;border:1px solid #e5e7eb;
|
||||||
|
background:#fff;font-size:.9rem;cursor:pointer;
|
||||||
|
}
|
||||||
|
.btn:hover{background:#f8fafc}
|
||||||
|
.btn-danger{border-color:#fecaca;color:#b91c1c}
|
||||||
|
.btn-danger:hover{background:#fef2f2}
|
||||||
|
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:.35rem;padding:.15rem .5rem;border-radius:999px;background:#f1f5f9;color:#334155;font-size:.75rem;border:1px solid #e5e7eb}
|
||||||
|
.chip .dot{width:.5rem;height:.5rem;border-radius:999px;background:#64748b}
|
||||||
|
|
||||||
|
dialog::backdrop{background:rgba(15,23,42,.3)}
|
||||||
|
#toast-root{z-index:2147483647}
|
||||||
|
.truncate{max-width:22rem;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
||||||
|
.hidden{display:none}
|
||||||
|
|
||||||
48
public/assets/css/toast.css
Normal file
48
public/assets/css/toast.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* assets/css/toast.css */
|
||||||
|
/* Works whether the root is in <body> or inside a <dialog open> (top layer) */
|
||||||
|
.toast-root, #toast-root {
|
||||||
|
position: fixed; /* relative to viewport even inside dialog's top layer */
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2147483647;
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
top: 16px;
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 420px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 10px 30px rgba(2, 6, 23, .25), 0 2px 8px rgba(2,6,23,.12);
|
||||||
|
color: #0f172a;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(148,163,184,.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
animation: toast-slide-in .18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast + .toast { margin-top: 12px; }
|
||||||
|
|
||||||
|
.toast .icon { font-size: 18px; line-height: 1; margin-top: 2px; }
|
||||||
|
.toast .content { flex: 1; font-size: 14px; line-height: 1.35; }
|
||||||
|
.toast .close { appearance: none; border: 0; background: transparent; font-size: 16px; color: #475569; cursor: pointer; padding: 2px 4px; }
|
||||||
|
|
||||||
|
/* success (green) by default */
|
||||||
|
.toast.success { border-color: #86efac; background: #ecfdf5; color: #065f46; }
|
||||||
|
.toast.success .icon { color: #10b981; }
|
||||||
|
|
||||||
|
/* error (red) */
|
||||||
|
.toast.error { border-color: #fecaca; background: #fff1f2; color: #7f1d1d; }
|
||||||
|
.toast.error .icon { color: #ef4444; }
|
||||||
|
|
||||||
|
@keyframes toast-slide-in {
|
||||||
|
from { transform: translateY(-6px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
109
public/assets/js/api.js
Normal file
109
public/assets/js/api.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// assets/js/api.js
|
||||||
|
const API = "api.php";
|
||||||
|
|
||||||
|
/** ---- intern: Hilfen ---- */
|
||||||
|
function withTs(url) {
|
||||||
|
const sep = url.includes("?") ? "&" : "?";
|
||||||
|
return `${url}${sep}t=${Date.now()}`; // no-store Absicherung
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonSafe(res) {
|
||||||
|
const text = await res.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("API: invalid JSON", { status: res.status, text });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... oberer Teil unverändert ...
|
||||||
|
|
||||||
|
/** zentraler Fetch-Wrapper: Credentials, no-store, 401→Login */
|
||||||
|
async function apiFetch(url, init = {}) {
|
||||||
|
const res = await fetch(withTs(url), {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "/login.php";
|
||||||
|
throw new Error("unauthorized");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ---- Public API ---- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action-Call:
|
||||||
|
* - apiAction('auth.me')
|
||||||
|
* - apiAction('sections.list', { method:'GET', data:{ template_id: 123 } })
|
||||||
|
* - apiAction('templates.create', { method:'POST', data:{ name:'...' } })
|
||||||
|
*/
|
||||||
|
export async function apiAction(
|
||||||
|
action,
|
||||||
|
{ method = "GET", data = null, headers = {} } = {}
|
||||||
|
) {
|
||||||
|
let url = `${API}?action=${encodeURIComponent(action)}`;
|
||||||
|
const init = { method, headers: { ...headers } };
|
||||||
|
|
||||||
|
// GET/HEAD → data als Query-String anhängen (kein Body!)
|
||||||
|
if ((method === "GET" || method === "HEAD") && data && typeof data === "object") {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (v !== undefined && v !== null) params.append(k, String(v));
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
if (qs) url += `&${qs}`;
|
||||||
|
} else if (data != null) {
|
||||||
|
init.headers["Content-Type"] = "application/json";
|
||||||
|
init.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiFetch(url, init);
|
||||||
|
return await parseJsonSafe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... Rest (apiList, apiCreate, apiUpdate, apiDelete, toast) unverändert ...
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen-Helper für Ressourcen – ruft `${res}.list` auf.
|
||||||
|
* Optional kannst du query-Objekte mitgeben, z.B. { template_id: 123 } für sections.
|
||||||
|
*/
|
||||||
|
export async function apiList(res, query = {}) {
|
||||||
|
const q = new URLSearchParams(query);
|
||||||
|
const qs = q.toString() ? `&${q.toString()}` : "";
|
||||||
|
const r = await apiAction(`${res}.list`, { method: "GET" });
|
||||||
|
// Falls du query serverseitig brauchst (z.B. template_id), nutze eine Action-Variante:
|
||||||
|
// return await apiAction(`${res}.list`, { method:"GET", data: query });
|
||||||
|
return r?.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET by id: nur nutzen, wenn du eine `${res}.get`-Action hast */
|
||||||
|
export async function apiGet(res, id) {
|
||||||
|
return await apiAction(`${res}.get`, { method: "GET", data: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CREATE / UPDATE / DELETE – sprechen `${res}.create|update|delete` an */
|
||||||
|
export async function apiCreate(res, payload) {
|
||||||
|
return await apiAction(`${res}.create`, { method: "POST", data: payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiUpdate(res, id, payload) {
|
||||||
|
return await apiAction(`${res}.update`, { method: "POST", data: { id, ...payload } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete(res, id) {
|
||||||
|
return await apiAction(`${res}.delete`, { method: "POST", data: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** optionaler Toast-Fallback (keine harte Abhängigkeit) */
|
||||||
|
export function toast(msg, ok = true, opts = {}) {
|
||||||
|
if (window.Toast?.show) {
|
||||||
|
window.Toast.show(msg, { type: ok ? "success" : "error", duration: 2200, ...opts });
|
||||||
|
} else {
|
||||||
|
(ok ? console.log : console.error)(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
84
public/assets/js/app.js
Normal file
84
public/assets/js/app.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// assets/js/app.js
|
||||||
|
import { initTabs } from './ui-tabs.js';
|
||||||
|
import { initLists } from './ui-list.js';
|
||||||
|
import { initCreate } from './ui-create.js';
|
||||||
|
import { initEditor } from './ui-editor.js';
|
||||||
|
import { mountLogoutButton, ensureFloatingLogout } from './ui-auth.js';
|
||||||
|
import { apiAction } from './api.js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt die App erst, wenn Auth validiert ist.
|
||||||
|
* Wichtig: KEIN finally → nur im Erfolgsfall UI freigeben (verhindert Flashing für Gäste).
|
||||||
|
*/
|
||||||
|
async function ensureAuthenticated() {
|
||||||
|
try {
|
||||||
|
const me = await apiAction('auth.me', { method: 'GET' });
|
||||||
|
if (!me?.ok || !me?.user) {
|
||||||
|
window.location.href = '/login.php';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ✅ nur für eingeloggte Nutzer: UI freigeben
|
||||||
|
document.documentElement.classList.remove('auth-pending');
|
||||||
|
const appRoot = document.getElementById('app');
|
||||||
|
if (appRoot && appRoot.hasAttribute('hidden')) appRoot.removeAttribute('hidden');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// apiAction leitet bei 401 ohnehin um
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAppFeatures() {
|
||||||
|
initTabs();
|
||||||
|
initLists();
|
||||||
|
initCreate();
|
||||||
|
initEditor();
|
||||||
|
|
||||||
|
// Logout-Buttons
|
||||||
|
mountLogoutButton('#btn-logout', { redirect: '/login.php' });
|
||||||
|
ensureFloatingLogout({ redirect: '/login.php' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync-Nachrichten aus dem Editor-Iframe (unverändert, aber mit credentials)
|
||||||
|
async function handleEditorMessages(ev) {
|
||||||
|
const msg = ev.data || {};
|
||||||
|
if (msg.source !== 'email-editor' || msg.type !== 'save') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = window.__currentEditorCtx || {};
|
||||||
|
const id = ctx.id;
|
||||||
|
const mode = (ctx.mode || msg.mode || '').toLowerCase();
|
||||||
|
const refs = Array.isArray(msg.refs) ? msg.refs : [];
|
||||||
|
if (!id || !mode) return;
|
||||||
|
|
||||||
|
if (mode === 'templates') {
|
||||||
|
await fetch('./api.php?resource=template_items&action=sync', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ template_id: id, items: refs })
|
||||||
|
});
|
||||||
|
} else if (mode === 'sections') {
|
||||||
|
await fetch('./api.php?resource=section_items&action=sync', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ section_id: id, items: refs.filter(r => r.ref_type === 'block') })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('refs sync failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const ok = await ensureAuthenticated();
|
||||||
|
if (!ok) return; // Gast → Redirect, UI bleibt verborgen
|
||||||
|
initAppFeatures(); // Eingeloggt → App initialisieren
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', handleEditorMessages);
|
||||||
|
|
||||||
207
public/assets/js/bridge/blocks-api (Kopie).js
Normal file
207
public/assets/js/bridge/blocks-api (Kopie).js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/* /assets/js/bridge/blocks-api.js (SCHRITT 16: Finaler Stabilitäts-Fix) */
|
||||||
|
(function (B) {
|
||||||
|
|
||||||
|
// 🛑 KRITISCHER FIX: Nur minimale Prüfung, um synchrone Initialisierung zu garantieren
|
||||||
|
if (!B || typeof grapesjs === 'undefined') {
|
||||||
|
console.warn("[BRIDGE-API] blocks-api.js: BridgeParts (B) oder GrapesJS fehlt. Exit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginName = 'bridge-blocks-api';
|
||||||
|
const qs = new URLSearchParams(location.search);
|
||||||
|
B.EDITOR_MODE = (qs.get('mode') || 'templates').toUpperCase();
|
||||||
|
console.log(`%c[${PluginName} - INIT] Editor Modus: ${B.EDITOR_MODE} (SCHRITT 16 - FINAL STABLE)`, 'color: #1E90FF; font-weight: bold;');
|
||||||
|
|
||||||
|
const TARGET_CAT_ID = 'custom';
|
||||||
|
const PLACEHOLDER_ID = 'api-placeholder-loading';
|
||||||
|
const REFERENCE_COMPONENT_TYPE = 'library-reference';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (1) Kern-Logik: Platzhalter und Kategorien registrieren (SYNCHRON)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const preRegisterCategoriesAndPlaceholders = (editor) => {
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
|
||||||
|
bm.add(PLACEHOLDER_ID, {
|
||||||
|
label: 'Lade Custom-Blöcke...',
|
||||||
|
category: TARGET_CAT_ID,
|
||||||
|
content: '<div style="padding: 10px; color: #1e3a8a; background-color: #eef2ff; border: 1px solid #c7d2fe; text-align: center;">⚙️ Custom-Blöcke werden geladen...</div>',
|
||||||
|
attributes: { class: 'gjs-block__api-placeholder' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cat = bm.getCategories().get(TARGET_CAT_ID);
|
||||||
|
if (!cat) {
|
||||||
|
bm.addCategory(TARGET_CAT_ID, { label: 'Custom', open: true, order: 1 });
|
||||||
|
}
|
||||||
|
console.log(`%c[${PluginName}] Platzhalter und Kategorie registriert.`, 'color: #008000;');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (2) Komponenten-Logik (ASYNCHRONER WORKAROUND & FIX)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const registerReferenceComponent = (editor) => {
|
||||||
|
const domc = editor.DomComponents;
|
||||||
|
const defaultType = domc.getType('default');
|
||||||
|
|
||||||
|
if (!defaultType) return;
|
||||||
|
|
||||||
|
// KRITISCHER WORKAROUND: Registrierung wird minimal verzögert
|
||||||
|
setTimeout(() => {
|
||||||
|
domc.addType(REFERENCE_COMPONENT_TYPE, {
|
||||||
|
model: defaultType.model.extend({
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Setze die Attribute sicher im init() (Fix für "defaults" TypeError)
|
||||||
|
if (this.get('type') !== REFERENCE_COMPONENT_TYPE) {
|
||||||
|
this.set('type', REFERENCE_COMPONENT_TYPE);
|
||||||
|
this.set('tagName', 'div');
|
||||||
|
this.set('lib-kind', '');
|
||||||
|
this.set('lib-id', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.on('change:lib-kind change:lib-id', this.reloadComponentContent);
|
||||||
|
const editorInstance = this.em.get('Editor');
|
||||||
|
if (editorInstance && this.get('lib-id')) {
|
||||||
|
// Prüft hier nur auf getApiItem, da es für die Referenz-Komponente essenziell ist
|
||||||
|
if(B.getApiItem) {
|
||||||
|
editorInstance.on('load', this.reloadComponentContent.bind(this), { once: true });
|
||||||
|
} else {
|
||||||
|
console.warn(`[${PluginName}] B.getApiItem fehlt. Inhalte für 'library-reference' können nicht geladen werden.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reloadComponentContent(opts = {}) {
|
||||||
|
const kind = this.get('lib-kind');
|
||||||
|
const id = this.get('lib-id');
|
||||||
|
|
||||||
|
if (!kind || !id) {
|
||||||
|
this.set('content', '<div style="padding: 10px; color: #dc3545; background-color: #fce7f3; border: 1px solid #fbcfe8; text-align: center;">🛑 Fehler: API-Referenz unvollständig.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!B.getApiItem) {
|
||||||
|
this.set('content', '<div style="padding: 10px; color: #dc3545; background-color: #fce7f3; border: 1px solid #fbcfe8; text-align: center;">🛑 Fehler: API-Kernfunktion getApiItem fehlt.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
B.getApiItem(kind, id)
|
||||||
|
.then(item => {
|
||||||
|
if (item && item.html) {
|
||||||
|
this.set('content', item.html);
|
||||||
|
console.log(`[${PluginName}] Geladenen Inhalt für ${kind}/${id} gesetzt.`);
|
||||||
|
} else {
|
||||||
|
this.set('content', `<div style="padding: 10px; color: #dc3545; background-color: #fce7f3; border: 1px solid #fbcfe8; text-align: center;">🛑 Fehler: Inhalt für ${kind}/${id} nicht gefunden.</div>`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`[${PluginName}] Fehler beim Abruf von ${kind}/${id}:`, error);
|
||||||
|
this.set('content', `<div style="padding: 10px; color: #dc3545; background-color: #fce7f3; border: 1px solid #fbcfe8; text-align: center;">🛑 Fehler beim Laden von ${kind}/${id}.</div>`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}, {}),
|
||||||
|
view: defaultType.view,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`%c[${PluginName}] Komponententyp '${REFERENCE_COMPONENT_TYPE}' ASYNCHRON registriert.`, 'color: #008000;');
|
||||||
|
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (3) Asynchrone Logik: API-Blöcke registrieren (FINAL CLEAN)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const loadAndRegisterApiBlocks = (editor) => {
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
const targetCatId = TARGET_CAT_ID;
|
||||||
|
|
||||||
|
// KRITISCHER FIX: Stelle sicher, dass der Platzhalter existiert.
|
||||||
|
if (!bm.get(PLACEHOLDER_ID)) {
|
||||||
|
bm.add(PLACEHOLDER_ID, {
|
||||||
|
label: 'Lade Custom-Blöcke...',
|
||||||
|
category: targetCatId,
|
||||||
|
content: '<div style="padding: 10px; color: #1e3a8a; background-color: #eef2ff; border: 1px solid #c7d2fe; text-align: center;">⚙️ Custom-Blöcke werden geladen...</div>',
|
||||||
|
attributes: { class: 'gjs-block__api-placeholder' },
|
||||||
|
});
|
||||||
|
console.log(`%c[${PluginName}] Platzhalter erneut hinzugefügt (Überlebens-Check).`, 'color: orange;');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 NEUER CHECK: Prüfe die fetch*-Funktionen erst HIER.
|
||||||
|
if (!B.fetchSections || !B.fetchBlocks || !B.fetchSnippets) {
|
||||||
|
console.error(`%c[${PluginName}] FEHLER: Eine der API-Ladefunktionen (fetchSections/Blocks/Snippets) fehlt.`, 'color: #dc3545; font-weight: bold;');
|
||||||
|
// Platzhalter bleibt, da die Kategorie sonst verschwindet.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Explizite Promise.all mit allen fetch*-Funktionen
|
||||||
|
Promise.all([
|
||||||
|
B.fetchSections().then(items => items.map(i => ({ ...i, kind: 'sections' }))),
|
||||||
|
B.fetchBlocks().then(items => items.map(i => ({ ...i, kind: 'blocks' }))),
|
||||||
|
B.fetchSnippets().then(items => items.map(i => ({ ...i, kind: 'snippets' })))
|
||||||
|
])
|
||||||
|
.then(results => {
|
||||||
|
// Führe alle Ergebnisse zu einem flachen Array zusammen
|
||||||
|
const apiItems = results.flat().filter(item => item && item.id);
|
||||||
|
|
||||||
|
// Array-Ausgabe zur Bestätigung der Daten
|
||||||
|
console.log(`%c[${PluginName}] API-Daten Array:`, 'color: #9400D3; font-weight: bold;', apiItems);
|
||||||
|
|
||||||
|
console.log(`%c[${PluginName}] API-Daten geladen: ${apiItems.length} Blöcke/Sektionen gefunden.`, 'color: #1E90FF; font-weight: bold;');
|
||||||
|
|
||||||
|
if (apiItems.length === 0) {
|
||||||
|
// Platzhalter bleibt, um die leere Kategorie sichtbar zu halten.
|
||||||
|
console.warn(`[${PluginName}] Keine API-Daten gefunden, Platzhalter bleibt (leer) erhalten.`);
|
||||||
|
} else {
|
||||||
|
apiItems.forEach(item => {
|
||||||
|
const blockId = `lib-${item.kind}-${item.id}`;
|
||||||
|
const label = item.name || item.label || 'Unbenannter Block';
|
||||||
|
const itemKindUpper = item.kind.toUpperCase();
|
||||||
|
|
||||||
|
const blockDefinition = {
|
||||||
|
label: label,
|
||||||
|
category: targetCatId,
|
||||||
|
content: {
|
||||||
|
type: REFERENCE_COMPONENT_TYPE,
|
||||||
|
attributes: { 'lib-kind': item.kind, 'lib-id': item.id },
|
||||||
|
},
|
||||||
|
attributes: { 'title': itemKindUpper },
|
||||||
|
media: item.preview_url ? `<img src="${item.preview_url}">` : '',
|
||||||
|
};
|
||||||
|
bm.add(blockId, blockDefinition);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Platzhalter entfernen, da Blöcke erfolgreich geladen wurden
|
||||||
|
bm.remove(PLACEHOLDER_ID);
|
||||||
|
console.log(`%c[${PluginName}] ${apiItems.length} API-Blöcke registriert. Platzhalter entfernt.`, 'color: #008000; font-weight: bold;');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`%c[${PluginName}] FEHLER beim Laden der API-Blöcke:`, 'color: #dc3545; font-weight: bold;', error);
|
||||||
|
// Platzhalter entfernen, um nicht im ewigen Ladezustand zu bleiben.
|
||||||
|
bm.remove(PLACEHOLDER_ID);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (4) Plugin-Funktion
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const plugin = (editor) => {
|
||||||
|
preRegisterCategoriesAndPlaceholders(editor);
|
||||||
|
registerReferenceComponent(editor);
|
||||||
|
|
||||||
|
editor.on('load', () => {
|
||||||
|
console.log(`%c[${PluginName}] GrapesJS 'load' Event: Starte asynchrones Laden der API-Blöcke.`, 'color: #1E90FF; font-weight: bold;');
|
||||||
|
loadAndRegisterApiBlocks(editor);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (5) Export an Bridge Core
|
||||||
|
// --------------------------------------------------------
|
||||||
|
if (B.registerGrapesJSPlugin) {
|
||||||
|
B.registerGrapesJSPlugin(PluginName, plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
270
public/assets/js/bridge/blocks-api.js
Normal file
270
public/assets/js/bridge/blocks-api.js
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/* /assets/js/bridge/blocks-api.js (UI-KERN UND KOMPONENTEN-SCHICHT) */
|
||||||
|
|
||||||
|
(function (B) {
|
||||||
|
|
||||||
|
const PluginName = 'bridge-blocks-api';
|
||||||
|
|
||||||
|
if (!B || typeof grapesjs === 'undefined') {
|
||||||
|
console.warn(`%c[${PluginName}] %cBridgeParts (B) oder GrapesJS fehlt. Exit.`, 'color:orange; font-weight:bold;', 'color:inherit;');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
B.LOG_CONFIG = B.LOG_CONFIG || { PLUGINS: {} };
|
||||||
|
B.LOG_CONFIG.PLUGINS[PluginName] = true;
|
||||||
|
|
||||||
|
const log = (message, color = '#1E90FF', type = 'info', force = false) => B.log(PluginName, message, color, type, force);
|
||||||
|
|
||||||
|
const qs = new URLSearchParams(location.search);
|
||||||
|
B.EDITOR_MODE = (qs.get('mode') || 'templates').toUpperCase();
|
||||||
|
log(`START: SKRIPT-AUSFÜHRUNG GESTARTET. Editor Modus: ${B.EDITOR_MODE}.`, '#DC143C');
|
||||||
|
|
||||||
|
const TARGET_CAT_ID = 'custom';
|
||||||
|
const PLACEHOLDER_ID = 'api-placeholder-loading';
|
||||||
|
const REFERENCE_COMPONENT_TYPE = 'library-reference';
|
||||||
|
|
||||||
|
// --- NEUE KONSTANTEN FÜR SPEICHERN-LOGIK ---
|
||||||
|
// Annahme: ID der aktuellen Seite/Template ist global in B verfügbar
|
||||||
|
const CURRENT_ENTITY_ID = B.CURRENT_ENTITY_ID || qs.get('id') || 0;
|
||||||
|
// Annahme: Basis-URL der API ist in B verfügbar
|
||||||
|
const API_KERNEL_URL = B.API_KERNEL_URL || '/api/ApiKernel.php';
|
||||||
|
// -------------------------------------------
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (1) Kern-Logik: Platzhalter und Kategorien registrieren (SYNCHRON)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const preRegisterCategoriesAndPlaceholders = (editor) => {
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
|
||||||
|
bm.add(PLACEHOLDER_ID, {
|
||||||
|
label: 'Lade Custom-Blöcke...',
|
||||||
|
category: TARGET_CAT_ID,
|
||||||
|
content: '<div style="padding: 10px; color: #1e3a8a; background-color: #eef2ff; border: 1px solid #c7d2fe; text-align: center;">⚙️ Custom-Blöcke werden geladen...</div>',
|
||||||
|
attributes: { class: 'gjs-block__api-placeholder' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cat = bm.getCategories().get(TARGET_CAT_ID);
|
||||||
|
if (!cat) {
|
||||||
|
bm.addCategory(TARGET_CAT_ID, { label: 'Custom', open: true, order: 1 });
|
||||||
|
}
|
||||||
|
log('Platzhalter und Kategorie registriert.', '#008000');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (2) Komponenten-Logik (ASYNCHRONER WORKAROUND & FIX)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const registerReferenceComponent = (editor) => {
|
||||||
|
const domc = editor.DomComponents;
|
||||||
|
const defaultType = domc.getType('default');
|
||||||
|
|
||||||
|
if (!defaultType) return;
|
||||||
|
|
||||||
|
log(`Starte Registrierung des Komponententyps '${REFERENCE_COMPONENT_TYPE}'.`, '#1E90FF');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
domc.addType(REFERENCE_COMPONENT_TYPE, {
|
||||||
|
model: defaultType.model.extend({
|
||||||
|
|
||||||
|
getCachedApiItem(kind, id) {
|
||||||
|
const key = `${kind}-${id}`;
|
||||||
|
const item = B.ApiItemCache?.[key];
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const id = this.get('lib-id');
|
||||||
|
const kind = this.get('lib-kind');
|
||||||
|
const startContent = this.get('startContent');
|
||||||
|
|
||||||
|
log(`INIT LÄUFT. lib-kind: ${kind}, lib-id: ${id}. (Bestätigung des Element-Drops/Load)`, '#8A2BE2');
|
||||||
|
|
||||||
|
if (startContent) {
|
||||||
|
// 💡 NEUER FIX: Beim Drop nur die 'content'-Eigenschaft setzen, NICHT als Unterkomponenten parsen
|
||||||
|
this.set('content', startContent);
|
||||||
|
this.unset('startContent');
|
||||||
|
log(`INHALT erfolgreich als REINES HTML aus 'startContent' gesetzt: ${kind}/${id}`, '#008000');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.on('change:lib-kind change:lib-id', this.reloadComponentContent);
|
||||||
|
|
||||||
|
if (!startContent && kind && id) {
|
||||||
|
this.reloadComponentContent({ forced: true, reason: 'INIT_LOAD_FROM_CACHE' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reloadComponentContent(opts = {}) {
|
||||||
|
const kind = this.get('lib-kind');
|
||||||
|
const id = this.get('lib-id');
|
||||||
|
const reason = opts.reason || (opts.forced ? 'FORCED_INTERNAL' : 'EVENT_CHANGE');
|
||||||
|
log(`RELOAD START (${reason}). Kind: ${kind}, ID: ${id}.`, '#8A2BE2');
|
||||||
|
|
||||||
|
if (!kind || !id) {
|
||||||
|
log('RELOAD FEHLER: lib-kind oder lib-id fehlt. Setze Fehler-Placeholder.', '#dc3545', 'error', true);
|
||||||
|
// 💡 FIX: Setze reinen HTML-String als content
|
||||||
|
this.set('content', '<div style="padding: 10px; color: #dc3545; background-color: #fce7f3; border: 1px solid #fbcfe8; text-align: center;">🛑 Fehler: API-Referenz unvollständig.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = this.getCachedApiItem(kind, id);
|
||||||
|
|
||||||
|
if (item && (item.html || item.content)) {
|
||||||
|
const content = item.html || item.content;
|
||||||
|
// 💡 FIX: Verwende set('content', ...) statt components(...)
|
||||||
|
// Dadurch wird der Inhalt als reiner HTML-String in die Komponente gesetzt
|
||||||
|
// und nicht als neue, bearbeitbare GrapesJS-Komponenten geparst.
|
||||||
|
this.set('content', content);
|
||||||
|
log(`INHALT erfolgreich für ${kind}/${id} geladen und als REINER HTML-STRING gesetzt.`, '#008000');
|
||||||
|
} else {
|
||||||
|
log(`RELOAD FEHLER: Inhalt für ${kind}/${id} NICHT im Cache gefunden.`, '#dc3545', 'error', true);
|
||||||
|
// 💡 FIX: Setze reinen HTML-HTML-String als content
|
||||||
|
this.set('content', `<div style="padding: 10px; color: #dc3545; background-color: #fce7f3; border: 1px solid #fbcfe8; text-align: center;">🛑 Fehler: Inhalt für ${kind}/${id} nicht im Cache gefunden.</div>`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
isComponent: el => el && el.nodeType === 1 && el.hasAttribute('lib-id'),
|
||||||
|
extend: 'default',
|
||||||
|
model: {
|
||||||
|
defaults: {
|
||||||
|
...defaultType.model.prototype.defaults,
|
||||||
|
// 🛑 KRITISCHE FIXES FÜR REFERENZEN
|
||||||
|
components: '', // Darf keine Unterkomponenten haben, die geparst werden
|
||||||
|
editable: false, // ❌ Nicht bearbeitbar (Inline-Editierung verhindern)
|
||||||
|
removable: true,
|
||||||
|
draggable: true,
|
||||||
|
copyable: true,
|
||||||
|
droppable: false, // ❌ Darf keine anderen Komponenten aufnehmen
|
||||||
|
// ---------------------------------
|
||||||
|
traits: [
|
||||||
|
{ type: 'text', name: 'lib-id', label: 'Library ID', changeProp: true },
|
||||||
|
{ type: 'text', name: 'lib-kind', label: 'Library Kind', changeProp: true },
|
||||||
|
],
|
||||||
|
'lib-id': '',
|
||||||
|
'lib-kind': '',
|
||||||
|
startContent: '',
|
||||||
|
content: '', // Inhalt, der das gerenderte HTML hält
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// 💡 WICHTIG: Die View muss den Content als reinen HTML-Inhalt rendern (defaultType macht das).
|
||||||
|
view: defaultType.view,
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`Komponententyp '${REFERENCE_COMPONENT_TYPE}' ASYNCHRON registriert.`, '#008000');
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (3) HINZUGEFÜGT: Speichern-Befehl (Command)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const registerSaveCommand = (editor) => {
|
||||||
|
|
||||||
|
editor.Commands.add('save-data', {
|
||||||
|
run: function(editor, sender) {
|
||||||
|
// 💡 FIX: Sicherstellen, dass sender existiert und die 'set'-Methode hat (nur bei Buttons)
|
||||||
|
if (sender && typeof sender.set === 'function') {
|
||||||
|
sender.set('active', 0); // Schaltet den Button nach dem Klick ab
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CURRENT_ENTITY_ID) {
|
||||||
|
log('SAVE ABORT', 'Speichern abgebrochen: Keine Entity ID verfügbar (B.CURRENT_ENTITY_ID fehlt oder ist 0).', 'red', 'error', true);
|
||||||
|
alert('Speichern fehlgeschlagen: Die ID des aktuellen Elements fehlt.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Daten extrahieren
|
||||||
|
const htmlContent = editor.getHtml() + '<style>' + editor.getCss() + '</style>';
|
||||||
|
// 2. KRITISCH: Holt die JSON-Repräsentation des Editors
|
||||||
|
const jsonProjectData = editor.getProjectData();
|
||||||
|
|
||||||
|
log('SAVE START', 'Starte Speichern des Inhalts an die API...', '#FF4500');
|
||||||
|
|
||||||
|
// 3. Daten für den POST-Request vorbereiten
|
||||||
|
const dataToSend = {
|
||||||
|
action: 'blocks.update', // Oder 'templates.update', je nach Entity
|
||||||
|
id: CURRENT_ENTITY_ID,
|
||||||
|
html: htmlContent,
|
||||||
|
// 🚨 KRITISCH: Korrigiert auf 'json_content' für das PHP-Backend
|
||||||
|
json_content: jsonProjectData,
|
||||||
|
name: B.CURRENT_ENTITY_NAME || 'Unbenannt', // Optional
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. API-Aufruf (fetch)
|
||||||
|
fetch(API_KERNEL_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
// Wichtig: JSON-Daten senden
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(dataToSend),
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
log('SAVE FAILED (HTTP)', `Speichern fehlgeschlagen: HTTP-Status ${response.status}.`, 'red', 'error', true);
|
||||||
|
throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.ok === false) {
|
||||||
|
log('SAVE FAILED (API)', `Speichern fehlgeschlagen: API-Fehler: ${data.error || 'Unbekannt'}`, 'red', 'error', true);
|
||||||
|
alert(`Speichern fehlgeschlagen: ${data.error || 'API-Fehler'}`);
|
||||||
|
} else {
|
||||||
|
log('SAVE SUCCESS', 'Speichern erfolgreich. JSON-Daten wurden gesendet.', '#008000', 'info', true);
|
||||||
|
// 💡 HINZUGEFÜGT: Bestätigung an das Elternfenster senden
|
||||||
|
window.parent.postMessage({ source: 'editor', type: 'save:success' }, '*');
|
||||||
|
editor.refresh(); // Optional: Editor-Ansicht aktualisieren
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
log('SAVE FAILED (FETCH)', `FEHLER beim Speichern: ${error.message}`, 'red', 'error', true);
|
||||||
|
alert('Speichern fehlgeschlagen. Netzwerk- oder JSON-Parse-Fehler.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eventuell den Button in der Toolbar registrieren (falls noch nicht geschehen)
|
||||||
|
editor.Panels.addButton('options', {
|
||||||
|
id: 'save-data',
|
||||||
|
className: 'fa fa-floppy-o',
|
||||||
|
command: 'save-data',
|
||||||
|
attributes: { title: 'Speichern (Strg/Cmd + S)' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tastenkürzel für Speichern hinzufügen
|
||||||
|
editor.Keymaps.add('ctrl-s', 'save-data', 'ctrl+s');
|
||||||
|
editor.Keymaps.add('cmd-s', 'save-data', 'cmd+s');
|
||||||
|
|
||||||
|
log('Speichern-Command und Button/Keymap registriert.', '#FF4500');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (4) Plugin-Funktion (AKTUALISIERT)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
const plugin = (editor) => {
|
||||||
|
preRegisterCategoriesAndPlaceholders(editor);
|
||||||
|
registerReferenceComponent(editor);
|
||||||
|
registerSaveCommand(editor); // HINZUGEFÜGT: Speichern-Logik
|
||||||
|
|
||||||
|
editor.on('load', () => {
|
||||||
|
log("GrapesJS 'load' Event: Delegiere asynchrones Laden der API-Blöcke an library-api.", '#1E90FF');
|
||||||
|
if (B.loadAndRegisterApiBlocks) {
|
||||||
|
setTimeout(() => {
|
||||||
|
B.loadAndRegisterApiBlocks(editor);
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
log(`FEHLER: B.loadAndRegisterApiBlocks ist nicht definiert. library-api.js wurde nicht geladen oder nicht richtig initialisiert.`, 'red', 'error', true);
|
||||||
|
editor.BlockManager.remove(PLACEHOLDER_ID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// (5) Export an Bridge Core (unverändert)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
if (B.registerGrapesJSPlugin) {
|
||||||
|
B.registerGrapesJSPlugin(PluginName, plugin);
|
||||||
|
log(`PLUGIN REGISTER: '${PluginName}' zur Bridge Plugin Registry hinzugefügt.`, '#008000');
|
||||||
|
} else {
|
||||||
|
log(`FEHLER: B.registerGrapesJSPlugin fehlt. Plugin-Registrierung gescheitert.`, 'red', 'error', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
131
public/assets/js/bridge/blocks-custom.js
Normal file
131
public/assets/js/bridge/blocks-custom.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/* /assets/js/bridge/blocks-custom.js (FINAL & LOG-KONTROLLIERT) */
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
const PluginName = 'blocks-custom';
|
||||||
|
const B = window.BridgeParts || (window.BridgeParts = {});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 🎯 NEU: LOKALE LOG-KONFIGURATION UND WRAPPER
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Setzen Sie dies auf 'false' in der config.js oder hier, um alle Logs NUR für dieses Plugin zu deaktivieren.
|
||||||
|
if (B.LOG_CONFIG && B.LOG_CONFIG.PLUGINS) {
|
||||||
|
B.LOG_CONFIG.PLUGINS[PluginName] = false; // <-- HIER IST IHR SCHALTER
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEUER LOKALER WRAPPER, der die zentrale B.log Funktion verwendet:
|
||||||
|
const log = (type, message, color = '#FFD700', logType = 'info', force = false) => {
|
||||||
|
if (typeof B.log === 'function') {
|
||||||
|
B.log(PluginName, `[${type}] ${message}`, color, logType, force);
|
||||||
|
} else if (logType === 'error') {
|
||||||
|
// Fallback für kritische Fehler, wenn B.log fehlt
|
||||||
|
console.error(`%c[${PluginName} - ${type}] %c${message}`, `color:red; font-weight:bold;`, 'color:inherit;');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
log('FILE CHECK', 'Datei-IIFE startet.'); // NEU: Kontrollierbarer Start-Log
|
||||||
|
|
||||||
|
if (window.__CUSTOM_BLOCKS_LOADED) return;
|
||||||
|
window.__CUSTOM_BLOCKS_LOADED = true;
|
||||||
|
|
||||||
|
const TARGET_CAT_ID = 'bausteine';
|
||||||
|
const ALL_CUSTOM_BLOCK_IDS = [];
|
||||||
|
|
||||||
|
function addOnce(bm, id, def) {
|
||||||
|
// Hinzufügen des Blocks und Sicherstellen der Kategorie-Zuweisung
|
||||||
|
try {
|
||||||
|
bm.add(id, { ...def, category: TARGET_CAT_ID });
|
||||||
|
ALL_CUSTOM_BLOCK_IDS.push(id);
|
||||||
|
log('BLOCK ADD', `Block '${id}' erfolgreich hinzugefügt.`, '#B8860B');
|
||||||
|
} catch (e) {
|
||||||
|
log('BLOCK ERROR', `Fehler beim Hinzufügen von Block '${id}': ${e.message}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = o => Object.entries(o).map(([k,v]) => `${k}:${v}`).join(';');
|
||||||
|
|
||||||
|
function register(editor) {
|
||||||
|
log('EXECUTION', `Starte Block-Registrierung für ${TARGET_CAT_ID}.`, '#DAA520');
|
||||||
|
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
|
||||||
|
// --- Custom-Blöcke DEFINIEREN ---
|
||||||
|
|
||||||
|
// TEXT
|
||||||
|
addOnce(bm, 'cust-text', { id:'cust-text', label:'📝 Text',
|
||||||
|
content:`<div style="${css({'font-family':'Arial,sans-serif','font-size':'14px','line-height':'1.5',color:'#0f172a',margin:'0 0 12px'})}">
|
||||||
|
<p style="${css({margin:'0 0 12px'})}">Dies ist ein Absatz. Doppelklick zum Bearbeiten.</p></div>` });
|
||||||
|
|
||||||
|
// IMAGE
|
||||||
|
addOnce(bm, 'cust-image', { id:'cust-image', label:'🖼️ Bild',
|
||||||
|
content:`<div style="${css({'text-align':'center',margin:'0 0 16px'})}">
|
||||||
|
<img src="https://placehold.co/600x300" alt="Bild" style="${css({width:'100%',height:'auto','max-width':'600px',border:'0',display:'inline-block'})}"></div>` });
|
||||||
|
|
||||||
|
// BUTTON
|
||||||
|
addOnce(bm, 'cust-button', { id:'cust-button', label:'🔘 Button',
|
||||||
|
content:`<div style="${css({'text-align':'center',margin:'0 0 16px'})}">
|
||||||
|
<a href="#" style="${css({display:'inline-block','background-color':'#0ea5e9',color:'#fff','text-decoration':'none',padding:'10px 18px','border-radius':'6px','font-family':'Arial,sans-serif','font-size':'14px'})}">Call To Action</a></div>` });
|
||||||
|
|
||||||
|
// DIVIDER
|
||||||
|
addOnce(bm, 'cust-divider',{ id:'cust-divider',label:'⎯ Divider',
|
||||||
|
content:`<hr style="${css({border:'0',height:'1px','background-color':'#e2e8f0',margin:'16px 0'})}">` });
|
||||||
|
|
||||||
|
// SPACER
|
||||||
|
addOnce(bm, 'cust-spacer', { id:'cust-spacer', label:'↕ Spacer',
|
||||||
|
content:`<div style="${css({height:'24px'})}"></div>` });
|
||||||
|
|
||||||
|
// 2 COL
|
||||||
|
addOnce(bm, 'cust-2col', { id:'cust-2col', label:'🧩 2 Spalten',
|
||||||
|
content:`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${css({'font-family':'Arial,sans-serif','border-collapse':'collapse','margin-bottom':'16px'})}">
|
||||||
|
<tr><td width="50%" valign="top" style="${css({padding:'0 8px 0 0'})}">
|
||||||
|
<div style="${css({'font-size':'14px','line-height':'1.5',color:'#0f172a'})}"><p style="${css({margin:'0 0 12px'})}">Linke Spalte – Inhalt hier.</p></div>
|
||||||
|
</td><td width="50%" valign="top" style="${css({padding:'0 0 0 8px'})}">
|
||||||
|
<div style="${css({'font-size':'14px','line-height':'1.5',color:'#0f172a'})}"><p style="${css({margin:'0 0 12px'})}">Rechte Spalte – Inhalt hier.</p></div>
|
||||||
|
</td></tr></table>` });
|
||||||
|
|
||||||
|
// MEDIA LEFT
|
||||||
|
addOnce(bm, 'cust-media-left', { id:'cust-media-left', label:'🖼️◀ Text',
|
||||||
|
content:`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="${css({'font-family':'Arial,sans-serif','border-collapse':'collapse','margin-bottom':'16px'})}">
|
||||||
|
<tr><td width="40%" valign="top" style="${css({padding:'0 8px 0 0'})}">
|
||||||
|
<img src="https://placehold.co/400x260" alt="Bild" style="${css({width:'100%',height:'auto',border:'0',display:'block'})}">
|
||||||
|
</td><td width="60%" valign="top" style="${css({padding:'0 0 0 8px'})}">
|
||||||
|
<h3 style="${css({margin:'0 0 8px','font-size':'18px',color:'#0f172a'})}">Überschrift</h3>
|
||||||
|
<p style="${css({margin:'0 0 12px','font-size':'14px',color:'#0f172a','line-height':'1.5'})}">Beschreibungstext …</p>
|
||||||
|
<a href="#" style="${css({display:'inline-block','background-color':'#0ea5e9',color:'#fff','text-decoration':'none',padding:'8px 14px','border-radius':'6px','font-size':'14px'})}">Mehr erfahren</a>
|
||||||
|
</td></tr></table>` });
|
||||||
|
|
||||||
|
// HERO
|
||||||
|
addOnce(bm, 'cust-hero', { id:'cust-hero', label:'🌄 Hero',
|
||||||
|
content:`<div style="${css({'text-align':'center',margin:'0 0 16px',padding:'12px','background-color':'#eef2ff',color:'#1e3a8a','border':'1px solid #c7d2fe','border-radius':'8px'})}">
|
||||||
|
<img src="https://placehold.co/640x240" alt="Hero" style="${css({width:'100%',height:'auto','max-width':'640px',border:'0',display:'inline-block','border-radius':'6px'})}">
|
||||||
|
<h2 style="${css({'font-family':'Arial,sans-serif',margin:'12px 0 8px','font-size':'22px'})}">Titel des Newsletters</h2>
|
||||||
|
<p style="${css({'font-size':'14px',margin:'0 0 12px'})}">Kurzer Untertitel oder Einleitung.</p>
|
||||||
|
</div>` });
|
||||||
|
|
||||||
|
// FOOTER
|
||||||
|
addOnce(bm, 'cust-footer', { id:'cust-footer', label:'⚓ Footer',
|
||||||
|
content:`<div style="${css({'font-family':'Arial,sans-serif','font-size':'12px',color:'#475569','line-height':'1.5','border-top':'1px solid #e2e8f0',padding:'12px 0','text-align':'center'})}">
|
||||||
|
<p style="${css({margin:'0 0 6px'})}"><strong>Dein Unternehmen GmbH</strong> • Musterstraße 1 • 12345 Berlin</p>
|
||||||
|
<p style="${css({margin:'0'})}"><a href="#" style="${css({color:'#0ea5e9','text-decoration':'none'})}">Abmelden</a> ·
|
||||||
|
<a href="#" style="${css({color:'#0ea5e9','text-decoration':'none'})}">Impressum</a> ·
|
||||||
|
<a href="#" style="${css({color:'#0ea5e9','text-decoration':'none'})}">Datenschutz</a></p>
|
||||||
|
</div>` });
|
||||||
|
|
||||||
|
log('SUCCESS', `Registrierung abgeschlossen. ${ALL_CUSTOM_BLOCK_IDS.length} Blöcke erstellt.`, '#008000', 'info', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 KRITISCHE EXPORT-KORREKTUR: Exportiere 'register', um den Fehler in bridge-core.js zu beheben
|
||||||
|
window.BridgeBlocksCustom = {
|
||||||
|
IDS: ALL_CUSTOM_BLOCK_IDS,
|
||||||
|
register: register // <--- NEU: Exportiert die Register-Funktion
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registriere das Modul als GrapesJS Plugin
|
||||||
|
if (B && B.registerGrapesJSPlugin && typeof register === 'function') {
|
||||||
|
B.registerGrapesJSPlugin('bridge-blocks-custom', register);
|
||||||
|
log('PLUGIN REGISTER', `'bridge-blocks-custom' erfolgreich zur Bridge Plugin Registry hinzugefügt.`, '#008000');
|
||||||
|
} else {
|
||||||
|
log('CRITICAL ERROR', `BridgeParts oder registerGrapesJSPlugin fehlt! Plugin-Registrierung gescheitert.`, 'red', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
139
public/assets/js/bridge/blocks-standard.js
Normal file
139
public/assets/js/bridge/blocks-standard.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/* /assets/js/bridge/blocks-standard.js (FINAL & LOG-KONTROLLIERT) */
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
const PluginName = 'blocks-standard';
|
||||||
|
const B = window.BridgeParts || (window.BridgeParts = {});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 🎯 NEU: LOKALE LOG-KONFIGURATION UND WRAPPER
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Setzen Sie dies auf 'false' in der config.js oder hier, um alle Logs NUR für dieses Plugin zu deaktivieren.
|
||||||
|
if (B.LOG_CONFIG && B.LOG_CONFIG.PLUGINS) {
|
||||||
|
B.LOG_CONFIG.PLUGINS[PluginName] = false; // <-- HIER IST IHR SCHALTER
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEUER LOKALER WRAPPER, der die zentrale B.log Funktion verwendet:
|
||||||
|
const log = (type, message, color = '#0000FF', logType = 'info', force = false) => {
|
||||||
|
if (typeof B.log === 'function') {
|
||||||
|
B.log(PluginName, `[${type}] ${message}`, color, logType, force);
|
||||||
|
} else if (logType === 'error') {
|
||||||
|
// Fallback für kritische Fehler, wenn B.log fehlt
|
||||||
|
console.error(`%c[${PluginName} - ${type}] %c${message}`, `color:red; font-weight:bold;`, 'color:inherit;');
|
||||||
|
} else {
|
||||||
|
// Fallback für sonstige Logs
|
||||||
|
console.log(`%c[${PluginName} - ${type}] %c${message}`, `color:${color}; font-weight:bold;`, 'color:inherit;');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
log('FILE CHECK', 'Datei-IIFE startet.');
|
||||||
|
|
||||||
|
// Kritische Prüfung, um doppelte Ausführung zu verhindern
|
||||||
|
if (window.__STANDARD_BLOCKS_LOADED) return;
|
||||||
|
window.__STANDARD_BLOCKS_LOADED = true;
|
||||||
|
|
||||||
|
const TARGET_CAT_ID = 'mysnips';
|
||||||
|
const TARGET_CAT_LABEL = 'Bibliothek';
|
||||||
|
const ALL_STANDARD_BLOCK_IDS = [];
|
||||||
|
const css = o => Object.entries(o).map(([k,v]) => `${k}:${v}`).join(';');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt einen Block hinzu oder aktualisiert ihn SICHER
|
||||||
|
*/
|
||||||
|
function addOrUpdate(bm, id, def) {
|
||||||
|
if (bm.get(id)) {
|
||||||
|
bm.remove(id);
|
||||||
|
log('UPDATE', `Entferne alte Block-Definition: ${id}`, 'gray');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDef = {
|
||||||
|
...def,
|
||||||
|
category: TARGET_CAT_ID,
|
||||||
|
force: true
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
bm.add(id, finalDef);
|
||||||
|
} catch (e) {
|
||||||
|
log('CRITICAL ERROR', `KRITISCHER FEHLER beim Hinzufügen von '${id}': ${e.message}`, 'red', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_STANDARD_BLOCK_IDS.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die eigentliche Plugin-Funktion, die von GrapesJS/Bridge aufgerufen wird.
|
||||||
|
*/
|
||||||
|
const pluginFunction = (editor) => {
|
||||||
|
// Aggressiver Log zur Prüfung der Ausführung
|
||||||
|
log('EXECUTION CHECK', `Starte Block-Registrierung für ${TARGET_CAT_ID}.`, '#993300');
|
||||||
|
|
||||||
|
if (!editor || !editor.BlockManager) {
|
||||||
|
log('EXECUTION CHECK', 'Fehler: GrapesJS Editor Instanz ist ungültig.', 'red', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
|
||||||
|
// =======================================================
|
||||||
|
// I. GRAPESJS DEFAULT BLÖCKE (ALLE STANDARD ELEMENTE)
|
||||||
|
// =======================================================
|
||||||
|
|
||||||
|
// TEXT (Registriert als 'std-text')
|
||||||
|
addOrUpdate(bm, 'std-text', { label:'Text (Basis)',
|
||||||
|
content:`<div data-gjs-type="text" style="${css({'font-family':'Arial,sans-serif','font-size':'14px',color:'#0f172a',margin:'0 0 12px'})}">Absatztext.</div>` });
|
||||||
|
|
||||||
|
// IMAGE (Registriert als 'std-image')
|
||||||
|
addOrUpdate(bm, 'std-image', { label:'Bild (Basis)',
|
||||||
|
content:`<img data-gjs-type="image" src="https://placehold.co/600x300" alt="Bild" style="${css({width:'100%',height:'auto','max-width':'600px',border:'0',display:'block'})}">` });
|
||||||
|
|
||||||
|
// LINK (Registriert als 'std-link')
|
||||||
|
addOrUpdate(bm, 'std-link', { label:'Link (Basis)',
|
||||||
|
content:`<a href="#" data-gjs-type="link" style="${css({color:'#0ea5e9','text-decoration':'none','font-family':'Arial,sans-serif','font-size':'14px'})}">Hyperlink</a>` });
|
||||||
|
|
||||||
|
// SECTION (Registriert als 'std-section')
|
||||||
|
addOrUpdate(bm, 'std-section', { label:'Sektion',
|
||||||
|
content:`<section style="${css({padding:'20px'})}" data-gjs-type="section">
|
||||||
|
<div style="${css({'font-family':'Arial,sans-serif','font-size':'14px',color:'#0f172a'})}">Inhalt der Sektion.</div>
|
||||||
|
</section>` });
|
||||||
|
|
||||||
|
// COLUMN (Registriert als 'std-column')
|
||||||
|
addOrUpdate(bm, 'std-column', { label:'Spalte',
|
||||||
|
content:`<div style="${css({padding:'10px','min-height':'50px','border':'1px dashed #ccc'})}" data-gjs-type="column">
|
||||||
|
<div style="${css({'font-family':'Arial,sans-serif','font-size':'12px',color:'#555'})}">Spalteninhalt</div>
|
||||||
|
</div>` });
|
||||||
|
|
||||||
|
// BUTTON (Registriert als 'std-button')
|
||||||
|
addOrUpdate(bm, 'std-button', { label:'Button (Basis)',
|
||||||
|
content:`<a href="#" data-gjs-type="button" style="${css({display:'inline-block','background-color':'#0ea5e9',color:'#fff','text-decoration':'none',padding:'10px 18px','border-radius':'6px','font-family':'Arial,sans-serif','font-size':'14px'})}">Button</a>` });
|
||||||
|
|
||||||
|
// DIVIDER (Registriert als 'std-divider')
|
||||||
|
addOrUpdate(bm, 'std-divider',{ label:'Trenner (Basis)',
|
||||||
|
content:`<hr data-gjs-type="divider" style="${css({border:'0',height:'1px','background-color':'#e2e8f0',margin:'16px 0'})}">` });
|
||||||
|
|
||||||
|
// MAP (Registriert als 'std-map')
|
||||||
|
addOrUpdate(bm, 'std-map', { label:'Karte',
|
||||||
|
content:`<iframe data-gjs-type="map" src="https://maps.google.com/maps?width=100%25&height=600&hl=de&q=Berlin&t=&z=14&ie=UTF8&iwloc=B&output=embed" width="100%" height="300" frameborder="0" style="${css({'border':'0',width:'100%',height:'300px'})}"></iframe>` });
|
||||||
|
|
||||||
|
// Löst die notwendigen Events für den Bridge Core / Cleanup aus.
|
||||||
|
editor.trigger('block:add');
|
||||||
|
|
||||||
|
log('SUCCESS', `Erfolgreich ${ALL_STANDARD_BLOCK_IDS.length} Standardblöcke in Kategorie '${TARGET_CAT_LABEL}' registriert.`, '#008000', 'info', true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exportiere für den manuellen Aufruf in bridge-core.js
|
||||||
|
window.BridgeBlocksStandard = {
|
||||||
|
IDS: ALL_STANDARD_BLOCK_IDS,
|
||||||
|
register: pluginFunction,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registriere das Modul als GrapesJS Plugin (für den Fall, dass es doch anderswo benötigt wird)
|
||||||
|
if (B && B.registerGrapesJSPlugin) {
|
||||||
|
B.registerGrapesJSPlugin('bridge-blocks-standard', window.BridgeBlocksStandard.register);
|
||||||
|
log('PLUGIN REGISTER', `'bridge-blocks-standard' erfolgreich zur Bridge Plugin Registry hinzugefügt.`, '#008000');
|
||||||
|
} else {
|
||||||
|
log('CRITICAL ERROR', `BridgeParts oder registerGrapesJSPlugin fehlt! Plugin-Registrierung gescheitert.`, 'red', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
265
public/assets/js/bridge/categorization-cleanup.js
Normal file
265
public/assets/js/bridge/categorization-cleanup.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/* /assets/js/bridge/categorization-cleanup.js (FINAL & LOG-KONTROLLIERT) */
|
||||||
|
(function(B){
|
||||||
|
|
||||||
|
if (!B || typeof grapesjs === 'undefined') return;
|
||||||
|
|
||||||
|
// 🛑 NEUER NAME: Dies wird das Plugin in GrapesJS registrieren
|
||||||
|
const PluginName = 'bridge-categorization-cleanup';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 🎯 NEU: LOKALE LOG-KONFIGURATION UND WRAPPER
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Setzen Sie dies auf 'false' in der config.js oder hier, um alle Logs NUR für dieses Plugin zu deaktivieren.
|
||||||
|
if (B.LOG_CONFIG && B.LOG_CONFIG.PLUGINS) {
|
||||||
|
B.LOG_CONFIG.PLUGINS[PluginName] = false; // <-- HIER IST IHR SCHALTER
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEUER LOKALER WRAPPER, der die zentrale B.log Funktion verwendet:
|
||||||
|
const log = (type, message, color = '#228B22', logType = 'info', force = false) => {
|
||||||
|
// Wir verwenden B.log, das die B.LOG_CONFIG.PLUGINS[PluginName] prüft
|
||||||
|
if (typeof B.log === 'function') {
|
||||||
|
B.log(PluginName, `[${type}] ${message}`, color, logType, force);
|
||||||
|
} else if (logType === 'error') {
|
||||||
|
// Fallback für kritische Fehler, wenn B.log fehlt (sollte nicht passieren)
|
||||||
|
console.error(`%c[${PluginName} - ${type}] %c${message}`, `color:red; font-weight:bold;`, 'color:inherit;');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 🛑 WICHTIG: Liste aller unerwünschten IDs/Labels
|
||||||
|
const UNWANTED_UNCATEGORIZED_ID = 'Uncategorized';
|
||||||
|
// Fügen Sie die gängigen IDs des gjs-preset-newsletter hinzu
|
||||||
|
const PRESET_UNWANTED_IDS = ['Basic', 'Layout', 'Extra', 'Components', 'Forms'];
|
||||||
|
|
||||||
|
// Alle IDs, die gelöscht werden müssen. Enthält NICHT mehr 'Bibliothek'.
|
||||||
|
const ALL_FORBIDDEN_CAT_IDS = [UNWANTED_UNCATEGORIZED_ID, ...PRESET_UNWANTED_IDS];
|
||||||
|
|
||||||
|
const UNWANTED_BLOCK_ID = 'gjs-lbr-block-disabled';
|
||||||
|
const UNWANTED_BLOCK_LABEL = 'Bibliothek-disabled';
|
||||||
|
|
||||||
|
const FALLBACK_CATEGORY_ID = 'mysnips';
|
||||||
|
|
||||||
|
const CUSTOM_BLOCK_IDS = (window.BridgeBlocksCustom && window.BridgeBlocksCustom.IDS) || [];
|
||||||
|
|
||||||
|
let normalizationRunCount = 0;
|
||||||
|
let normalizationIsRunning = false;
|
||||||
|
let maxNormalizationRuns = 5;
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// HILFSFUNKTION: Entfernt hartnäckige Kategorie-DOM-Elemente
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const zapUnwantedCategoryDom = (editor) => {
|
||||||
|
const blocksPanelEl = editor.BlockManager.getContainer();
|
||||||
|
if (blocksPanelEl) {
|
||||||
|
let removedCount = 0;
|
||||||
|
blocksPanelEl.querySelectorAll('.gjs-block-cat').forEach(catEl => {
|
||||||
|
const catTitleEl = catEl.querySelector('.gjs-title');
|
||||||
|
|
||||||
|
if (catTitleEl) {
|
||||||
|
const catTitle = catTitleEl.textContent.trim();
|
||||||
|
// Prüft, ob der Titel eine der unerwünschten IDs ist (z.B. 'Basic')
|
||||||
|
if (ALL_FORBIDDEN_CAT_IDS.includes(catTitle)) {
|
||||||
|
catEl.remove();
|
||||||
|
removedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (removedCount > 0) {
|
||||||
|
log('DOM FIX', `${removedCount} unerwünschte Kategorie-DOM-Elemente entfernt.`, 'orange', 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Hilfsfunktion: Erzwingt das Neu-Rendern der Block-View
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const renderBlocks = (editor) => {
|
||||||
|
zapUnwantedCategoryDom(editor);
|
||||||
|
log('RENDER', 'DOM-Cleanup ausgeführt.', 'green');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 1. Funktion zum Ausblenden/Normalisieren der Kategorien (Kernlogik)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const normalizeCategories = (editor) => {
|
||||||
|
if (normalizationIsRunning || normalizationRunCount >= maxNormalizationRuns) {
|
||||||
|
if (normalizationRunCount >= maxNormalizationRuns) {
|
||||||
|
log('SKIP', `normalizeCategories übersprungen: Maximale Läufe (${maxNormalizationRuns}) erreicht.`, 'red', 'warn');
|
||||||
|
} else {
|
||||||
|
log('SKIP', 'normalizeCategories übersprungen: Läuft bereits.', 'red', 'warn');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizationIsRunning = true;
|
||||||
|
normalizationRunCount++;
|
||||||
|
// Nur das Start-Log kann eine Gruppen-Markierung sein
|
||||||
|
log('START', `Starte normalizeCategories Run #${normalizationRunCount}`, '#191970');
|
||||||
|
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
const config = B.CATEGORY_CONFIG || {};
|
||||||
|
const configuredCategoryIds = Object.keys(config);
|
||||||
|
|
||||||
|
log('CONFIG', `Konfigurierte Kategorie-IDs: ${configuredCategoryIds.join(', ')}`, '#555555');
|
||||||
|
|
||||||
|
// --- A. Explizites Erstellen der Kategorien (Sicherheits-Fallback) ---
|
||||||
|
const catsToEnsure = new Set(configuredCategoryIds);
|
||||||
|
|
||||||
|
catsToEnsure.forEach(catId => {
|
||||||
|
const catConf = config[catId];
|
||||||
|
if (!catConf) return;
|
||||||
|
|
||||||
|
if (!bm.getCategories().get(catId)) {
|
||||||
|
bm.getCategories().add({
|
||||||
|
id: catId,
|
||||||
|
label: catConf.label,
|
||||||
|
open: catConf.open !== false,
|
||||||
|
order: catConf.ord || 999
|
||||||
|
});
|
||||||
|
log('CAT FALLBACK', `Kategorie '${catId}' fehlte und wurde JETZT erstellt!`, 'red', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- B. Zwangszuweisung der Blöcke und Bereinigung von Blöcken ---
|
||||||
|
bm.getAll().each(block => {
|
||||||
|
const id = block.get('id');
|
||||||
|
const label = block.get('label');
|
||||||
|
let catId = block.get('category');
|
||||||
|
|
||||||
|
if (typeof catId === 'object' && catId.id) {
|
||||||
|
catId = catId.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Lösche unerwünschten hartnäckigen Block (DEAKTIVIERT)
|
||||||
|
if (id === UNWANTED_BLOCK_ID || label === UNWANTED_BLOCK_LABEL) {
|
||||||
|
// ... (Block removal logic commented out)
|
||||||
|
// log('BLOCK REMOVE', `Lösche unerwünschten Block: ${id}`, 'red', 'warn');
|
||||||
|
// bm.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Setze Blöcke ohne oder mit unerwünschter/unbekannter Kategorie auf den Fallback (mysnips)
|
||||||
|
const isUnconfiguredOrForbidden = !catId || !configuredCategoryIds.includes(catId) || ALL_FORBIDDEN_CAT_IDS.includes(catId);
|
||||||
|
|
||||||
|
if (isUnconfiguredOrForbidden) {
|
||||||
|
if (id) {
|
||||||
|
log('BLOCK FIX', `Block '${id}' ('${label}') verschoben nach '${FALLBACK_CATEGORY_ID}' (Ursprüngliche Kat ID: ${catId || 'keine/leer'}).`, 'orange', 'warn');
|
||||||
|
block.set('category', FALLBACK_CATEGORY_ID);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('BLOCK OK', `Block '${id}' ('${label}') bleibt in Kategorie '${catId}'.`, 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Custom Blocks schützen
|
||||||
|
if (CUSTOM_BLOCK_IDS.includes(id) && catId !== 'bausteine') {
|
||||||
|
log('BLOCK FIX', `Custom Block '${id}' auf 'bausteine' korrigiert.`, 'orange', 'warn');
|
||||||
|
block.set('category', 'bausteine');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- C. Kategorien erzwingen, Label korrigieren und Löschen von Modellen ---
|
||||||
|
const categories = bm.getCategories().models || bm.getCategories();
|
||||||
|
let visibleCategories = [];
|
||||||
|
|
||||||
|
categories.forEach(catModel => {
|
||||||
|
const catId = catModel.get('id');
|
||||||
|
const catConf = config[catId];
|
||||||
|
|
||||||
|
// 1. Aggressives Löschen von unerwünschten Preset-Kategorien
|
||||||
|
if (ALL_FORBIDDEN_CAT_IDS.includes(catId)) {
|
||||||
|
log('CAT REMOVE', `Lösche unerwünschtes Category Model: ${catId} (Da in ALL_FORBIDDEN_CAT_IDS).`, 'red', 'error');
|
||||||
|
bm.getCategories().remove(catModel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeConf = catConf;
|
||||||
|
|
||||||
|
// 2. Finde eine existierende, aber nicht konfigurierte Kategorie, und blende sie aus
|
||||||
|
if (!activeConf && catId) {
|
||||||
|
log('CAT HIDE', `Kategorie '${catId}' existiert, ist aber nicht in CATEGORY_CONFIG. Wird ausgeblendet.`, 'orange', 'warn');
|
||||||
|
catModel.set('visible', false);
|
||||||
|
catModel.set('open', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Korrigiere Label, Sortierung und Sichtbarkeit (Konfigurierte Kategorien)
|
||||||
|
if (activeConf) {
|
||||||
|
const oldLabel = catModel.get('label');
|
||||||
|
const newLabel = activeConf.label;
|
||||||
|
const visibility = true;
|
||||||
|
|
||||||
|
if (oldLabel !== newLabel) {
|
||||||
|
log('CAT UPDATE', `Korrigiere Label von '${catId}' von '${oldLabel}' auf '${newLabel}'.`, '#00BFFF');
|
||||||
|
catModel.set('label', newLabel, { silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
catModel.set('visible', visibility);
|
||||||
|
catModel.set('open', activeConf.open !== false);
|
||||||
|
catModel.set('order', activeConf.ord || 999);
|
||||||
|
visibleCategories.push(catId);
|
||||||
|
|
||||||
|
log('CAT FINAL', `Kategorie '${catId}' auf Visible: ${visibility}, Order: ${catModel.get('order')}.`, 'green');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- D. Cleanup und Neu-Sortierung erzwingen ---
|
||||||
|
categories.sort((a, b) => (a.get('order') || 999) - (b.get('order') || 999));
|
||||||
|
|
||||||
|
B.sortBlocksByPrefixAndLabel && B.sortBlocksByPrefixAndLabel(bm.getAll().models);
|
||||||
|
|
||||||
|
// DOM Cleanup wird über renderBlocks aufgerufen
|
||||||
|
renderBlocks(editor);
|
||||||
|
|
||||||
|
log('END', `Kategorisierung abgeschlossen. Sichtbare Kategorien (Modelle): ${visibleCategories.sort().join(', ')}.`, 'green', 'info', true); // FINAL Log ist forced=true für Abschlussmeldung
|
||||||
|
|
||||||
|
normalizationIsRunning = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// GrapesJS Plugin Registrierung
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
grapesjs.plugins.add(PluginName, (editor, opts = {}) => {
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
|
||||||
|
// 1. Initialer, verspäteter Lauf bei Ladevorgang
|
||||||
|
editor.on('load', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
log('FINAL RUN', `Starte finalen Normalisierungslauf nach 2500ms.`, 'orange', 'warn');
|
||||||
|
normalizeCategories(editor);
|
||||||
|
}, 2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. WATCHDOG gegen Label-Überschreibung oder unerwünschte Adds
|
||||||
|
bm.getCategories().on('add change:label', (categoryModel) => {
|
||||||
|
const catId = categoryModel.get('id');
|
||||||
|
const newLabel = categoryModel.get('label');
|
||||||
|
const expectedLabel = B.CATEGORY_CONFIG?.[catId]?.label;
|
||||||
|
|
||||||
|
// WATCHDOG-ADD
|
||||||
|
if (ALL_FORBIDDEN_CAT_IDS.includes(catId)) {
|
||||||
|
log('WATCHDOG-ADD', `Unerwünschte Kategorie '${catId}' wurde hinzugefügt! Starte Sofort-Korrektur.`, 'red', 'error');
|
||||||
|
bm.getCategories().remove(categoryModel);
|
||||||
|
setTimeout(() => normalizeCategories(editor), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WATCHDOG-LABEL
|
||||||
|
if (expectedLabel && newLabel !== expectedLabel) {
|
||||||
|
log('WATCHDOG-CHANGE', `Externe Label-Manipulation von '${catId}' erkannt: Korrigiere von '${newLabel}' auf '${expectedLabel}'.`, 'orange', 'warn');
|
||||||
|
categoryModel.set('label', expectedLabel, { silent: true });
|
||||||
|
setTimeout(() => normalizeCategories(editor), 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exporte beibehalten, falls sie in bridge-core.js verwendet werden
|
||||||
|
B.normalizeCategories = normalizeCategories;
|
||||||
|
B.renderBlocks = renderBlocks;
|
||||||
|
|
||||||
|
log('INIT', 'Master-Koordinator registriert.', '#008080');
|
||||||
|
});
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/* /assets/js/bridge/categorization-master.js (FINALE KORREKTUR V3: Entfernt aggressives bm.render()) */
|
||||||
|
(function(B){
|
||||||
|
|
||||||
|
if (!B || typeof grapesjs === 'undefined') return;
|
||||||
|
|
||||||
|
const PluginName = 'bridge-categorization-master';
|
||||||
|
|
||||||
|
// 🛑 WICHTIG: Liste aller unerwünschten IDs/Labels
|
||||||
|
const UNWANTED_CATEGORY_ID = 'Bibliothek';
|
||||||
|
const UNWANTED_UNCATEGORIZED_ID = 'Uncategorized';
|
||||||
|
// Fügen Sie die gängigen IDs des gjs-preset-newsletter hinzu
|
||||||
|
const PRESET_UNWANTED_IDS = ['Basic', 'Layout', 'Extra', 'Components', 'Forms'];
|
||||||
|
|
||||||
|
// Alle IDs, die gelöscht werden müssen
|
||||||
|
const ALL_FORBIDDEN_CAT_IDS = [UNWANTED_CATEGORY_ID, UNWANTED_UNCATEGORIZED_ID, ...PRESET_UNWANTED_IDS];
|
||||||
|
|
||||||
|
const UNWANTED_BLOCK_ID = 'gjs-lbr-block';
|
||||||
|
const UNWANTED_BLOCK_LABEL = 'Bibliothek';
|
||||||
|
|
||||||
|
const FALLBACK_CATEGORY_ID = 'mysnips';
|
||||||
|
|
||||||
|
const CUSTOM_BLOCK_IDS = (window.BridgeBlocksCustom && window.BridgeBlocksCustom.IDS) || [];
|
||||||
|
|
||||||
|
let normalizationRunCount = 0;
|
||||||
|
let normalizationIsRunning = false;
|
||||||
|
let maxNormalizationRuns = 5;
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// HILFSFUNKTION: Entfernt hartnäckige Kategorie-DOM-Elemente
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const zapUnwantedCategoryDom = (editor) => {
|
||||||
|
const blocksPanelEl = editor.Panels.getPanel('blocks')?.get('el');
|
||||||
|
if (blocksPanelEl) {
|
||||||
|
let removedCount = 0;
|
||||||
|
blocksPanelEl.querySelectorAll('.gjs-block-cat').forEach(catEl => {
|
||||||
|
const catTitleEl = catEl.querySelector('.gjs-title');
|
||||||
|
|
||||||
|
if (catTitleEl) {
|
||||||
|
const catTitle = catTitleEl.textContent.trim();
|
||||||
|
// Prüfe auf unerwünschte Titel (sowohl Standard als auch Presets)
|
||||||
|
if (ALL_FORBIDDEN_CAT_IDS.includes(catTitle) || catTitle === UNWANTED_UNCATEGORIZED_ID || catTitle === UNWANTED_CATEGORY_ID) {
|
||||||
|
catEl.remove();
|
||||||
|
removedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (removedCount > 0) {
|
||||||
|
console.warn(`[${PluginName}][DOM Fix] ${removedCount} unerwünschte Kategorie-DOM-Elemente entfernt.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Hilfsfunktion: Erzwingt das Neu-Rendern der Block-View (NUR DOM-CLEANUP)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const renderBlocks = (editor) => {
|
||||||
|
// 🛑 KRITISCHE KORREKTUR: Entferne bm.render() – nur DOM-Cleanup ist hier nötig,
|
||||||
|
// da das Setzen der Model-Eigenschaften (label, visible) das Rendering übernehmen sollte.
|
||||||
|
zapUnwantedCategoryDom(editor);
|
||||||
|
console.log(`[${PluginName}][Render] DOM-Cleanup ausgeführt.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 1. Funktion zum Ausblenden/Normalisieren der Kategorien (Kernlogik)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const normalizeCategories = (editor) => {
|
||||||
|
if (normalizationIsRunning || normalizationRunCount >= maxNormalizationRuns) {
|
||||||
|
if (normalizationRunCount >= maxNormalizationRuns) {
|
||||||
|
console.warn(`[${PluginName}] normalizeCategories übersprungen: Maximale Läufe (${maxNormalizationRuns}) erreicht.`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[${PluginName}] normalizeCategories übersprungen: Läuft bereits.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizationIsRunning = true;
|
||||||
|
normalizationRunCount++;
|
||||||
|
console.group(`[${PluginName}] normalizeCategories Run #${normalizationRunCount}`);
|
||||||
|
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
const config = B.CATEGORY_CONFIG || {};
|
||||||
|
const configuredCategoryIds = Object.keys(config);
|
||||||
|
|
||||||
|
|
||||||
|
// 🛑 NEUER FALLBACK-FIX: Stellen Sie sicher, dass alle konfigurierten Kategorien existieren,
|
||||||
|
// bevor Blöcke zugewiesen werden.
|
||||||
|
configuredCategoryIds.forEach(catId => {
|
||||||
|
const catConf = config[catId];
|
||||||
|
if (!bm.getCategories().get(catId)) {
|
||||||
|
bm.getCategories().add({
|
||||||
|
id: catId,
|
||||||
|
label: catConf.label,
|
||||||
|
open: catConf.open !== false,
|
||||||
|
order: catConf.ord || 999
|
||||||
|
});
|
||||||
|
// Nur als Warnung, da dies bei 'mysnips' oft passiert, wenn es leer ist.
|
||||||
|
console.warn(`[${PluginName}][Cat Fallback] Kategorie '${catId}' wurde nachträglich erstellt.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- A. Zwangszuweisung der Blöcke und Bereinigung von Blöcken ---
|
||||||
|
bm.getAll().each(block => {
|
||||||
|
const id = block.get('id');
|
||||||
|
const label = block.get('label');
|
||||||
|
let catId = block.get('category');
|
||||||
|
|
||||||
|
if (typeof catId === 'object' && catId.id) {
|
||||||
|
catId = catId.id; // Behandle Category-Objekte
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Lösche unerwünschten hartnäckigen Block (z.B. gjs-lbr-block)
|
||||||
|
if (id === UNWANTED_BLOCK_ID || label === UNWANTED_BLOCK_LABEL) {
|
||||||
|
console.log(`[${PluginName}][Block Fix] Unerwünschter Block '${id}' ('${label}') entfernt.`);
|
||||||
|
bm.remove(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Setze Blöcke ohne oder mit unerwünschter/unbekannter Kategorie auf den Fallback (mysnips)
|
||||||
|
// HINWEIS: 'custom' ist in configuredCategoryIds, falls es noch keine Blöcke von der API hat.
|
||||||
|
if (!catId || !configuredCategoryIds.includes(catId) || ALL_FORBIDDEN_CAT_IDS.includes(catId)) {
|
||||||
|
// Nur wenn der Block nicht leer ist
|
||||||
|
if (id) {
|
||||||
|
console.log(`[${PluginName}][Block Fix] Block '${id}' ('${label}') verschoben nach '${FALLBACK_CATEGORY_ID}' (von Kat: ${catId || 'keine'}).`);
|
||||||
|
block.set('category', FALLBACK_CATEGORY_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Custom Blocks schützen
|
||||||
|
if (CUSTOM_BLOCK_IDS.includes(id) && catId !== 'bausteine') {
|
||||||
|
block.set('category', 'bausteine');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- B. Kategorien erzwingen, Label korrigieren und Löschen von Modellen ---
|
||||||
|
const categories = bm.getCategories().models || bm.getCategories();
|
||||||
|
let visibleCategories = [];
|
||||||
|
|
||||||
|
// Gehe alle Category Models durch
|
||||||
|
categories.forEach(catModel => {
|
||||||
|
const catId = catModel.get('id');
|
||||||
|
const catConf = config[catId];
|
||||||
|
|
||||||
|
// Aggressives Löschen von unerwünschten Preset-Kategorien
|
||||||
|
if (ALL_FORBIDDEN_CAT_IDS.includes(catId)) {
|
||||||
|
console.warn(`[${PluginName}][Cat Fix] Lösche unerwünschtes Category Model: ${catId}`);
|
||||||
|
bm.getCategories().remove(catModel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finde eine existierende, aber nicht konfigurierte Kategorie, und blende sie aus
|
||||||
|
// WICHTIG: mysnips ist der FALLBACK_CATEGORY_ID, hier darf es NICHT ausgeblendet werden.
|
||||||
|
if (!catConf && catId && catId !== FALLBACK_CATEGORY_ID) {
|
||||||
|
catModel.set('visible', false);
|
||||||
|
catModel.set('open', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Korrigiere Label, Sortierung und Sichtbarkeit der konfigurierten Kategorien
|
||||||
|
if (catConf) {
|
||||||
|
// 🛑 KRITISCHER FIX: Garantiertes Setzen des korrekten Labels (LÖST KLEINSCHREIBUNGS-PROBLEM)
|
||||||
|
if (catModel.get('label') !== catConf.label) {
|
||||||
|
console.log(`[${PluginName}][Cat Fix] Korrigiere Label von '${catId}' auf '${catConf.label}'.`);
|
||||||
|
catModel.set('label', catConf.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ** Das Setzen von 'visible' und 'open' sollte die UI-Aktualisierung (Kategorie anzeigen) auslösen. **
|
||||||
|
catModel.set('visible', true);
|
||||||
|
catModel.set('open', catConf.open !== false);
|
||||||
|
catModel.set('order', catConf.ord || 999);
|
||||||
|
visibleCategories.push(catId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- C. Cleanup und Neu-Rendern erzwingen ---
|
||||||
|
categories.sort((a, b) => (a.get('order') || 999) - (b.get('order') || 999));
|
||||||
|
|
||||||
|
B.sortBlocksByPrefixAndLabel && B.sortBlocksByPrefixAndLabel(bm.getAll().models);
|
||||||
|
|
||||||
|
// 🛑 KRITISCH: Rendering WIRD NICHT mehr erzwungen – nur DOM Cleanup.
|
||||||
|
renderBlocks(editor);
|
||||||
|
|
||||||
|
console.log(`Kategorisierung abgeschlossen. Sichtbare Kategorien: ${visibleCategories.sort().join(', ')}.`);
|
||||||
|
console.groupEnd();
|
||||||
|
normalizationIsRunning = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// GrapesJS Plugin Registrierung
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
grapesjs.plugins.add(PluginName, (editor, opts = {}) => {
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
|
||||||
|
// 1. Initialer, verspäteter Lauf bei Ladevorgang
|
||||||
|
editor.on('load', () => {
|
||||||
|
// FINALER LAUF: Läuft, wenn ALLE Standard-Plugins fertig sind
|
||||||
|
setTimeout(() => {
|
||||||
|
console.warn(`[${PluginName}][FINAL RUN] Starte finalen Normalisierungslauf nach 2500ms.`);
|
||||||
|
normalizeCategories(editor);
|
||||||
|
}, 2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. WATCHDOG gegen Label-Überschreibung oder unerwünschte Adds
|
||||||
|
bm.getCategories().on('add change:label', (categoryModel) => {
|
||||||
|
const catId = categoryModel.get('id');
|
||||||
|
const newLabel = categoryModel.get('label');
|
||||||
|
const expectedLabel = B.CATEGORY_CONFIG?.[catId]?.label;
|
||||||
|
|
||||||
|
// WATCHDOG-ADD: Entfernt unerwünschte Kategorien sofort, falls sie erstellt werden
|
||||||
|
if (ALL_FORBIDDEN_CAT_IDS.includes(catId)) {
|
||||||
|
console.error(`[${PluginName}][WATCHDOG-ADD] Unerwünschte Kategorie '${catId}' wurde hinzugefügt! Starte Sofort-Korrektur.`);
|
||||||
|
bm.getCategories().remove(categoryModel);
|
||||||
|
setTimeout(() => normalizeCategories(editor), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WATCHDOG-LABEL: Korrigiert falsche Labels (z.B. "bausteine" -> "🧱 Bausteine")
|
||||||
|
if (expectedLabel && newLabel !== expectedLabel) {
|
||||||
|
console.warn(`[${PluginName}][WATCHDOG-CHANGE] Externe Label-Manipulation von '${catId}' erkannt: Korrigiere von '${newLabel}' auf '${expectedLabel}'.`);
|
||||||
|
|
||||||
|
// Sofortiges Zurücksetzen des Labels auf den korrekten Wert
|
||||||
|
categoryModel.set('label', expectedLabel, { silent: true });
|
||||||
|
|
||||||
|
// Triggere einen Normalize-Lauf, damit die UI die Korrektur sieht und die Sortierung passt.
|
||||||
|
setTimeout(() => normalizeCategories(editor), 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
B.normalizeCategories = normalizeCategories;
|
||||||
|
B.renderBlocks = renderBlocks;
|
||||||
|
|
||||||
|
console.log(`[${PluginName}] Master-Koordinator registriert.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
14
public/assets/js/bridge/categorization-master.js
Normal file
14
public/assets/js/bridge/categorization-master.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/* /assets/js/bridge/categorization-master.js (PLATZHALTER - LOGIK IN CLEANUP.JS VERSCHOBEN) */
|
||||||
|
(function(B){
|
||||||
|
|
||||||
|
if (!B || typeof grapesjs === 'undefined') return;
|
||||||
|
|
||||||
|
const PluginName = 'bridge-categorization-master';
|
||||||
|
|
||||||
|
// Dies ist nun ein leeres Plugin. Die Logik wurde nach categorization-cleanup.js verschoben.
|
||||||
|
grapesjs.plugins.add(PluginName, (editor, opts = {}) => {
|
||||||
|
// Leere Plugin-Funktion. Führt keine Aufräumarbeiten, Normalisierung oder Exporte durch.
|
||||||
|
console.log(`[${PluginName}] Plugin existiert (Logik nach cleanup.js verschoben).`);
|
||||||
|
});
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
55
public/assets/js/bridge/category-config.js
Normal file
55
public/assets/js/bridge/category-config.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/* /assets/js/bridge/category-config.js (FINALE KORREKTUR DER BLAUPASE) */
|
||||||
|
(function(B) {
|
||||||
|
if (!B) return;
|
||||||
|
|
||||||
|
// NEU: Map, die Ressourcentyp ('kind') zu API-Basis-URL zuordnet. Wird von library-api.js gelesen.
|
||||||
|
B.RESOURCE_API_BASES = B.RESOURCE_API_BASES || {};
|
||||||
|
const API_BASE_DEFAULT = (B.API_BASE || '/api/editor');
|
||||||
|
|
||||||
|
// DEFINITION DER ZIEL-KATEGORIEN
|
||||||
|
B.CATEGORY_CONFIG = {
|
||||||
|
// --- 1. BIBLIOTHEK (mysnips) ---
|
||||||
|
mysnips: {
|
||||||
|
// ... (Bleibt unverändert, da es kein API-Async-Laden nutzt)
|
||||||
|
ord: 20,
|
||||||
|
open: false,
|
||||||
|
label: '📚 Bibliothek',
|
||||||
|
files: ['blocks-standard.js'],
|
||||||
|
registration_mode: 'sync',
|
||||||
|
},
|
||||||
|
// --- 2. BAUSTEINE (bausteine) ---
|
||||||
|
bausteine: {
|
||||||
|
// ... (Bleibt unverändert, da es kein API-Async-Laden nutzt)
|
||||||
|
ord: 10,
|
||||||
|
open: true,
|
||||||
|
label: '🧱 Bausteine',
|
||||||
|
files: ['blocks-custom.js'],
|
||||||
|
registration_mode: 'sync',
|
||||||
|
},
|
||||||
|
// --- 3. API Custom-Blocks (Standard-API) ----
|
||||||
|
custom: {
|
||||||
|
ord: 1,
|
||||||
|
label: 'Custom',
|
||||||
|
open: true,
|
||||||
|
files: ['library-api.js','blocks-api.js'],
|
||||||
|
registration_mode: 'async',
|
||||||
|
// NEU: API-Konfiguration für diese Kategorie
|
||||||
|
api_config: {
|
||||||
|
base_url: '/api/editor', // Nutzt die Standard-API
|
||||||
|
resources: ['templates','sections', 'blocks', 'snippets'] // Ressourcen, die von dort geladen werden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Initialisierung der zentralen RESOURCE_API_BASES Map ---
|
||||||
|
// Diese Logik stellt sicher, dass library-api.js weiß, welchen Endpunkt es für welchen "kind" nutzen muss.
|
||||||
|
Object.values(B.CATEGORY_CONFIG).forEach(config => {
|
||||||
|
if (config.api_config && Array.isArray(config.api_config.resources)) {
|
||||||
|
const baseUrl = config.api_config.base_url || API_BASE_DEFAULT;
|
||||||
|
config.api_config.resources.forEach(resourceKind => {
|
||||||
|
B.RESOURCE_API_BASES[resourceKind] = baseUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
28
public/assets/js/bridge/category-config.js WORKED
Normal file
28
public/assets/js/bridge/category-config.js WORKED
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/* /assets/js/bridge/category-config.js (FINAL: Zentrale Konfiguration) */
|
||||||
|
(function(w){
|
||||||
|
var B = w.BridgeParts = w.BridgeParts || {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Konfiguration für Block-Kategorien und deren Sortierprioritäten.
|
||||||
|
*/
|
||||||
|
B.CATEGORY_CONFIG = {
|
||||||
|
// Prio 1
|
||||||
|
'lib-templates': { label:'Bibliothek: Templates (Ref)', ord: 1, open: true },
|
||||||
|
|
||||||
|
// Prio 2 (Custom)A
|
||||||
|
'custom': { label:'Custom', ord: 2, open: true },
|
||||||
|
'custom-fix': { label:'Custom', ord: 2, open: true },
|
||||||
|
'custom-flex': { label:'Custom', ord: 2, open: true },
|
||||||
|
|
||||||
|
// Prio 3 (Bausteine)
|
||||||
|
'bausteine': { label:'Bausteine', ord: 3, open: true },
|
||||||
|
|
||||||
|
// Prio 4 (Bibliothek)
|
||||||
|
'mysnips': { label:'Bibliothek', ord: 4, open: true },
|
||||||
|
|
||||||
|
// INTERNE (Werden später im Plugin auf Prio 2 umgeleitet und sortiert)
|
||||||
|
'lib-sections': { label:'Bibliothek: Sections', ord: 99, open: true },
|
||||||
|
'lib-blocks': { label:'Bibliothek: Blöcke', ord: 99, open: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
})(window);
|
||||||
143
public/assets/js/bridge/custom-blocks-plugin.js
Normal file
143
public/assets/js/bridge/custom-blocks-plugin.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/* /assets/js/bridge/custom-blocks-plugin.js (FINALE VERSION 2.0: Erzwungene Sortierung) */
|
||||||
|
(function(gjs, B){
|
||||||
|
if (!gjs || !B || !B.CATEGORY_CONFIG) return;
|
||||||
|
|
||||||
|
// --- 1. Block-Sortierlogik (Wieder logische Gewichte: 1 < 2 < 3) -----------------------
|
||||||
|
const getSortWeight = (id) => {
|
||||||
|
// Logisch: Section (1) < Block (2) < Snippet (3)
|
||||||
|
if (id.startsWith('custom-section-') || id.startsWith('lib-sec-')) return 1;
|
||||||
|
if (id.startsWith('custom-block-') || id.startsWith('lib-blk-')) return 2;
|
||||||
|
if (id.startsWith('custom-snippet-') || id.startsWith('snip-')) return 3;
|
||||||
|
return 99;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortBlocksByPrefixAndLabel = (blocks) => {
|
||||||
|
blocks.sort((a, b) => {
|
||||||
|
const aId = String((a.get ? a.get('id') : a.id) || '');
|
||||||
|
const bId = String((b.get ? b.get('id') : b.id) || '');
|
||||||
|
// Hier nutzen wir die Rohdaten (aSnippet, bSnippet)
|
||||||
|
const aLabel = String((a.get ? a.get('label') : a.label) || '').toLowerCase();
|
||||||
|
const bLabel = String((b.get ? b.get('label') : b.label) || '').toLowerCase();
|
||||||
|
|
||||||
|
const aWeight = getSortWeight(aId);
|
||||||
|
const bWeight = getSortWeight(bId);
|
||||||
|
|
||||||
|
// 1. Sortierung nach Gewicht (1, 2, 3)
|
||||||
|
if (aWeight !== bWeight) return aWeight - bWeight;
|
||||||
|
|
||||||
|
// 2. Sortierung alphabetisch (a vor b)
|
||||||
|
if (aLabel < bLabel) return -1;
|
||||||
|
if (aLabel > bLabel) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
gjs.plugins.add('bridge-custom-blocks', (editor, opts = {}) => {
|
||||||
|
const config = B.CATEGORY_CONFIG;
|
||||||
|
const validCatIds = Object.keys(config);
|
||||||
|
|
||||||
|
// IDs zur internen Zuweisung
|
||||||
|
const CAT_CUSTOM_MAIN_ID = 'custom';
|
||||||
|
const CAT_BAUSTEINE_ID = 'bausteine';
|
||||||
|
const CAT_BIBLIOTHEK_ID = 'mysnips';
|
||||||
|
const CAT_LIB_TEMPLATES_ID = 'lib-templates';
|
||||||
|
|
||||||
|
|
||||||
|
const normalizeAndSort = (ed) => {
|
||||||
|
try {
|
||||||
|
const bm = ed.BlockManager;
|
||||||
|
const categories = bm.getCategories ? bm.getCategories() : null;
|
||||||
|
|
||||||
|
let allBlocks = bm.getAll().models || bm.getAll();
|
||||||
|
let customBlocksArray = [];
|
||||||
|
let otherBlocksArray = [];
|
||||||
|
|
||||||
|
// 1. Blöcke neu kategorisieren & trennen
|
||||||
|
(allBlocks || []).forEach(b => {
|
||||||
|
const id = String((b.get ? b.get('id') : b.id) || '');
|
||||||
|
let targetCatId = null;
|
||||||
|
|
||||||
|
if (id.startsWith('lib-tpl-ref-')) {
|
||||||
|
targetCatId = CAT_LIB_TEMPLATES_ID;
|
||||||
|
} else if (id.startsWith('custom-') || id.startsWith('lib-sec-') || id.startsWith('lib-blk-') || id.startsWith('snip-')) {
|
||||||
|
targetCatId = CAT_CUSTOM_MAIN_ID;
|
||||||
|
} else if (id.startsWith('blk-') || id.startsWith('std-')) {
|
||||||
|
targetCatId = CAT_BAUSTEINE_ID;
|
||||||
|
} else if (!b.get('category')) {
|
||||||
|
targetCatId = CAT_BIBLIOTHEK_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetCatId) {
|
||||||
|
b.set('category', targetCatId);
|
||||||
|
if (targetCatId === CAT_CUSTOM_MAIN_ID) {
|
||||||
|
customBlocksArray.push(b);
|
||||||
|
} else {
|
||||||
|
otherBlocksArray.push(b);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Blöcke, die nicht zugewiesen wurden (z.B. basic/extra), behalten
|
||||||
|
otherBlocksArray.push(b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Block-Sortierung INNERHALB der "custom" Kategorie
|
||||||
|
if (customBlocksArray.length > 0) {
|
||||||
|
sortBlocksByPrefixAndLabel(customBlocksArray);
|
||||||
|
console.log('[DEBUG PLUGIN] Custom Blocks intern sortiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEU: Gesamte BlockManager-Kollektion mit sortierten Custom-Blöcken überschreiben
|
||||||
|
// Wir nehmen alle sortierten Custom-Blöcke und fügen die anderen Blöcke danach an.
|
||||||
|
const newBlockOrder = customBlocksArray.concat(otherBlocksArray);
|
||||||
|
|
||||||
|
// Dieser Hack sollte die Reihenfolge in der Seitenleiste erzwingen.
|
||||||
|
if (bm.getAll().reset) {
|
||||||
|
bm.getAll().reset(newBlockOrder);
|
||||||
|
console.log('[DEBUG PLUGIN] Gesamte Block-Kollektion mit neuer Sortierung überschrieben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Kategorien Aufräumen & Sortieren (wie zuvor)
|
||||||
|
if (categories && categories.models) {
|
||||||
|
// Aufräumen
|
||||||
|
categories.models.slice().forEach(cat => {
|
||||||
|
const catId = (cat.get('id') || cat.id || '').toLowerCase();
|
||||||
|
if (!validCatIds.includes(catId) && catId !== 'basic' && catId !== 'extra') {
|
||||||
|
categories.remove(cat);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Labels korrigieren und Kategorie-Sortierung erzwingen
|
||||||
|
categories.models.forEach(cat => {
|
||||||
|
const catId = (cat.get('id') || cat.id || '').toLowerCase();
|
||||||
|
if (config[catId]) {
|
||||||
|
cat.set('label', config[catId].label);
|
||||||
|
cat.set('open', config[catId].open ?? true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const catOrder = (m) => config[String((m.get('id') || m.id)).toLowerCase()]?.ord || 99;
|
||||||
|
const arr = categories.models.slice().sort((a,b) => catOrder(a) - catOrder(b));
|
||||||
|
categories.reset(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Finaler DOM-Sweep
|
||||||
|
B.enforceCategoryOrder && B.enforceCategoryOrder(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.error('[CustomPlugin] Error during normalize:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Listener
|
||||||
|
editor.on('block:add block:remove block:reset', () => normalizeAndSort(editor));
|
||||||
|
editor.on('load', () => {
|
||||||
|
normalizeAndSort(editor);
|
||||||
|
setTimeout(() => normalizeAndSort(editor), 100);
|
||||||
|
setTimeout(() => normalizeAndSort(editor), 800);
|
||||||
|
setTimeout(() => normalizeAndSort(editor), 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(window.grapesjs, window.BridgeParts);
|
||||||
143
public/assets/js/bridge/custom-blocks-plugin.js WORKED
Normal file
143
public/assets/js/bridge/custom-blocks-plugin.js WORKED
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/* /assets/js/bridge/custom-blocks-plugin.js (FINALE VERSION 2.0: Erzwungene Sortierung) */
|
||||||
|
(function(gjs, B){
|
||||||
|
if (!gjs || !B || !B.CATEGORY_CONFIG) return;
|
||||||
|
|
||||||
|
// --- 1. Block-Sortierlogik (Wieder logische Gewichte: 1 < 2 < 3) -----------------------
|
||||||
|
const getSortWeight = (id) => {
|
||||||
|
// Logisch: Section (1) < Block (2) < Snippet (3)
|
||||||
|
if (id.startsWith('custom-section-') || id.startsWith('lib-sec-')) return 1;
|
||||||
|
if (id.startsWith('custom-block-') || id.startsWith('lib-blk-')) return 2;
|
||||||
|
if (id.startsWith('custom-snippet-') || id.startsWith('snip-')) return 3;
|
||||||
|
return 99;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortBlocksByPrefixAndLabel = (blocks) => {
|
||||||
|
blocks.sort((a, b) => {
|
||||||
|
const aId = String((a.get ? a.get('id') : a.id) || '');
|
||||||
|
const bId = String((b.get ? b.get('id') : b.id) || '');
|
||||||
|
// Hier nutzen wir die Rohdaten (aSnippet, bSnippet)
|
||||||
|
const aLabel = String((a.get ? a.get('label') : a.label) || '').toLowerCase();
|
||||||
|
const bLabel = String((b.get ? b.get('label') : b.label) || '').toLowerCase();
|
||||||
|
|
||||||
|
const aWeight = getSortWeight(aId);
|
||||||
|
const bWeight = getSortWeight(bId);
|
||||||
|
|
||||||
|
// 1. Sortierung nach Gewicht (1, 2, 3)
|
||||||
|
if (aWeight !== bWeight) return aWeight - bWeight;
|
||||||
|
|
||||||
|
// 2. Sortierung alphabetisch (a vor b)
|
||||||
|
if (aLabel < bLabel) return -1;
|
||||||
|
if (aLabel > bLabel) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
gjs.plugins.add('bridge-custom-blocks', (editor, opts = {}) => {
|
||||||
|
const config = B.CATEGORY_CONFIG;
|
||||||
|
const validCatIds = Object.keys(config);
|
||||||
|
|
||||||
|
// IDs zur internen Zuweisung
|
||||||
|
const CAT_CUSTOM_MAIN_ID = 'custom';
|
||||||
|
const CAT_BAUSTEINE_ID = 'bausteine';
|
||||||
|
const CAT_BIBLIOTHEK_ID = 'mysnips';
|
||||||
|
const CAT_LIB_TEMPLATES_ID = 'lib-templates';
|
||||||
|
|
||||||
|
|
||||||
|
const normalizeAndSort = (ed) => {
|
||||||
|
try {
|
||||||
|
const bm = ed.BlockManager;
|
||||||
|
const categories = bm.getCategories ? bm.getCategories() : null;
|
||||||
|
|
||||||
|
let allBlocks = bm.getAll().models || bm.getAll();
|
||||||
|
let customBlocksArray = [];
|
||||||
|
let otherBlocksArray = [];
|
||||||
|
|
||||||
|
// 1. Blöcke neu kategorisieren & trennen
|
||||||
|
(allBlocks || []).forEach(b => {
|
||||||
|
const id = String((b.get ? b.get('id') : b.id) || '');
|
||||||
|
let targetCatId = null;
|
||||||
|
|
||||||
|
if (id.startsWith('lib-tpl-ref-')) {
|
||||||
|
targetCatId = CAT_LIB_TEMPLATES_ID;
|
||||||
|
} else if (id.startsWith('custom-') || id.startsWith('lib-sec-') || id.startsWith('lib-blk-') || id.startsWith('snip-')) {
|
||||||
|
targetCatId = CAT_CUSTOM_MAIN_ID;
|
||||||
|
} else if (id.startsWith('blk-') || id.startsWith('std-')) {
|
||||||
|
targetCatId = CAT_BAUSTEINE_ID;
|
||||||
|
} else if (!b.get('category')) {
|
||||||
|
targetCatId = CAT_BIBLIOTHEK_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetCatId) {
|
||||||
|
b.set('category', targetCatId);
|
||||||
|
if (targetCatId === CAT_CUSTOM_MAIN_ID) {
|
||||||
|
customBlocksArray.push(b);
|
||||||
|
} else {
|
||||||
|
otherBlocksArray.push(b);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Blöcke, die nicht zugewiesen wurden (z.B. basic/extra), behalten
|
||||||
|
otherBlocksArray.push(b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Block-Sortierung INNERHALB der "custom" Kategorie
|
||||||
|
if (customBlocksArray.length > 0) {
|
||||||
|
sortBlocksByPrefixAndLabel(customBlocksArray);
|
||||||
|
console.log('[DEBUG PLUGIN] Custom Blocks intern sortiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEU: Gesamte BlockManager-Kollektion mit sortierten Custom-Blöcken überschreiben
|
||||||
|
// Wir nehmen alle sortierten Custom-Blöcke und fügen die anderen Blöcke danach an.
|
||||||
|
const newBlockOrder = customBlocksArray.concat(otherBlocksArray);
|
||||||
|
|
||||||
|
// Dieser Hack sollte die Reihenfolge in der Seitenleiste erzwingen.
|
||||||
|
if (bm.getAll().reset) {
|
||||||
|
bm.getAll().reset(newBlockOrder);
|
||||||
|
console.log('[DEBUG PLUGIN] Gesamte Block-Kollektion mit neuer Sortierung überschrieben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Kategorien Aufräumen & Sortieren (wie zuvor)
|
||||||
|
if (categories && categories.models) {
|
||||||
|
// Aufräumen
|
||||||
|
categories.models.slice().forEach(cat => {
|
||||||
|
const catId = (cat.get('id') || cat.id || '').toLowerCase();
|
||||||
|
if (!validCatIds.includes(catId) && catId !== 'basic' && catId !== 'extra') {
|
||||||
|
categories.remove(cat);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Labels korrigieren und Kategorie-Sortierung erzwingen
|
||||||
|
categories.models.forEach(cat => {
|
||||||
|
const catId = (cat.get('id') || cat.id || '').toLowerCase();
|
||||||
|
if (config[catId]) {
|
||||||
|
cat.set('label', config[catId].label);
|
||||||
|
cat.set('open', config[catId].open ?? true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const catOrder = (m) => config[String((m.get('id') || m.id)).toLowerCase()]?.ord || 99;
|
||||||
|
const arr = categories.models.slice().sort((a,b) => catOrder(a) - catOrder(b));
|
||||||
|
categories.reset(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Finaler DOM-Sweep
|
||||||
|
B.enforceCategoryOrder && B.enforceCategoryOrder(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
console.error('[CustomPlugin] Error during normalize:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Listener
|
||||||
|
editor.on('block:add block:remove block:reset', () => normalizeAndSort(editor));
|
||||||
|
editor.on('load', () => {
|
||||||
|
normalizeAndSort(editor);
|
||||||
|
setTimeout(() => normalizeAndSort(editor), 100);
|
||||||
|
setTimeout(() => normalizeAndSort(editor), 800);
|
||||||
|
setTimeout(() => normalizeAndSort(editor), 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})(window.grapesjs, window.BridgeParts);
|
||||||
6
public/assets/js/bridge/custom-plugin.js
Normal file
6
public/assets/js/bridge/custom-plugin.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* /assets/js/bridge/custom-plugin.js (FINALE VERSION: API-Logik Hülle) */
|
||||||
|
(function(B){
|
||||||
|
if (!B || typeof grapesjs === 'undefined') return;
|
||||||
|
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
118
public/assets/js/bridge/general-functions.js
Normal file
118
public/assets/js/bridge/general-functions.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/* /assets/js/bridge/general-functions.js (LOGIK-FIX: GLOBAL_DEBUG prüft als erstes) */
|
||||||
|
(function(B){
|
||||||
|
if (!B) return;
|
||||||
|
|
||||||
|
// --- 🎯 1. ZENTRALE LOG-KONTROLLE (Konfiguration & Defaults) ---
|
||||||
|
B.LOG_CONFIG = B.LOG_CONFIG || {};
|
||||||
|
|
||||||
|
// Globale Steuerung: Deaktiviert ALLE Logs (außer force=true)
|
||||||
|
// 🛑 KRITISCHE KORREKTUR: Wir setzen den Wert nur, wenn er noch nicht gesetzt wurde (z.B. durch bridge-core.js)
|
||||||
|
B.LOG_CONFIG.GLOBAL_DEBUG = B.LOG_CONFIG.GLOBAL_DEBUG !== undefined ? B.LOG_CONFIG.GLOBAL_DEBUG : true;
|
||||||
|
|
||||||
|
// Steuerung nach Log-Ebenen (wirken nur, wenn GLOBAL_DEBUG = true)
|
||||||
|
B.LOG_CONFIG.INFO_ENABLED = B.LOG_CONFIG.INFO_ENABLED !== undefined ? B.LOG_CONFIG.INFO_ENABLED : true;
|
||||||
|
B.LOG_CONFIG.WARN_ENABLED = B.LOG_CONFIG.WARN_ENABLED !== undefined ? B.LOG_CONFIG.WARN_ENABLED : true;
|
||||||
|
B.LOG_CONFIG.ERROR_ENABLED = B.LOG_CONFIG.ERROR_ENABLED !== undefined ? B.LOG_CONFIG.ERROR_ENABLED : true;
|
||||||
|
|
||||||
|
// Steuerung für große Datenmengen (B.logData)
|
||||||
|
B.LOG_CONFIG.DATA_ENABLED = B.LOG_CONFIG.DATA_ENABLED !== undefined ? B.LOG_CONFIG.DATA_ENABLED : true;
|
||||||
|
|
||||||
|
// NEU: Objekt zur Speicherung des individuellen Log-Status pro Plugin (Standard: leeres Objekt)
|
||||||
|
B.LOG_CONFIG.PLUGINS = B.LOG_CONFIG.PLUGINS || {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Log-Funktion mit Prüfung auf globale Schalter, Log-Ebenen und Plugin-spezifische Schalter.
|
||||||
|
* @param {string} pluginName - Der Name des aufrufenden Plugins (KRITISCH für die neue Logik).
|
||||||
|
* @param {string} message - Die zu loggende Nachricht.
|
||||||
|
* @param {string} color - CSS-Farbe für die Nachricht (optional).
|
||||||
|
* @param {string} type - Log-Typ ('info', 'warn', 'error').
|
||||||
|
* @param {boolean} force - Wenn true, wird geloggt, auch wenn GLOBAL_DEBUG/Plugin-Log false ist.
|
||||||
|
*/
|
||||||
|
B.log = (pluginName, message, color = 'inherit', type = 'info', force = false) => {
|
||||||
|
const config = B.LOG_CONFIG;
|
||||||
|
|
||||||
|
// 1. Prüfe auf force (immer loggen)
|
||||||
|
if (!force) {
|
||||||
|
// 🛑 KRITISCHE KORREKTUR: Prüfe auf GLOBAL_DEBUG an 2. Stelle (wenn nicht 'force')
|
||||||
|
if (!config.GLOBAL_DEBUG) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Prüfe den PLUGIN-SPEZIFISCHEN SCHALTER
|
||||||
|
// Wenn der Schalter im PLUGINS-Objekt existiert UND auf false gesetzt ist, abbrechen.
|
||||||
|
const pluginStatus = config.PLUGINS[pluginName];
|
||||||
|
if (pluginStatus === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Prüfe auf spezifische Log-Ebenen-Schalter
|
||||||
|
if (type === 'info' && !config.INFO_ENABLED) return;
|
||||||
|
if (type === 'warn' && !config.WARN_ENABLED) return;
|
||||||
|
if (type === 'error' && !config.ERROR_ENABLED) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Führe das Logging aus
|
||||||
|
const stylePlugin = `color:orange; font-weight:bold;`;
|
||||||
|
const styleMessage = `color:${color}; font-weight:normal;`;
|
||||||
|
|
||||||
|
const logFn = (type === 'error') ? console.error : (type === 'warn' ? console.warn : console.log);
|
||||||
|
|
||||||
|
logFn(`%c[${pluginName}] %c${message}`, stylePlugin, styleMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spezielle Funktion zum Loggen großer Datenmengen (prüft B.LOG_CONFIG.DATA_ENABLED und Plugin-Schalter).
|
||||||
|
* Wird jetzt als Wrapper für B.log verwendet.
|
||||||
|
*/
|
||||||
|
B.logData = (pluginName, data) => {
|
||||||
|
// 1. Prüfe, ob das Daten-Logging global erlaubt ist
|
||||||
|
if (!B.LOG_CONFIG.DATA_ENABLED) return;
|
||||||
|
|
||||||
|
// 2. Den "Daten-Ausgabe..." Log durch B.log schicken, um die Filter zu durchlaufen
|
||||||
|
// Wir verwenden force=false, damit GLOBAL_DEBUG und Plugin-Schalter angewendet werden
|
||||||
|
B.log(pluginName, 'Daten-Ausgabe (nächste Zeile):', 'gray', 'info', false);
|
||||||
|
|
||||||
|
// 3. Wenn B.log den Filter passiert hätte, loggen wir hier das eigentliche Objekt (nur wenn GLOBAL_DEBUG true)
|
||||||
|
const pluginStatus = B.LOG_CONFIG.PLUGINS?.[pluginName];
|
||||||
|
if (B.LOG_CONFIG.GLOBAL_DEBUG && pluginStatus !== false) {
|
||||||
|
console.log(data); // Das eigentliche Objekt-Log ohne Formatierung
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- 2. Hilfsfunktion zur Sortiergewichtung ---
|
||||||
|
const getSortWeight = (id) => {
|
||||||
|
if (['text', 'image', 'link', 'section', 'column', 'button', 'divider', 'map'].includes(id)) return 99;
|
||||||
|
if (id.startsWith('cust-')) return 1;
|
||||||
|
if (id.startsWith('lib-')) return 2;
|
||||||
|
if (id.endsWith('-fix') || id.endsWith('-flex')) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (!id.includes('-')) return 99;
|
||||||
|
return 50;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 3. Hilfsfunktion zur Sortierung ---
|
||||||
|
const sortBlocksByPrefixAndLabel = (blocks) => {
|
||||||
|
blocks.sort((a, b) => {
|
||||||
|
const aId = String((a.get ? a.get('id') : a.id) || '');
|
||||||
|
const bId = String((b.get ? b.get('id') : b.id) || '');
|
||||||
|
const aLabel = String((a.get ? a.get('label') : a.label) || '').toLowerCase();
|
||||||
|
const bLabel = String((b.get ? b.get('label') : b.label) || '').toLowerCase();
|
||||||
|
|
||||||
|
const aWeight = getSortWeight(aId);
|
||||||
|
const bWeight = getSortWeight(bId);
|
||||||
|
|
||||||
|
if (aWeight !== bWeight) return aWeight - bWeight;
|
||||||
|
if (aLabel < bLabel) return -1;
|
||||||
|
if (aLabel > bLabel) return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 4. Funktionen zum BridgeParts-Objekt hinzufügen ---
|
||||||
|
B.getSortWeight = getSortWeight;
|
||||||
|
B.sortBlocksByPrefixAndLabel = sortBlocksByPrefixAndLabel;
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
39
public/assets/js/bridge/helpers.js
Normal file
39
public/assets/js/bridge/helpers.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* /assets/js/bridge/helpers.js — Namespace + Utilities (kein ES-Module) */
|
||||||
|
(function(w){
|
||||||
|
var B = w.BridgeParts = w.BridgeParts || {};
|
||||||
|
|
||||||
|
B.post = function(m){ try { parent.postMessage(m,'*'); } catch{} };
|
||||||
|
B.send = function(type, payload){ B.post(Object.assign({ source:'email-editor', type:type }, payload||{})); };
|
||||||
|
B.log = function(msg){ B.post({ source:'bridge', type:'log', detail:String(msg||'') }); };
|
||||||
|
|
||||||
|
B.ready = function(cb){ (function t(){ if (w.grapesjs) return cb(); setTimeout(t,40); })(); };
|
||||||
|
|
||||||
|
B.BADGE = function(){ return document.getElementById('badge'); };
|
||||||
|
B.badgeSay = function(txt, tone){
|
||||||
|
var b = B.BADGE(); if(!b) return;
|
||||||
|
b.textContent = txt;
|
||||||
|
var cfg = {
|
||||||
|
ok: ['#ecfeff','#155e75','#a5f3fc'],
|
||||||
|
warn: ['#fef3c7','#92400e','#fde68a'],
|
||||||
|
err: ['#fee2e2','#7f1d1d','#fecaca'],
|
||||||
|
base: ['#eef2ff','#1e3a8a','#c7d2fe']
|
||||||
|
}[tone||'base'];
|
||||||
|
b.style.background = cfg[0]; b.style.color = cfg[1]; b.style.borderColor = cfg[2];
|
||||||
|
};
|
||||||
|
|
||||||
|
B.waitForBlocks = function(ed, opt){
|
||||||
|
opt = opt || {};
|
||||||
|
var timeoutMs = opt.timeoutMs || 4000, interval = opt.interval || 80;
|
||||||
|
var bm = ed.BlockManager, t0 = Date.now();
|
||||||
|
return new Promise(function(resolve){
|
||||||
|
(function tick(){
|
||||||
|
var n = (bm && bm.getAll && bm.getAll().length) || 0;
|
||||||
|
if (n > 0 || Date.now() - t0 > timeoutMs) return resolve(n);
|
||||||
|
setTimeout(tick, interval);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
B.renderBlocks = function(ed){ try { ed.BlockManager.render(); } catch{} };
|
||||||
|
})(window);
|
||||||
|
|
||||||
246
public/assets/js/bridge/library-api.js
Normal file
246
public/assets/js/bridge/library-api.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/* /assets/js/bridge/library-api.js (FINAL & KORRIGIERT FÜR FLEXIBLE API-BASES) */
|
||||||
|
|
||||||
|
(function(B){
|
||||||
|
|
||||||
|
// 🛑 WICHTIG: Globalen Cache-Speicher initialisieren (wird von blocks-api.js gelesen)
|
||||||
|
B.ApiItemCache = B.ApiItemCache || {};
|
||||||
|
|
||||||
|
if (!B || typeof grapesjs === 'undefined') return;
|
||||||
|
|
||||||
|
const PluginName = 'bridge-library-api';
|
||||||
|
|
||||||
|
// NEU: Standard-API-Basis für Abwärtskompatibilität, falls nichts konfiguriert
|
||||||
|
const API_BASE_FALLBACK = (B.API_BASE || '/api/editor');
|
||||||
|
|
||||||
|
// Konstanten
|
||||||
|
const TARGET_CAT_ID = 'custom';
|
||||||
|
const PLACEHOLDER_ID = 'api-placeholder-loading';
|
||||||
|
const REFERENCE_COMPONENT_TYPE = 'library-reference';
|
||||||
|
|
||||||
|
if (B.LOG_CONFIG && B.LOG_CONFIG.PLUGINS) {
|
||||||
|
B.LOG_CONFIG.PLUGINS[PluginName] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = (type, message, color = '#6A5ACD', logType = 'info', force = false) => {
|
||||||
|
if (typeof B.log === 'function') {
|
||||||
|
B.log(PluginName, `[${type}] ${message}`, color, logType, force);
|
||||||
|
} else {
|
||||||
|
if (logType === 'error') {
|
||||||
|
console.error(`%c[${PluginName} - ${type}] %c${message}`, 'color:red; font-weight:bold;', 'color:inherit;');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const logApiData = (data) => B.logData(PluginName, data);
|
||||||
|
|
||||||
|
log('INIT', 'API-Schicht initialisiert.');
|
||||||
|
|
||||||
|
// --- HILFSFUNKTIONEN ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die korrekte API-Basis-URL für einen Ressourcentyp (kind) zurück.
|
||||||
|
* Nutzt die zentrale Map B.RESOURCE_API_BASES, die in category-config.js gefüllt wurde.
|
||||||
|
*/
|
||||||
|
const getApiBase = (resource) => {
|
||||||
|
// Fallback auf die konfigurierte Standard-Basis, falls die Map noch nicht existiert oder der Eintrag fehlt.
|
||||||
|
return (B.RESOURCE_API_BASES && B.RESOURCE_API_BASES[resource]) || API_BASE_FALLBACK;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildApiUrl = (resource, action='list', params = {}) => {
|
||||||
|
// KORREKTUR: Nutzt jetzt die dynamisch ermittelte API-Basis
|
||||||
|
const apiBase = getApiBase(resource);
|
||||||
|
|
||||||
|
const url = new URL(apiBase, window.location.origin);
|
||||||
|
|
||||||
|
url.searchParams.set('resource', resource);
|
||||||
|
url.searchParams.set('action', action);
|
||||||
|
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== null && value !== undefined) url.searchParams.set(key, value);
|
||||||
|
});
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldLoad = (resource) => {
|
||||||
|
const mode = (B.EDITOR_MODE || 'TEMPLATES').toUpperCase();
|
||||||
|
|
||||||
|
// HINWEIS: Hier muss für neue Ressourcen (wie 'products') ggf. der mode angepasst werden,
|
||||||
|
// falls sie nicht in TEMPLATES geladen werden sollen.
|
||||||
|
switch (mode) {
|
||||||
|
case 'TEMPLATES':
|
||||||
|
const templateResources = ['templates', 'sections', 'blocks', 'snippets', 'products']; // Beispiel: products hinzugefügt
|
||||||
|
return templateResources.includes(resource);
|
||||||
|
|
||||||
|
case 'SECTIONS':
|
||||||
|
const sectionResources = ['blocks', 'snippets'];
|
||||||
|
return sectionResources.includes(resource);
|
||||||
|
|
||||||
|
case 'BLOCKS':
|
||||||
|
return resource === 'snippets';
|
||||||
|
|
||||||
|
default:
|
||||||
|
log('MODE WARN', `Unbekannter Editor Modus '${mode}' festgestellt.`, 'orange', 'warn');
|
||||||
|
return resource === 'snippets';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const fetchData = (resource, action='list', params = {}) => {
|
||||||
|
// ... (Rest der fetchData-Funktion bleibt unverändert, nutzt aber die korrigierte buildApiUrl)
|
||||||
|
const url = buildApiUrl(resource, action, params);
|
||||||
|
const cacheKey = action === 'get' ? `${resource}-${params.id}` : null;
|
||||||
|
|
||||||
|
// Cache-Check verwendet B.ApiItemCache
|
||||||
|
if (cacheKey && B.ApiItemCache.hasOwnProperty(cacheKey)) {
|
||||||
|
log('CACHE HIT', `Cache Hit für /${resource}-${cacheKey}.`, '#708090', 'info');
|
||||||
|
return Promise.resolve(B.ApiItemCache[cacheKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
log('API ERROR', `API-Aufruf fehlgeschlagen für /${resource}/${action}: ${response.status} (${response.statusText})`, 'red', 'error');
|
||||||
|
// 💡 KORREKTUR: Bei HTTP-Fehler immer ein leeres Array für LIST und leeres Objekt für GET zurückgeben.
|
||||||
|
return action === 'get' ? {} : { items: [] };
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.ok === false) {
|
||||||
|
log('API ERROR', `API-Fehler für /${resource}: ${data.error || 'Unbekannt'}`, 'red', 'error');
|
||||||
|
// 💡 KORREKTUR: Bei API-Fehler ('ok: false') immer leeres Array/Objekt zurückgeben.
|
||||||
|
return action === 'get' ? {} : { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = data.items || data.data || data.item;
|
||||||
|
const finalResult = result ? (Array.isArray(result) ? result : (action === 'list' ? (result.items || []) : result)) : (action === 'list' ? [] : {});
|
||||||
|
|
||||||
|
const resultIsArray = Array.isArray(finalResult);
|
||||||
|
const resultLength = resultIsArray ? finalResult.length : (Object.keys(finalResult).length > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
log('EXTRACT SUCCESS', `Extrahiert ${resultLength} Elemente (Typ: ${action}) für /${resource}.`);
|
||||||
|
|
||||||
|
// Cache-Speicherung verwendet B.ApiItemCache
|
||||||
|
if (cacheKey && resultLength > 0) {
|
||||||
|
B.ApiItemCache[cacheKey] = finalResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 KORREKTUR: Bei LIST (action='list') geben wir immer ein Array zurück, sonst das Objekt
|
||||||
|
return finalResult;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
log('FETCH ERROR', `FEHLER beim Fetchen oder Parsen von /${resource}: ${error.message}`, 'red', 'error', true);
|
||||||
|
return action === 'get' ? {} : [];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Exportierte Core-Funktionen (jetzt generisch) ---
|
||||||
|
|
||||||
|
// NEU: Generische Fetch-Funktion für jeden Ressourcentyp ('kind')
|
||||||
|
B.fetchResource = (kind) => {
|
||||||
|
if (!shouldLoad(kind)) {
|
||||||
|
log('BLOCKED', `Blockiert: ${kind} (Modus: ${B.EDITOR_MODE})`, '#708090', 'info');
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
return fetchData(kind).then(items => Array.isArray(items) ? items : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Die alten hardcodierten Funktionen verwenden jetzt die neue generische Funktion
|
||||||
|
B.fetchTemplates = () => B.fetchResource('templates');
|
||||||
|
B.fetchSnippets = () => B.fetchResource('snippets');
|
||||||
|
B.fetchSections = () => B.fetchResource('sections');
|
||||||
|
B.fetchBlocks = () => B.fetchResource('blocks');
|
||||||
|
|
||||||
|
B.getApiItem = (kind, id) => fetchData(kind, 'get', { id: id });
|
||||||
|
|
||||||
|
B.clearApiCache = () => {
|
||||||
|
B.ApiItemCache = {}; // Cache leeren
|
||||||
|
log('CACHE CLEAR', `API-Cache geleert.`, 'orange', 'warn');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🚀 Zentrale Funktion zum Laden und Registrieren der Blöcke
|
||||||
|
B.loadAndRegisterApiBlocks = (editor) => {
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
|
||||||
|
// NEU: Ressourcen-Kinds aus der Konfiguration sammeln
|
||||||
|
const resourceKindsToLoad = Object.keys(B.RESOURCE_API_BASES || {});
|
||||||
|
|
||||||
|
if (resourceKindsToLoad.length === 0) {
|
||||||
|
log('FEHLER', 'Keine Ressourcen-Kind-Konfiguration (B.RESOURCE_API_BASES) gefunden.', '#dc3545', 'error', true);
|
||||||
|
bm.remove(PLACEHOLDER_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map aller Fetch-Promises erstellen
|
||||||
|
const fetchPromises = resourceKindsToLoad.map(kind =>
|
||||||
|
B.fetchResource(kind).then(items => items.map(i => ({ ...i, kind: kind })))
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
log('API START', `Starte Promise.all für API-Abruf der Blöcke/Sektionen (${resourceKindsToLoad.join(', ')})...`, '#1E90FF');
|
||||||
|
|
||||||
|
Promise.all(fetchPromises)
|
||||||
|
.then(results => {
|
||||||
|
const apiItems = results.flat().filter(item => item && item.id);
|
||||||
|
|
||||||
|
log(`API SUCCESS`, `${apiItems.length} Elemente gefunden.`, '#9400D3');
|
||||||
|
logApiData(apiItems);
|
||||||
|
|
||||||
|
if (apiItems.length === 0) {
|
||||||
|
log('NO DATA', 'Keine API-Daten gefunden.', 'orange', 'warn', true);
|
||||||
|
} else {
|
||||||
|
apiItems.forEach(item => {
|
||||||
|
const blockId = `lib-${item.kind}-${item.id}`;
|
||||||
|
const label = item.name || item.label || 'Unbenannter Block';
|
||||||
|
const itemKindUpper = item.kind.toUpperCase();
|
||||||
|
|
||||||
|
// Hier wird der Block-Manager-Block registriert
|
||||||
|
// ... (Der Rest der Logik bleibt unverändert) ...
|
||||||
|
const blockDefinition = {
|
||||||
|
label: label,
|
||||||
|
category: TARGET_CAT_ID,
|
||||||
|
// 💡 KORREKTUR: Immer die library-reference-Komponente verwenden, um die Referenz-Logik
|
||||||
|
// (mit editable: false) aus blocks-api.js zu erzwingen.
|
||||||
|
content: {
|
||||||
|
type: REFERENCE_COMPONENT_TYPE,
|
||||||
|
'lib-kind': item.kind,
|
||||||
|
'lib-id': item.id,
|
||||||
|
// NEU: startContent wird nur als reines HTML übergeben.
|
||||||
|
// Die Logik in blocks-api.js (init/reloadComponentContent) kümmert sich um die Anzeige.
|
||||||
|
startContent: item.html || item.content || '<div style="padding: 10px; color: #dc3545; background-color: #fce7f3; border: 1px solid #fbcfe8; text-align: center;">🛑 Fehler: Inhalt fehlte beim Laden.</div>',
|
||||||
|
content: '', // Wichtig: Beim Drop keinen GrapesJS-Content setzen
|
||||||
|
},
|
||||||
|
attributes: { 'title': itemKindUpper },
|
||||||
|
media: item.preview_url ? `<img src="${item.preview_url}">` : '',
|
||||||
|
};
|
||||||
|
bm.add(blockId, blockDefinition);
|
||||||
|
});
|
||||||
|
|
||||||
|
bm.remove(PLACEHOLDER_ID);
|
||||||
|
log(`REGISTRATION`, `${apiItems.length} API-Blöcke registriert. Platzhalter entfernt.`, '#008000');
|
||||||
|
|
||||||
|
const reloadExistingComponents = () => {
|
||||||
|
const allComponents = editor.DomComponents.getWrapper().find(`[data-gjs-type="${REFERENCE_COMPONENT_TYPE}"]`);
|
||||||
|
allComponents.forEach(component => {
|
||||||
|
if (component.get('lib-id') && component.components().length === 0 && typeof component.reloadComponentContent === 'function') {
|
||||||
|
log(`RELOAD START`, `Lade ${component.get('lib-kind')}/${component.get('lib-id')} nach Cache-Füllung (Sicherheitsnetz).`, '#FF4500');
|
||||||
|
component.reloadComponentContent({ forced: true, reason: 'EXISTING_CONTENT_RELOAD' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(reloadExistingComponents, 100);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// Hier wird der Fehler von fetchData oder map abgefangen
|
||||||
|
log('FETCH ERROR', `FEHLER beim Laden der API-Blöcke: ${error.message}`, '#dc3545', 'error', true);
|
||||||
|
bm.remove(PLACEHOLDER_ID);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
17
public/assets/js/bridge/library-parts.js
Normal file
17
public/assets/js/bridge/library-parts.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/* /assets/js/bridge/library-parts.js (BEREINIGT) */
|
||||||
|
|
||||||
|
(function(B){
|
||||||
|
// Alle API-spezifischen Funktionen (fetchData, fetchTemplates, etc.)
|
||||||
|
// und der apiItemCache wurden nach library-api.js verschoben.
|
||||||
|
|
||||||
|
if (!B || typeof grapesjs === 'undefined') return;
|
||||||
|
|
||||||
|
const PluginName = 'bridge-library-parts-core';
|
||||||
|
|
||||||
|
if (B.LOG_CONFIG && B.LOG_CONFIG.PLUGINS) {
|
||||||
|
B.LOG_CONFIG.PLUGINS[PluginName] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusätzliche Core-Funktionen, die nicht API-spezifisch sind, würden hier verbleiben.
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
19
public/assets/js/bridge/library-plugin.js_NV
Normal file
19
public/assets/js/bridge/library-plugin.js_NV
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/* /assets/js/bridge/library-plugin.js (Plugin für Standard-Bausteine) */
|
||||||
|
(function(B){
|
||||||
|
if (!B || typeof grapesjs === 'undefined') return;
|
||||||
|
|
||||||
|
const PluginName = 'bridge-library-plugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GrapesJS Plugin Registrierung: bridge-library-plugin
|
||||||
|
* Dieses Plugin dient als Platzhalter für die zukünftige Konfiguration
|
||||||
|
* der Standard-Blöcke (Bausteine). Es fügt aktuell keine Blöcke hinzu,
|
||||||
|
* sondern wartet auf die Blöcke aus dem Preset (gjs-preset-newsletter).
|
||||||
|
* Der Categorization Master wird diese Blöcke später in die Kategorie 'bausteine' verschieben.
|
||||||
|
*/
|
||||||
|
grapesjs.plugins.add(PluginName, (editor, opts = {}) => {
|
||||||
|
console.log(`[${PluginName}] Plugin registriert. Erwartet Blöcke vom Preset.`);
|
||||||
|
// ToDo: Zukünftige Standardblöcke hier hinzufügen
|
||||||
|
});
|
||||||
|
|
||||||
|
})(window.BridgeParts || (window.BridgeParts = {}));
|
||||||
166
public/assets/js/bridge/library.js
Normal file
166
public/assets/js/bridge/library.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/* /assets/js/bridge/library.js — Kategorien, Defaults, Snippets (FINAL UND KORRIGIERT) */
|
||||||
|
(function(w){
|
||||||
|
var B = w.BridgeParts = w.BridgeParts || {};
|
||||||
|
if (!B.CATEGORY_CONFIG) B.CATEGORY_CONFIG = {}; // Muss vorhanden sein
|
||||||
|
|
||||||
|
/* Panels/Views sicherstellen (Unverändert) */
|
||||||
|
B.ensureViews = function(ed){
|
||||||
|
var pn = ed.Panels;
|
||||||
|
if (!pn.getPanel('views')) pn.addPanel({ id:'views' });
|
||||||
|
if (!pn.getButton('views','open-blocks'))
|
||||||
|
pn.addButton('views',[{ id:'open-blocks', command:'open-blocks', togglable:1, className:'gjs-pn-btn' }]);
|
||||||
|
if (!pn.getButton('views','open-layers'))
|
||||||
|
pn.addButton('views',[{ id:'open-layers', command:'open-layers', togglable:1, className:'gjs-pn-btn' }]);
|
||||||
|
if (!pn.getButton('views','open-sm'))
|
||||||
|
pn.addButton('views',[{ id:'open-sm', command:'open-sm', togglable:1, className:'gjs-pn-btn' }]);
|
||||||
|
try{ var b=pn.getButton('views','open-blocks'); b && b.set('active',true); }catch{}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Helpers --------------------------------------------------------------- */
|
||||||
|
function addOnce(bm, id, def){
|
||||||
|
if (!id || typeof id !== 'string') return;
|
||||||
|
try{ if (bm.get && bm.get(id)) return; bm.add(id, def); }catch{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kategorien erzeugen (Funktion unverändert)
|
||||||
|
function forceCategory(bm, id, label, open){
|
||||||
|
try{
|
||||||
|
if (typeof bm.addCategory === 'function') {
|
||||||
|
var cts = bm.getCategories && bm.getCategories();
|
||||||
|
var find = function(){
|
||||||
|
if (!cts) return null;
|
||||||
|
if (typeof cts.findWhere === 'function') {
|
||||||
|
return cts.findWhere({ id }) || cts.findWhere({ label });
|
||||||
|
}
|
||||||
|
var arr = cts.models || cts || [];
|
||||||
|
for (var i=0;i<arr.length;i++){
|
||||||
|
var m = arr[i], lid = (m.get?m.get('id'):m.id), lbl=(m.get?m.get('label'):m.label);
|
||||||
|
if (lid===id || lbl===label) return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
var c = find();
|
||||||
|
if (!c) c = bm.addCategory({ id:id, label:label, open:!!open });
|
||||||
|
try { c.set && c.set('open', !!open); } catch {}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}catch{}
|
||||||
|
return { id:id, label:label, open:!!open, __labelOnly:true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCategories(bm){
|
||||||
|
// Stellt sicher, dass die vier Hauptkategorien erstellt werden.
|
||||||
|
var C_CUSTOM = forceCategory(bm,'custom', B.CATEGORY_CONFIG.custom.label, true);
|
||||||
|
var C_STD = forceCategory(bm,'bausteine', B.CATEGORY_CONFIG.bausteine.label, true);
|
||||||
|
var C_LIB = forceCategory(bm,'mysnips', B.CATEGORY_CONFIG.mysnips.label, true);
|
||||||
|
var C_TPLS = forceCategory(bm,'lib-templates', B.CATEGORY_CONFIG['lib-templates'].label, true);
|
||||||
|
|
||||||
|
return { C_CUSTOM, C_STD, C_LIB, C_TPLS };
|
||||||
|
}
|
||||||
|
B._ensureCategories = ensureCategories;
|
||||||
|
|
||||||
|
/* Harte Zuordnung von Custom-IDs → Kategorien (ENTFERNT) */
|
||||||
|
B.forceFixFlexCategories = function(ed){
|
||||||
|
// Logik liegt jetzt im Plugin
|
||||||
|
};
|
||||||
|
|
||||||
|
// **Harte** Sortierung der Kategorien – Sammlung + DOM (DOM-Fallback)
|
||||||
|
B.enforceCategoryOrder = function(ed){
|
||||||
|
try{
|
||||||
|
var bm = ed.BlockManager;
|
||||||
|
setTimeout(function(){
|
||||||
|
try{
|
||||||
|
var cont = bm.getContainer && bm.getContainer();
|
||||||
|
if (!cont) return;
|
||||||
|
// Notfall-DOM-Sortierung (falls das Plugin versagt)
|
||||||
|
var nodes = Array.prototype.slice.call(cont.querySelectorAll('.gjs-block-category'));
|
||||||
|
if (!nodes.length) return;
|
||||||
|
|
||||||
|
function nodeRank(n){
|
||||||
|
var t = (n.querySelector('.gjs-title') || n).textContent || '';
|
||||||
|
var s = t.trim().toLowerCase();
|
||||||
|
if (s==='bibliothek: templates (ref)') return 1; // Prio 1
|
||||||
|
if (s==='custom') return 2; // Prio 2
|
||||||
|
if (s==='bausteine') return 3; // Prio 3
|
||||||
|
if (s.startsWith('bibliothek')) return 4; // Prio 4+
|
||||||
|
return 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.sort(function(a,b){ return nodeRank(a)-nodeRank(b); })
|
||||||
|
.forEach(function(n){ cont.appendChild(n); });
|
||||||
|
}catch{}
|
||||||
|
},0);
|
||||||
|
}catch{}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Kategorien normalisieren + sortieren (ENTFERNT) */
|
||||||
|
B.normalizeCategories = function(ed){
|
||||||
|
// Logik liegt jetzt im Plugin
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Bausteine (Explizite Zuweisung zur ID 'bausteine') */
|
||||||
|
B.addDefaultBlocks = function(ed){
|
||||||
|
var bm = ed.BlockManager;
|
||||||
|
B._ensureCategories(bm); // Stellen Sie sicher, dass alle Hauptkategorien existieren
|
||||||
|
|
||||||
|
// Explizite Kategorie-Definition basierend auf der ID 'bausteine'
|
||||||
|
var cat_bausteine = { id:'bausteine', label:B.CATEGORY_CONFIG.bausteine.label, open:true };
|
||||||
|
|
||||||
|
addOnce(bm,'blk-img', { label:'Bild', category:cat_bausteine, media:'<svg viewBox="0 0 24 24" width="22" height="22"><rect x="3" y="5" width="18" height="14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 13l3-3 5 6" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="9" cy="9" r="1.5" fill="currentColor"/></svg>', content:'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif"><tr><td align="center"><img src="https://via.placeholder.com/600x200" alt="" width="600" style="max-width:100%;display:block;border:0;" /></td></tr></table>' });
|
||||||
|
addOnce(bm,'blk-btn', { label:'Button', category:cat_bausteine, media:'<svg viewBox="0 0 24 24" width="22" height="22"><rect x="4" y="7" width="16" height="10" rx="5" fill="none" stroke="currentColor" stroke-width="2"/></svg>', content:'<table role="presentation" cellpadding="0" cellspacing="0" align="center" style="font-family:Arial,sans-serif;margin:16px auto;"><tr><td><a href="#" style="background:#0ea5e9;color:#fff;text-decoration:none;padding:12px 20px;border-radius:6px;display:inline-block;">Call to Action</a></td></tr></table>' });
|
||||||
|
addOnce(bm,'blk-text', { label:'Text', category:cat_bausteine, media:'<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 7h16M4 12h10M4 17h8" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>', content:'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif"><tr><td style="font-size:16px;line-height:1.5;color:#0f172a;"><p style="margin:0 0 12px 0;">Überschrift</p><p style="margin:0;">Fließtext …</p></td></tr></table>' });
|
||||||
|
addOnce(bm,'blk-2cols', { label:'2 Spalten', category:cat_bausteine, media:'<svg viewBox="0 0 24 24" width="22" height="22"><rect x="4" y="6" width="7" height="12" fill="none" stroke="currentColor" stroke-width="2"/><rect x="13" y="6" width="7" height="12" fill="none" stroke="currentColor" stroke-width="2"/></svg>', content:'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif"><tr><td width="50%" valign="top" style="padding:8px;"><p style="margin:0;">Linke Spalte</p></td><td width="50%" valign="top" style="padding:8px;"><p style="margin:0;">Rechte Spalte</p></td></tr></table>' });
|
||||||
|
addOnce(bm,'blk-600', { label:'Container 600px', category:cat_bausteine, media:'<svg viewBox="0 0 24 24" width="22" height="22"><rect x="3" y="7" width="18" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="2"/></svg>', content:'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:100%;background:#ffffff;border:1px solid #e5e7eb;border-radius:6px;"><tr><td style="padding:16px;"><p style="margin:0;">Inhalt hier hinein …</p></td></tr></table></td></tr></table>' });
|
||||||
|
addOnce(bm,'blk-hr', { label:'Trenner', category:cat_bausteine, media:'<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 12h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>', content:'<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="padding:8px 0;"><hr style="border:none;border-top:1px solid #e5e7eb;margin:0;" /></td></tr></table>' });
|
||||||
|
addOnce(bm,'blk-spacer', { label:'Abstand', category:cat_bausteine, media:'<svg viewBox="0 0 24 24" width="22" height="22"><path d="M12 6v12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="6" r="1.5" fill="currentColor"/><circle cx="12" cy="18" r="1.5" fill="currentColor"/></svg>', content:'<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td style="height:16px;line-height:16px;font-size:0;"> </td></tr></table>' });
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Snippets → Custom (Initiale Zuweisung zur ID 'custom') */
|
||||||
|
B.replaceSnippetBlocks = function(ed, list){
|
||||||
|
try{
|
||||||
|
var bm = ed.BlockManager;
|
||||||
|
B._ensureCategories(bm);
|
||||||
|
|
||||||
|
var all = (bm.getAll && bm.getAll()) || [];
|
||||||
|
(all.models || all).forEach(function(b){
|
||||||
|
if (!b) return;
|
||||||
|
var id=b.get&&b.get('id');
|
||||||
|
// Entferne alte Snippets
|
||||||
|
if(id && String(id).startsWith('snip-')) try{ bm.remove(id); }catch{}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Explizite Kategorie-Definition basierend auf der ID 'custom'
|
||||||
|
var cat_custom = { id:'custom', label:B.CATEGORY_CONFIG.custom.label, open:true };
|
||||||
|
|
||||||
|
|
||||||
|
(list||[]).forEach(function(raw){
|
||||||
|
if(!raw) return;
|
||||||
|
var html = raw.html || raw.content || '';
|
||||||
|
if(!html) return;
|
||||||
|
var id = 'snip-'+(raw.id ?? ('x'+Math.random().toString(36).slice(2)));
|
||||||
|
addOnce(bm, id, {
|
||||||
|
label: raw.name || ('Snippet '+(raw.id ?? '')),
|
||||||
|
// Zuweisung zur 'Custom' Kategorie
|
||||||
|
category: cat_custom,
|
||||||
|
media:'<svg viewBox="0 0 24 24" width="22" height="22"><path d="M5 7h14M5 12h10M5 17h8" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>',
|
||||||
|
content: html
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}catch{}
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Snippets nachladen (für Buttons) */
|
||||||
|
B.fetchSnippets = async function(){
|
||||||
|
try{
|
||||||
|
var res = await fetch('../api.php?resource=snippets&action=list&t='+Date.now(), {
|
||||||
|
credentials:'same-origin', cache:'no-store', headers:{'Cache-Control':'no-cache'}
|
||||||
|
});
|
||||||
|
var rows = await res.json();
|
||||||
|
rows = rows && rows.items ? rows.items : (Array.isArray(rows) ? rows : []);
|
||||||
|
return rows.map(function(r){ return { id:r.id, name:r.name, html:r.content||r.html||'' }; });
|
||||||
|
}catch(e){ B.log && B.log('reload-snippets-error:'+e); return []; }
|
||||||
|
};
|
||||||
|
|
||||||
|
})(window);
|
||||||
166
public/assets/js/bridge/refs.js
Normal file
166
public/assets/js/bridge/refs.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/* /assets/js/bridge/refs.js — Referenzen & Custom Fix/Flex (FINAL KORRIGIERT: Snippet Cleanup) */
|
||||||
|
(function (w) {
|
||||||
|
var B = w.BridgeParts = w.BridgeParts || {};
|
||||||
|
if (!B.CATEGORY_CONFIG) B.CATEGORY_CONFIG = {};
|
||||||
|
|
||||||
|
/* ---------- Basis-Konfig (unverändert) ---------- */
|
||||||
|
var SHOW_SNIPPETS_IN_FIX_DEFAULT = false;
|
||||||
|
var MODE = (window.__editorMode || 'templates').toLowerCase();
|
||||||
|
|
||||||
|
/* ---------- Hilfs-UI (unverändert) ---------- */
|
||||||
|
B.editorRefPlaceholder = function (type, id, name) {
|
||||||
|
var safe = (name || '').replace(/[<>&"]/g, '');
|
||||||
|
return {
|
||||||
|
html:
|
||||||
|
'<div data-ref-type="' + type + '" data-ref-id="' + id + '" data-ref-name="' + safe + '" ' +
|
||||||
|
'style="border:1px dashed #94a3b8;padding:8px;border-radius:6px;background:#f8fafc;margin:8px 0;">' +
|
||||||
|
'<strong style="font:600 12px system-ui,Arial">Ref: ' + type + ' #' + id + '</strong>' +
|
||||||
|
'<div style="font:12px system-ui,Arial;opacity:.8">' + safe + '</div>' +
|
||||||
|
'</div>'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- Loader (REST) (unverändert) ---------- */
|
||||||
|
async function jsonList(url){
|
||||||
|
try{
|
||||||
|
var res = await fetch(url, { credentials:'same-origin', cache:'no-store' });
|
||||||
|
var data = await res.json();
|
||||||
|
return data && data.items ? data.items : (Array.isArray(data) ? data : []);
|
||||||
|
}catch(e){ return []; }
|
||||||
|
}
|
||||||
|
B.fetchTemplates = async function(){
|
||||||
|
var rows = await jsonList('../api.php?action=templates.list&t='+Date.now());
|
||||||
|
return rows.map(r => ({ id:r.id, name:r.name }));
|
||||||
|
};
|
||||||
|
B.fetchTemplateFull = async function (id) {
|
||||||
|
try {
|
||||||
|
var url = '../api.php?action=templates.get&id=' + encodeURIComponent(id) + '&t=' + Date.now();
|
||||||
|
var res = await fetch(url, { credentials: 'same-origin', cache: 'no-store' });
|
||||||
|
var data = await res.json();
|
||||||
|
var it = data && (data.item || data);
|
||||||
|
return (it && (it.html || it.content)) ? (it.html || it.content) : '';
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
};
|
||||||
|
B.fetchSections = async function(){
|
||||||
|
var rows = await jsonList('../api.php?action=sections.list&t='+Date.now());
|
||||||
|
return rows.map(r => ({ id:r.id, name:r.name, html:r.html || '' }));
|
||||||
|
};
|
||||||
|
B.fetchBlocks = async function(){
|
||||||
|
var rows = await jsonList('../api.php?action=blocks.list&t='+Date.now());
|
||||||
|
return rows.map(r => ({ id:r.id, name:r.name, html:r.html || '' }));
|
||||||
|
};
|
||||||
|
B.fetchSnippets = async function(){
|
||||||
|
var rows = await jsonList('../api.php?action=snippets.list&t='+Date.now());
|
||||||
|
return rows.map(r => ({ id:r.id, name:r.name, html:r.html || r.content || '' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- lokale Helfer (unverändert) ---------- */
|
||||||
|
function addOnce(bm, id, def){
|
||||||
|
if (!id || typeof id !== 'string') return;
|
||||||
|
try{ if (bm.get && bm.get(id)) return; bm.add(id, def); }catch{}
|
||||||
|
}
|
||||||
|
function removeByPrefix(bm, prefix){
|
||||||
|
try{
|
||||||
|
var all = (bm.getAll && bm.getAll()) || [];
|
||||||
|
(all.models || all).forEach(function(b){
|
||||||
|
if (!b) return;
|
||||||
|
var id = (b.get && b.get('id')) || b.id || '';
|
||||||
|
if (id && String(id).startsWith(prefix)) {
|
||||||
|
try{ bm.remove(id); }catch{}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}catch{}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Referenzbibliothek (lib-*) (angepasst: Zuweisung zu 'custom') ---------- */
|
||||||
|
B.addReferenceLibrary = function (ed, payload) {
|
||||||
|
payload = payload || {};
|
||||||
|
var templates = payload.templates || [];
|
||||||
|
var sections = payload.sections || [];
|
||||||
|
var blocks = payload.blocks || [];
|
||||||
|
|
||||||
|
// Aggressive Bereinigung aller lib-tpl-ref-* Blöcke
|
||||||
|
removeByPrefix(ed.BlockManager, 'lib-tpl-ref-');
|
||||||
|
|
||||||
|
var bm = ed.BlockManager;
|
||||||
|
if (B._ensureCategories) B._ensureCategories(bm); // Stellt sicher, dass die Hauptkategorien existieren
|
||||||
|
|
||||||
|
// Explizite Kategorie-Definitionen
|
||||||
|
var cat_templates = { id:'lib-templates', label:B.CATEGORY_CONFIG['lib-templates'].label, open:true };
|
||||||
|
var cat_custom = { id:'custom', label:B.CATEGORY_CONFIG.custom.label, open:true };
|
||||||
|
|
||||||
|
// Template-Referenzen (Prio 1) - NUR IM TEMPLATE-MODUS HINZUFÜGEN
|
||||||
|
if (MODE === 'templates') {
|
||||||
|
templates.forEach(function (t) {
|
||||||
|
addOnce(bm, 'lib-tpl-ref-' + t.id, {
|
||||||
|
label: (t.name || ('Vorlage #' + t.id)),
|
||||||
|
category: cat_templates, // Zuweisung zur Prio 1
|
||||||
|
media: '<svg viewBox="0 0 24 24" width="22" height="22"><rect x="4" y="5" width="16" height="14" rx="2" stroke="currentColor" fill="none" stroke-width="2"/></svg>',
|
||||||
|
content: B.editorRefPlaceholder('template', t.id, t.name).html
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sections-Referenzen (werden zu Custom umgeleitet, Prio 2)
|
||||||
|
sections.forEach(function (s) {
|
||||||
|
addOnce(bm, 'lib-sec-' + s.id, {
|
||||||
|
label: s.name || ('Section #' + s.id),
|
||||||
|
category: cat_custom, // Explizit Custom
|
||||||
|
media: '<svg viewBox="0 0 24 24" width="22" height="22"><rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" fill="none" stroke-width="2"/></svg>',
|
||||||
|
content: B.editorRefPlaceholder('section', s.id, s.name).html
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blocks-Referenzen (werden zu Custom umgeleitet, Prio 2)
|
||||||
|
blocks.forEach(function (b) {
|
||||||
|
addOnce(bm, 'lib-blk-' + b.id, {
|
||||||
|
label: b.name || ('Block #' + b.id),
|
||||||
|
category: cat_custom, // Explizit Custom
|
||||||
|
media: '<svg viewBox="0 0 24 24" width="22" height="22"><rect x="6" y="7" width="12" height="10" rx="2" stroke="currentColor" fill="none" stroke-width="2"/></svg>',
|
||||||
|
content: B.editorRefPlaceholder('block', b.id, b.name).html
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- Custom Fix/Flex (ENTFERNT / GELEERT) ---------- */
|
||||||
|
B.addCustomLibrary = function (ed, payload, mode) {
|
||||||
|
/* (Keine Aktion nötig.) */
|
||||||
|
};
|
||||||
|
|
||||||
|
// WICHTIGE NEUE FUNKTION: Entfernt alle alten Snippet-Blöcke
|
||||||
|
B.addEditableTemplatesLibrary = function(ed) {
|
||||||
|
// Aggressive Bereinigung aller alten flexiblen Snippet-Blöcke,
|
||||||
|
// um Konflikte mit den neuen custom-snippet-* Blöcken zu vermeiden.
|
||||||
|
removeByPrefix(ed.BlockManager, 'snip-');
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- Ref-Sammlung für Speichern/Render (unverändert) ---------- */
|
||||||
|
B.collectRefs = function (ed) {
|
||||||
|
var root = ed.getWrapper && ed.getWrapper();
|
||||||
|
var els = root && root.find ? root.find('[data-ref-type]') : [];
|
||||||
|
var out = [];
|
||||||
|
if (Array.isArray(els) && els.length) {
|
||||||
|
els.forEach(function (el) {
|
||||||
|
try {
|
||||||
|
var m = el.getAttributes ? el.getAttributes() : {};
|
||||||
|
var t = (m['data-ref-type'] || '').toString().toLowerCase();
|
||||||
|
var i = parseInt(m['data-ref-id'] || '0', 10);
|
||||||
|
if (!t || !i) return;
|
||||||
|
if (!/^(template|section|block|snippet)$/.test(t)) return;
|
||||||
|
if (t === 'snippet') return; // Snippets immer flex/by value
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
sort: out.length,
|
||||||
|
ref_type: t === 'template' ? 'section' : t,
|
||||||
|
ref_id: i,
|
||||||
|
overrides_json: null,
|
||||||
|
lock_to_version: null
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
})(window);
|
||||||
57
public/assets/js/toast.js
Normal file
57
public/assets/js/toast.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// assets/js/toast.js
|
||||||
|
// Shows toast in the TOP LAYER: if a <dialog open> exists, append inside it.
|
||||||
|
// Otherwise append to <body>. Default type = success (green).
|
||||||
|
|
||||||
|
window.Toast = (function () {
|
||||||
|
// create (or reuse) a toast root inside a given container
|
||||||
|
function ensureRoot(container) {
|
||||||
|
// Find last (topmost) open dialog if not explicitly passed
|
||||||
|
const host = container || (() => {
|
||||||
|
const dialogs = Array.from(document.querySelectorAll('dialog[open]'));
|
||||||
|
return dialogs.length ? dialogs[dialogs.length - 1] : document.body;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Root per container (class-based to allow multiple roots)
|
||||||
|
let root = host.querySelector(':scope > .toast-root');
|
||||||
|
if (!root) {
|
||||||
|
root = document.createElement('div');
|
||||||
|
root.className = 'toast-root';
|
||||||
|
host.appendChild(root);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(msg, type, duration, container) {
|
||||||
|
const root = ensureRoot(container);
|
||||||
|
|
||||||
|
const n = document.createElement('div');
|
||||||
|
n.className = 'toast' + (type === 'error' ? ' error' : ' success');
|
||||||
|
n.innerHTML = `
|
||||||
|
<span class="icon">${type === 'error' ? '⚠️' : '✅'}</span>
|
||||||
|
<span class="content">${msg || ''}</span>
|
||||||
|
<button class="close" aria-label="Schließen">✕</button>
|
||||||
|
`;
|
||||||
|
root.appendChild(n);
|
||||||
|
|
||||||
|
const close = () => { try { n.remove(); } catch {} };
|
||||||
|
n.querySelector('.close')?.addEventListener('click', close);
|
||||||
|
|
||||||
|
const t = setTimeout(close, Number(duration) || 2200);
|
||||||
|
// clean timer if user closes early
|
||||||
|
n.addEventListener('remove', () => clearTimeout(t), { once:true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
|
function show(msg, opt) {
|
||||||
|
opt = opt || {};
|
||||||
|
render(
|
||||||
|
msg,
|
||||||
|
opt.type === 'error' ? 'error' : 'success',
|
||||||
|
opt.duration,
|
||||||
|
opt.container // optional: pass a specific container
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show };
|
||||||
|
})();
|
||||||
|
|
||||||
69
public/assets/js/ui-auth.js
Normal file
69
public/assets/js/ui-auth.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// assets/js/ui-auth.js
|
||||||
|
/**
|
||||||
|
* Bindet einen Logout-Button und leitet nach dem Logout weiter (default: /login.php).
|
||||||
|
* Usage:
|
||||||
|
* import { mountLogoutButton, ensureFloatingLogout } from './ui-auth.js';
|
||||||
|
* mountLogoutButton('#btn-logout', { redirect: '/login.php' });
|
||||||
|
*/
|
||||||
|
export function mountLogoutButton(selector = '#btn-logout', opts = {}) {
|
||||||
|
const { redirect = '/login.php' } = opts;
|
||||||
|
const btn = document.querySelector(selector);
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
// Doppelte Bindings verhindern
|
||||||
|
if (btn.dataset.bound === '1') return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
btn.disabled = true;
|
||||||
|
const oldText = btn.textContent;
|
||||||
|
btn.textContent = 'Abmelden…';
|
||||||
|
try {
|
||||||
|
await fetch('api.php?action=auth.logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout failed', err);
|
||||||
|
// Fallback: trotzdem auf Login
|
||||||
|
} finally {
|
||||||
|
window.location.href = redirect;
|
||||||
|
// Falls Redirect durch Policy o.ä. blockiert wäre:
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = oldText;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.dataset.bound = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt bei Bedarf automatisch einen Floating-Logout-Button (oben rechts)
|
||||||
|
* und bindet ihn (gut für Staging/Admin).
|
||||||
|
*/
|
||||||
|
export function ensureFloatingLogout(opts = {}) {
|
||||||
|
if (document.querySelector('#btn-logout')) {
|
||||||
|
mountLogoutButton('#btn-logout', opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'btn-logout';
|
||||||
|
btn.textContent = 'Logout';
|
||||||
|
Object.assign(btn.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
right: '12px',
|
||||||
|
top: '12px',
|
||||||
|
zIndex: '10000',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
cursor: 'pointer'
|
||||||
|
});
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
mountLogoutButton('#btn-logout', opts);
|
||||||
|
}
|
||||||
|
|
||||||
26
public/assets/js/ui-create.js
Normal file
26
public/assets/js/ui-create.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { apiList, apiCreate, toast } from './api.js';
|
||||||
|
export function initCreate(){
|
||||||
|
const btn=document.getElementById('btn-new'), dlg=document.getElementById('createDialog'), form=document.getElementById('createForm'), fields=document.getElementById('createFields'), hint=document.getElementById('createHint');
|
||||||
|
if(!btn||!dlg||!form||!fields) return;
|
||||||
|
const curTab=()=>{ const a=document.querySelector('nav [data-tab].bg-sky-50')||document.querySelector('nav [data-tab]'); return a?a.getAttribute('data-tab'):'templates'; };
|
||||||
|
|
||||||
|
btn.onclick = async ()=>{
|
||||||
|
fields.innerHTML=''; const tab=curTab();
|
||||||
|
const name=document.createElement('input'); name.type='text'; name.required=true; name.placeholder='Name*'; name.className='w-full border rounded-lg px-3 py-2'; name.id='f-name'; fields.appendChild(name);
|
||||||
|
async function addSel(id,label,res){ const sel=document.createElement('select'); sel.id=id; sel.className='w-full border rounded-lg px-3 py-2'; sel.innerHTML=`<option value="">(ohne ${label}-Zuordnung)</option>`; const data=await apiList(res); (data||[]).forEach(t=>{ const o=document.createElement('option'); o.value=t.id; o.textContent=`#${t.id} · ${t.name||''}`; sel.appendChild(o); }); fields.appendChild(sel); }
|
||||||
|
if(tab==='sections') await addSel('f-template','Template','templates');
|
||||||
|
if(tab==='blocks') await addSel('f-section','Section','sections');
|
||||||
|
if(tab==='snippets') await addSel('f-block','Block','blocks');
|
||||||
|
hint.textContent=`Neues ${tab} anlegen`; dlg.showModal();
|
||||||
|
|
||||||
|
form.onsubmit=async(e)=>{ e.preventDefault();
|
||||||
|
const payload={ name:(document.getElementById('f-name')?.value||'').trim() }; if(!payload.name) return;
|
||||||
|
if(tab==='snippets') payload.content=''; else payload.html='';
|
||||||
|
if(tab==='sections') payload.template_id=document.getElementById('f-template')?.value||null;
|
||||||
|
if(tab==='blocks') payload.section_id =document.getElementById('f-section')?.value ||null;
|
||||||
|
if(tab==='snippets') payload.block_id =document.getElementById('f-block')?.value ||null;
|
||||||
|
const r=await apiCreate(tab,payload); if(r&&r.id){ dlg.close(); toast('Erstellt',true); window.loadList && window.loadList(tab); } else { toast('Erstellen fehlgeschlagen',false,{duration:3000}); console.error('Create failed',r); }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const cancel=document.getElementById('createCancel'); cancel && (cancel.onclick=()=>dlg.close());
|
||||||
|
}
|
||||||
438
public/assets/js/ui-editor.js
Normal file
438
public/assets/js/ui-editor.js
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
/* /assets/js/ui-editor.js (KORRIGIERT: Speichern wird an iFrame-Editor delegiert) */
|
||||||
|
// Öffnen, Befüllen, Speichern (mit Live-HTML), Preview – Race-Schutz & Lade-Overlay.
|
||||||
|
|
||||||
|
import { apiUpdate, apiList, apiGet, toast, apiAction } from './api.js';
|
||||||
|
|
||||||
|
export function initEditor() {
|
||||||
|
// ... (Alle Konstanten bleiben unverändert) ...
|
||||||
|
const dlg = document.getElementById('editorDialog');
|
||||||
|
const iframe = document.getElementById('editorFrame');
|
||||||
|
const btnSave = document.getElementById('btn-save');
|
||||||
|
const btnPreview = document.getElementById('btn-preview');
|
||||||
|
const btnTest = document.getElementById('btn-test');
|
||||||
|
const btnClose = document.getElementById('btn-close');
|
||||||
|
const btnClear = document.getElementById('btn-clear-main');
|
||||||
|
|
||||||
|
const prevDlg = document.getElementById('previewDialog');
|
||||||
|
const sendDlg = document.getElementById('sendTestDialog');
|
||||||
|
const sendForm = document.getElementById('sendTestForm');
|
||||||
|
const sendTo = document.getElementById('send_to');
|
||||||
|
const sendSubject = document.getElementById('send_subject');
|
||||||
|
const btnCancelSend= document.getElementById('btn-cancel-send');
|
||||||
|
const btnSendNow = document.getElementById('btn-send-now');
|
||||||
|
const prevFrame = document.getElementById('previewFrame');
|
||||||
|
const btnPrevClose = document.getElementById('btn-close-preview');
|
||||||
|
|
||||||
|
let current = null; // { resource, id, name }
|
||||||
|
let bridgeListener = null;
|
||||||
|
let reqToken = 0; // steigender Token pro Öffnen -> ignoriert verspätete Events
|
||||||
|
|
||||||
|
const ok = (m) => toast(m, true);
|
||||||
|
const err = (m) => toast(m, false);
|
||||||
|
|
||||||
|
// ---------- Hilfen ----------
|
||||||
|
function activeMode() {
|
||||||
|
const b = document.querySelector('nav [data-tab].bg-sky-50, nav [data-tab].text-sky-700, nav [data-tab].active');
|
||||||
|
return (b?.dataset?.tab) || (current?.resource) || 'templates';
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHtmlToFrame(html) {
|
||||||
|
iframe.srcdoc = `<!doctype html><html>
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
|
<body id="gjs">${html || ''}</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEditedHtml() {
|
||||||
|
const win = iframe?.contentWindow;
|
||||||
|
const doc = iframe?.contentDocument;
|
||||||
|
if (!win || !doc) return '';
|
||||||
|
|
||||||
|
const ed = win.__gjs || (win.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
|
||||||
|
if (ed && typeof ed.getHtml === 'function') {
|
||||||
|
const html = ed.getHtml();
|
||||||
|
const css = (typeof ed.getCss === 'function') ? ed.getCss() : '';
|
||||||
|
return css ? `<style>${css}</style>\n${html}` : html;
|
||||||
|
}
|
||||||
|
const root = doc.querySelector('#gjs') || doc.body || doc.documentElement;
|
||||||
|
return root ? root.innerHTML : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForEditor(maxMs = 8000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const start = Date.now();
|
||||||
|
(function poll() {
|
||||||
|
const win = iframe?.contentWindow;
|
||||||
|
const ed = win?.__gjs || (win?.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
|
||||||
|
if (ed) return resolve(ed);
|
||||||
|
if (Date.now() - start > maxMs) return reject(new Error('Editor not ready'));
|
||||||
|
setTimeout(poll, 120);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🚨 NEUE FUNKTION: Delegiert das Kommando an den Editor im iFrame
|
||||||
|
async function delegateCommand(commandName) {
|
||||||
|
try {
|
||||||
|
const editor = await waitForEditor(3000);
|
||||||
|
if (editor.Commands.has(commandName)) {
|
||||||
|
// Führt den Command im iFrame aus (z.B. 'save-data')
|
||||||
|
editor.runCommand(commandName);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
err(`Delegieren fehlgeschlagen: Command '${commandName}' nicht gefunden.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err(`Delegieren fehlgeschlagen: Editor nicht bereit (${commandName}).`);
|
||||||
|
console.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... (hideReadyBadge bleibt unverändert) ...
|
||||||
|
function hideReadyBadge(doc) {
|
||||||
|
if (!doc) return;
|
||||||
|
const kill = () => {
|
||||||
|
const el = doc.getElementById('badge');
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
};
|
||||||
|
kill();
|
||||||
|
|
||||||
|
const style = doc.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#badge { display:none !important; }
|
||||||
|
.gjs-pn-status { display:none !important; }
|
||||||
|
.ready-badge,
|
||||||
|
.status-badge.ready,
|
||||||
|
[data-status="ready"],
|
||||||
|
[data-badge="ready"],
|
||||||
|
.gjs-ready,
|
||||||
|
.gjs-badge-ready { display:none !important; }
|
||||||
|
`;
|
||||||
|
doc.head.appendChild(style);
|
||||||
|
|
||||||
|
const mo = new MutationObserver(() => { kill(); /* hideByText(doc); */ });
|
||||||
|
mo.observe(doc.documentElement, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
setTimeout(() => { kill(); /* hideByText(doc); */ }, 150);
|
||||||
|
setTimeout(() => { kill(); /* hideByText(doc); */ }, 500);
|
||||||
|
setTimeout(() => { kill(); /* hideByText(doc); */ }, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (Lade-Overlay bleibt unverändert) ...
|
||||||
|
let veilEl = null;
|
||||||
|
function ensureVeil() {
|
||||||
|
if (veilEl) return veilEl;
|
||||||
|
veilEl = document.createElement('div');
|
||||||
|
Object.assign(veilEl.style, {
|
||||||
|
position:'absolute', inset:'0', background:'rgba(248,250,252,.85)',
|
||||||
|
display:'flex', alignItems:'center', justifyContent:'center',
|
||||||
|
zIndex:'2147483000', fontFamily:'system-ui, -apple-system, Segoe UI, Roboto, Arial',
|
||||||
|
fontSize:'14px', color:'#0f172a'
|
||||||
|
});
|
||||||
|
veilEl.innerHTML = `
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.6rem;align-items:center;">
|
||||||
|
<div class="spinner" style="width:28px;height:28px;border-radius:999px;border:3px solid #cbd5e1;border-top-color:#0ea5e9;animation:spin .8s linear infinite"></div>
|
||||||
|
<div style="font-weight:500;">Lade Editor …</div>
|
||||||
|
</div>
|
||||||
|
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||||
|
`;
|
||||||
|
const host = dlg?.querySelector('.h-full, .flex, .flex-col') || dlg;
|
||||||
|
(host || document.body).appendChild(veilEl);
|
||||||
|
return veilEl;
|
||||||
|
}
|
||||||
|
function showVeil(){ ensureVeil().style.display = 'flex'; }
|
||||||
|
function hideVeil(){ if (veilEl) veilEl.style.display = 'none'; }
|
||||||
|
|
||||||
|
// ... (Kontext-Filter-Ladung bleibt unverändert) ...
|
||||||
|
async function listBlocksForTemplate(templateId){
|
||||||
|
try {
|
||||||
|
const direct = await apiList('blocks', { template_id: templateId });
|
||||||
|
if (Array.isArray(direct) && direct.length) return direct;
|
||||||
|
} catch {}
|
||||||
|
const sections = await apiList('sections', { template_id: templateId }).catch(()=>[]);
|
||||||
|
const out = [];
|
||||||
|
for (const s of (sections || [])) {
|
||||||
|
const b = await apiList('blocks', { section_id: s.id }).catch(()=>[]);
|
||||||
|
if (b?.length) out.push(...b);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Snippets eines Templates (direkt oder via Sections->Blocks als Fallback)
|
||||||
|
async function listSnippetsForTemplate(templateId){
|
||||||
|
try {
|
||||||
|
const direct = await apiList('snippets', { template_id: templateId });
|
||||||
|
if (Array.isArray(direct) && direct.length) return direct;
|
||||||
|
} catch {}
|
||||||
|
const sections = await apiList('sections', { template_id: templateId }).catch(()=>[]);
|
||||||
|
const blocksAll = [];
|
||||||
|
for (const s of (sections || [])) {
|
||||||
|
const b = await apiList('blocks', { section_id: s.id }).catch(()=>[]);
|
||||||
|
if (b?.length) blocksAll.push(...b);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const b of blocksAll) {
|
||||||
|
const sn = await apiList('snippets', { block_id: b.id }).catch(()=>[]);
|
||||||
|
if (sn?.length) out.push(...sn);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Referenz-Bibliothek (für „Custom – Fix“)
|
||||||
|
async function buildRefLibForContext(ctx){
|
||||||
|
const kind = (ctx.resource || 'templates').replace(/s$/,''); // template|section|block
|
||||||
|
const id = ctx.id;
|
||||||
|
if (kind === 'template'){
|
||||||
|
const [sections, blocks] = await Promise.all([
|
||||||
|
apiList('sections', { template_id: id }).catch(()=>[]),
|
||||||
|
listBlocksForTemplate(id)
|
||||||
|
]);
|
||||||
|
return { sections, blocks };
|
||||||
|
}
|
||||||
|
if (kind === 'section'){
|
||||||
|
const blocks = await apiList('blocks', { section_id: id }).catch(()=>[]);
|
||||||
|
return { sections: [], blocks };
|
||||||
|
}
|
||||||
|
return { sections: [], blocks: [] }; // block -> keine Sections/Blocks in Fix
|
||||||
|
}
|
||||||
|
// Snippets (für „Custom – Flex“) kontextabhängig
|
||||||
|
async function buildSnippetsForContext(ctx){
|
||||||
|
const kind = (ctx.resource || 'templates').replace(/s$/,'');
|
||||||
|
const id = ctx.id;
|
||||||
|
let rows = [];
|
||||||
|
if (kind === 'template') rows = await listSnippetsForTemplate(id);
|
||||||
|
else if (kind === 'section') rows = await apiList('snippets', { section_id: id }).catch(()=>[]);
|
||||||
|
else if (kind === 'block') rows = await apiList('snippets', { block_id: id }).catch(()=>[]);
|
||||||
|
else rows = await apiList('snippets').catch(()=>[]);
|
||||||
|
return (rows || []).map(r => ({ id: r.id, name: r.name, html: r.content || r.html || '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Initialen HTML-Inhalt in Editor pushen (mit Token/Race-Schutz) ----------
|
||||||
|
async function pushInitialHtmlToEditor({ mode, html, snippets, ref, token }) {
|
||||||
|
if (token !== reqToken) return; // veraltete Anfrage ignorieren
|
||||||
|
|
||||||
|
const win = iframe?.contentWindow;
|
||||||
|
const doc = iframe?.contentDocument;
|
||||||
|
|
||||||
|
// NEU: HTML wird NUR über postMessage gesendet. Die Bridge im iFrame ist verantwortlich
|
||||||
|
// dafür, das HTML in GrapesJS zu setzen, NACHDEM ihre Plugins fertig sind.
|
||||||
|
try {
|
||||||
|
win?.postMessage({ source:'admin', type:'init', mode, html: html || '', snippets: snippets || [], ref: ref || {} }, '*');
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Warten auf Editor ist noch sinnvoll, um das Lade-Badge zu unterdrücken,
|
||||||
|
// aber wir manipulieren den Editor NICHT MEHR direkt von hier aus.
|
||||||
|
await waitForEditor(6000);
|
||||||
|
if (token !== reqToken) return;
|
||||||
|
|
||||||
|
// ... (Gelöschte Logik: ed.setComponents(html) ist nun in der Bridge-Logik) ...
|
||||||
|
} catch {
|
||||||
|
/* Falls GJS noch nicht bereit ist, arbeiten wir nur via postMessage. */
|
||||||
|
}
|
||||||
|
|
||||||
|
try { hideReadyBadge(doc); } catch {}
|
||||||
|
if (token === reqToken) hideVeil();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Öffnen ----------
|
||||||
|
async function open(item, resource) {
|
||||||
|
current = {
|
||||||
|
resource: String(resource || activeMode() || 'templates').toLowerCase(),
|
||||||
|
id: Number(item?.id || 0),
|
||||||
|
name: item?.name || ''
|
||||||
|
};
|
||||||
|
if (!current.id) return err('Ungültige ID');
|
||||||
|
|
||||||
|
// globaler Kontext
|
||||||
|
window.__currentItemId = current.id;
|
||||||
|
window.__currentEditorCtx = { id: current.id, mode: current.resource };
|
||||||
|
|
||||||
|
// Neuen Token erzeugen & alten Listener entfernen
|
||||||
|
reqToken++;
|
||||||
|
const myToken = reqToken;
|
||||||
|
if (bridgeListener) window.removeEventListener('message', bridgeListener);
|
||||||
|
bridgeListener = null;
|
||||||
|
|
||||||
|
// Overlay zeigen
|
||||||
|
showVeil();
|
||||||
|
|
||||||
|
// Daten parallel laden (fresh HTML + kontextgefilterte Snippets + Referenzen)
|
||||||
|
let fresh = '';
|
||||||
|
let snippets = [];
|
||||||
|
let refLib = { sections: [], blocks: [] };
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
(async() => {
|
||||||
|
try {
|
||||||
|
const row = await apiGet(current.resource, current.id);
|
||||||
|
// API liefert jetzt top-level html/content; fallback auf item.*
|
||||||
|
fresh = row?.html ?? row?.content ?? row?.item?.html ?? row?.item?.content ?? '';
|
||||||
|
} catch {}
|
||||||
|
})(),
|
||||||
|
(async() => { snippets = await buildSnippetsForContext(current); })(),
|
||||||
|
(async() => { refLib = await buildRefLibForContext(current); })()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// iFrame-Load -> Bridge-Ready abhören
|
||||||
|
iframe.onload = function () {
|
||||||
|
if (myToken !== reqToken) return;
|
||||||
|
|
||||||
|
try { hideReadyBadge(iframe.contentDocument); } catch {}
|
||||||
|
|
||||||
|
bridgeListener = (ev) => {
|
||||||
|
const d = ev?.data || {};
|
||||||
|
if (!d) return;
|
||||||
|
// wir erwarten Nachrichten aus der Bridge/Editor
|
||||||
|
if (d.source !== 'bridge' && d.source !== 'editor') return;
|
||||||
|
if (myToken !== reqToken) return;
|
||||||
|
|
||||||
|
// NEU: Wenn der Editor meldet, dass er *gespeichert* hat,
|
||||||
|
// aktualisieren wir die Liste im Elternfenster
|
||||||
|
if (d.type === 'save:success') {
|
||||||
|
ok('Gespeichert');
|
||||||
|
try {
|
||||||
|
if (typeof window.reloadActiveList === 'function') window.reloadActiveList();
|
||||||
|
else if (typeof window.__reloadList === 'function') window.__reloadList(current.resource);
|
||||||
|
} catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// neue Bridge meldet gjs:ready; ältere evtl. core-ready/bridge:ready
|
||||||
|
if (d.type === 'gjs:ready' || d.type === 'core-ready' || d.type === 'bridge:ready' || d.type === 'bridge:booted') {
|
||||||
|
pushInitialHtmlToEditor({
|
||||||
|
mode: current.resource,
|
||||||
|
html: fresh,
|
||||||
|
snippets,
|
||||||
|
ref: {
|
||||||
|
sections: (refLib.sections || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' })),
|
||||||
|
blocks: (refLib.blocks || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' }))
|
||||||
|
},
|
||||||
|
token: myToken
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', bridgeListener);
|
||||||
|
|
||||||
|
// Fallback, falls kein Ready ankommt
|
||||||
|
setTimeout(() => {
|
||||||
|
pushInitialHtmlToEditor({
|
||||||
|
mode: current.resource,
|
||||||
|
html: fresh,
|
||||||
|
snippets,
|
||||||
|
ref: {
|
||||||
|
sections: (refLib.sections || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' })),
|
||||||
|
blocks: (refLib.blocks || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' }))
|
||||||
|
},
|
||||||
|
token: myToken
|
||||||
|
});
|
||||||
|
}, 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jetzt den Editor-Core laden (erst NACH about:blank)
|
||||||
|
iframe.src = `editor/editor-core.php?mode=${encodeURIComponent(current.resource)}&id=${current.id}&t=${Date.now()}`;
|
||||||
|
|
||||||
|
dlg?.showModal?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Speichern (DELEGIERT) ----------
|
||||||
|
// 🚨 KORRIGIERT: Delegiert Speichern an den iFrame, der die JSON-Daten holt!
|
||||||
|
async function save() {
|
||||||
|
if (!current?.id) return err('Keine aktive ID');
|
||||||
|
|
||||||
|
const mode = activeMode();
|
||||||
|
if (mode !== 'snippets') { // Nur Templates/Blocks/Sections delegieren, Snippets behalten die alte Logik (NUR HTML)
|
||||||
|
return delegateCommand('save-data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alte Snippet-Logik beibehalten (falls der Snippet-Editor nicht GrapesJS ist und nur HTML erwartet)
|
||||||
|
const liveHtml = await readEditedHtml();
|
||||||
|
|
||||||
|
const payload = { id: current.id, content: liveHtml };
|
||||||
|
|
||||||
|
const res = await apiUpdate(mode, current.id, payload);
|
||||||
|
if (!res?.ok) { err('Speichern fehlgeschlagen'); return; }
|
||||||
|
|
||||||
|
ok('Gespeichert');
|
||||||
|
try {
|
||||||
|
if (typeof window.reloadActiveList === 'function') await window.reloadActiveList();
|
||||||
|
else if (typeof window.__reloadList === 'function') window.__reloadList(mode);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... (Der Rest der Funktionen bleibt unverändert) ...
|
||||||
|
async function clearEditor() {
|
||||||
|
const win = iframe?.contentWindow;
|
||||||
|
const ed = win?.__gjs || (win?.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
|
||||||
|
if (ed) {
|
||||||
|
ed.setComponents('');
|
||||||
|
ed.setStyle('');
|
||||||
|
} else {
|
||||||
|
writeHtmlToFrame('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPreview() {
|
||||||
|
const html = await readEditedHtml();
|
||||||
|
prevFrame.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${html || '<em>(leer)</em>'}</body></html>`;
|
||||||
|
prevDlg?.showModal?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSend() {
|
||||||
|
sendSubject.value = 'Testversand';
|
||||||
|
sendTo.value = '';
|
||||||
|
sendDlg?.showModal?.();
|
||||||
|
}
|
||||||
|
function closeSend(){ sendDlg?.close?.(); }
|
||||||
|
|
||||||
|
async function doSend(ev){
|
||||||
|
ev?.preventDefault?.();
|
||||||
|
const to = sendTo.value.trim();
|
||||||
|
if(!to){ toast("Bitte Empfänger angeben", false); return; }
|
||||||
|
const win = iframe?.contentWindow;
|
||||||
|
const ctx = (win && win.__currentEditorCtx) || {};
|
||||||
|
const id = (window.__currentItemId || ctx?.id || 0);
|
||||||
|
if(!id){ toast("Kein Template geladen", false); return; }
|
||||||
|
// Hier wird der gespeicherte HTML-Code verwendet, nicht der Live-HTML, da apiAction
|
||||||
|
// keine Live-Daten erwartet. Es geht um template_id.
|
||||||
|
const r = await apiAction('templates.test_send', { method:'POST', data:{ template_id: id, to, subject: sendSubject.value || 'Testversand' } });
|
||||||
|
if(r?.ok){ toast("Testversand ausgelöst"); closeSend(); } else { toast("Senden fehlgeschlagen", false); }
|
||||||
|
}
|
||||||
|
function closePreview(){ prevDlg?.close?.(); }
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
// nächstes Öffnen invalidiert laufende asyncs
|
||||||
|
reqToken++;
|
||||||
|
|
||||||
|
try { iframe.contentWindow?.postMessage({source:'admin',type:'reset'}, '*'); } catch {}
|
||||||
|
if (bridgeListener) window.removeEventListener('message', bridgeListener);
|
||||||
|
bridgeListener = null;
|
||||||
|
|
||||||
|
hideVeil();
|
||||||
|
dlg?.close?.();
|
||||||
|
|
||||||
|
// iFrame zurück auf blank
|
||||||
|
iframe.src = 'about:blank#' + Date.now();
|
||||||
|
|
||||||
|
// Kontext leeren
|
||||||
|
current = null;
|
||||||
|
window.__currentItemId = undefined;
|
||||||
|
window.__currentEditorCtx = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
btnSave && (btnSave.onclick = save);
|
||||||
|
btnClear && (btnClear.onclick = clearEditor);
|
||||||
|
btnClose && (btnClose.onclick = close);
|
||||||
|
btnPrevClose && (btnPrevClose.onclick = closePreview);
|
||||||
|
btnPreview && (btnPreview.onclick = openPreview);
|
||||||
|
btnTest && (btnTest.onclick = openSend);
|
||||||
|
btnCancelSend&& (btnCancelSend.onclick= closeSend);
|
||||||
|
sendForm && (sendForm.onsubmit = doSend);
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
window.EditorUI = { open, save, close, clear: clearEditor, preview: openPreview };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default-Export + globaler Fallback
|
||||||
|
export default initEditor;
|
||||||
|
window.initEditor = initEditor;
|
||||||
170
public/assets/js/ui-list.js
Normal file
170
public/assets/js/ui-list.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { apiList, apiGet, apiDelete, apiUpdate, toast } from './api.js';
|
||||||
|
|
||||||
|
function esc(s=''){
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g,'&')
|
||||||
|
.replace(/</g,'<')
|
||||||
|
.replace(/>/g,'>')
|
||||||
|
.replace(/"/g,'"')
|
||||||
|
.replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSnippetEditor(id){
|
||||||
|
const dlg = document.getElementById('editSnippetDialog');
|
||||||
|
const form = document.getElementById('editSnippetForm');
|
||||||
|
const inpName = document.getElementById('edit_snip_name');
|
||||||
|
const taContent = document.getElementById('edit_snip_content');
|
||||||
|
const btnCancel = document.getElementById('editSnippetCancel');
|
||||||
|
|
||||||
|
// Daten laden
|
||||||
|
let row = {};
|
||||||
|
try { row = await apiGet('snippets', id) || {}; } catch(e){}
|
||||||
|
|
||||||
|
if (inpName) inpName.value = row.name || '';
|
||||||
|
if (taContent) taContent.value = row.content || '';
|
||||||
|
|
||||||
|
function cleanup(){
|
||||||
|
form && form.removeEventListener('submit', onSubmit);
|
||||||
|
btnCancel && (btnCancel.onclick = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(ev){
|
||||||
|
ev.preventDefault();
|
||||||
|
try{
|
||||||
|
const res = await apiUpdate('snippets', id, {
|
||||||
|
name: inpName ? inpName.value : '',
|
||||||
|
content: taContent ? taContent.value : ''
|
||||||
|
});
|
||||||
|
toast(res && res.ok ? 'Snippet gespeichert' : 'Speichern fehlgeschlagen', !!(res && res.ok));
|
||||||
|
dlg && dlg.close();
|
||||||
|
cleanup();
|
||||||
|
// Liste neu laden
|
||||||
|
loadList('snippets');
|
||||||
|
}catch(e){
|
||||||
|
toast('Speichern fehlgeschlagen', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) form.addEventListener('submit', onSubmit, { once:false });
|
||||||
|
if (btnCancel) btnCancel.onclick = () => { dlg && dlg.close(); cleanup(); };
|
||||||
|
|
||||||
|
dlg && dlg.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadList(resource){
|
||||||
|
const el=document.getElementById(`view-${resource}`); if(!el) return;
|
||||||
|
|
||||||
|
el.innerHTML=`<div class='rounded-2xl border bg-white overflow-hidden'>
|
||||||
|
<div class='px-4 py-2 border-b bg-gray-50 text-sm font-medium'>${resource.charAt(0).toUpperCase()+resource.slice(1)}</div>
|
||||||
|
<div id='list-${resource}' class='divide-y'>Lade …</div></div>`;
|
||||||
|
|
||||||
|
const data=await apiList(resource);
|
||||||
|
const list=el.querySelector(`#list-${resource}`);
|
||||||
|
|
||||||
|
if(!Array.isArray(data)||data.length===0){
|
||||||
|
list.innerHTML=`<div class='p-4 text-sm text-gray-500'>Keine Einträge</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentBadge(r,it){
|
||||||
|
if(r==='sections'&&it.template_id) return `<span class="chip"><span class="dot"></span> Template #${it.template_id}${it.template_name ? ' · '+esc(it.template_name) : ''}</span>`;
|
||||||
|
if(r==='blocks'&&it.section_id) return `<span class="chip"><span class="dot"></span> Section #${it.section_id}${it.section_name ? ' · '+esc(it.section_name) : ''}</span>`;
|
||||||
|
if(r==='snippets'&&it.block_id) return `<span class="chip"><span class="dot"></span> Block #${it.block_id}${it.block_name ? ' · '+esc(it.block_name) : ''}</span>`;
|
||||||
|
return '<span class="chip"><span class="dot"></span> frei</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML=data.map(item=>{
|
||||||
|
const name = esc(item.name||'');
|
||||||
|
const openBtn = (['templates','sections','blocks'].includes(resource))
|
||||||
|
? `<button class='btn' data-open='${resource}:${item.id}'>Im E-Mail-Editor öffnen</button>` : '';
|
||||||
|
|
||||||
|
const editBtn = (resource==='snippets')
|
||||||
|
? `<button class='btn' data-edit='snippets:${item.id}'>Bearbeiten</button>` : '';
|
||||||
|
|
||||||
|
const prevBtn = `<button class='btn' data-preview='${resource}:${item.id}'>Vorschau</button>`;
|
||||||
|
const delBtn = `<button class='btn btn-danger' data-del='${resource}:${item.id}' data-name='${name}'>Löschen</button>`;
|
||||||
|
const debugBtn= `<a class='btn' href='api.php?resource=${resource}&action=get&id=${item.id}' target='_blank' rel='noopener'>GET</a>`;
|
||||||
|
|
||||||
|
return `<div class='p-3 flex items-center gap-3'>
|
||||||
|
<div class='min-w-48 font-medium truncate' title="${name}">${name || '(ohne Name)'}</div>
|
||||||
|
<div class='text-xs text-gray-500'>#${item.id}</div>
|
||||||
|
<div class='text-xs'>${parentBadge(resource,item)}</div>
|
||||||
|
<div class='ms-auto flex gap-2'>${[openBtn, editBtn, prevBtn, delBtn, debugBtn].filter(Boolean).join('')}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// --- Editor öffnen (ANPASSUNG) -----------------------------------------
|
||||||
|
list.querySelectorAll('[data-open]').forEach(b=>b.addEventListener('click', async ()=>{
|
||||||
|
const [res,id]=b.dataset.open.split(':');
|
||||||
|
|
||||||
|
// Detail laden, um Name + aktuellen HTML/Content zu haben
|
||||||
|
const obj = await apiGet(res,id);
|
||||||
|
const name = obj?.name || '';
|
||||||
|
const html = obj ? (obj.html ?? obj.content ?? '') : '';
|
||||||
|
|
||||||
|
// Globale Kontexte (werden von Editor/anderen Modulen genutzt)
|
||||||
|
window.__currentItemId = Number(id);
|
||||||
|
window.__currentEditorCtx = { id:Number(id), mode:res };
|
||||||
|
|
||||||
|
// Bevorzugt EditorUI.open nutzen; Fallback: __openEditor (Bestand)
|
||||||
|
if (window.EditorUI && typeof window.EditorUI.open === 'function') {
|
||||||
|
window.EditorUI.open({ id:Number(id), name, html }, res);
|
||||||
|
} else if (window.__openEditor) {
|
||||||
|
window.__openEditor({ resource:res, id:Number(id), name, html });
|
||||||
|
} else {
|
||||||
|
console.warn('Kein Editor-Entry-Point gefunden (EditorUI.open / __openEditor).');
|
||||||
|
toast('Editor ist nicht initialisiert.', false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// edit snippet
|
||||||
|
list.querySelectorAll('[data-edit]').forEach(b=>b.addEventListener('click', async ()=>{
|
||||||
|
const [, id] = b.dataset.edit.split(':');
|
||||||
|
await openSnippetEditor(id);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// preview
|
||||||
|
const prevDlg=document.getElementById('previewDialog'), prevFrame=document.getElementById('previewFrame');
|
||||||
|
list.querySelectorAll('[data-preview]').forEach(b=>b.addEventListener('click', async ()=>{
|
||||||
|
const [res,id]=b.dataset.preview.split(':');
|
||||||
|
const obj=await apiGet(res,id);
|
||||||
|
const html=(obj?.html||obj?.content||'<em>(leer)</em)');
|
||||||
|
prevFrame.srcdoc='<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>'+html+'</body></html>';
|
||||||
|
prevDlg.showModal();
|
||||||
|
}));
|
||||||
|
|
||||||
|
// delete
|
||||||
|
const delDlg=document.getElementById('deleteDialog'),
|
||||||
|
delText=document.getElementById('deleteText'),
|
||||||
|
delForm=document.getElementById('deleteForm'),
|
||||||
|
delCancel=document.getElementById('deleteCancel');
|
||||||
|
|
||||||
|
let pending=null;
|
||||||
|
delCancel && (delCancel.onclick=()=>{pending=null;delDlg.close();});
|
||||||
|
|
||||||
|
list.querySelectorAll('[data-del]').forEach(b=>b.addEventListener('click',()=>{
|
||||||
|
const [res,id]=b.dataset.del.split(':'); const nm=b.dataset.name||'';
|
||||||
|
pending={res,id,nm};
|
||||||
|
delText && (delText.innerHTML=`Soll <strong>${nm || '(ohne Name)'} #${id}</strong> aus <strong>${res}</strong> wirklich gelöscht werden?<br><span class="text-rose-600">Achtung:</span> Kinder-Elemente werden <em>nicht</em> automatisch mit gelöscht.`);
|
||||||
|
delDlg.showModal();
|
||||||
|
}));
|
||||||
|
|
||||||
|
delForm && (delForm.onsubmit=async(e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
if(!pending) return delDlg.close();
|
||||||
|
const r=await apiDelete(pending.res,pending.id);
|
||||||
|
delDlg.close();
|
||||||
|
toast(r&&r.ok?'Gelöscht':'Löschen fehlgeschlagen', !!(r&&r.ok), {duration:3000});
|
||||||
|
loadList(resource);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initLists(){
|
||||||
|
loadList('templates');
|
||||||
|
// Public reload helper (wird vom Snippet-Editor genutzt)
|
||||||
|
window.__reloadList = loadList;
|
||||||
|
// Backwards compat (falls woanders genutzt)
|
||||||
|
window.loadList = loadList;
|
||||||
|
}
|
||||||
|
|
||||||
11
public/assets/js/ui-tabs.js
Normal file
11
public/assets/js/ui-tabs.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function initTabs(){
|
||||||
|
const tabs=document.querySelectorAll('nav [data-tab]'); if(!tabs.length) return;
|
||||||
|
const views={ templates:document.getElementById('view-templates'), sections:document.getElementById('view-sections'), blocks:document.getElementById('view-blocks'), snippets:document.getElementById('view-snippets') };
|
||||||
|
tabs.forEach(btn=>btn.addEventListener('click',()=>{
|
||||||
|
tabs.forEach(b=>b.classList.remove('bg-sky-50','text-sky-700'));
|
||||||
|
btn.classList.add('bg-sky-50','text-sky-700');
|
||||||
|
document.querySelectorAll('.view').forEach(v=>v.classList.add('hidden'));
|
||||||
|
const tab=btn.dataset.tab; views[tab]?.classList.remove('hidden');
|
||||||
|
window.loadList && window.loadList(tab);
|
||||||
|
}));
|
||||||
|
}
|
||||||
158
public/assets/js/ui-tools.js
Normal file
158
public/assets/js/ui-tools.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// assets/js/ui-tools.js
|
||||||
|
// Öffnet API-Health (JSON), DB-Doctor (Iframe) & beliebige JSON-GETs im Popup,
|
||||||
|
// ohne die Seite zu verlassen. Links bleiben als Fallback nutzbar.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const dlg = document.getElementById('toolsDialog');
|
||||||
|
if (!dlg) return;
|
||||||
|
|
||||||
|
const title = document.getElementById('toolsTitle');
|
||||||
|
const btnX = document.getElementById('toolsClose');
|
||||||
|
const btnCopy = document.getElementById('toolsCopy');
|
||||||
|
const btnDl = document.getElementById('toolsDownload');
|
||||||
|
|
||||||
|
const jsonWrap = document.getElementById('toolsJsonWrap');
|
||||||
|
const jsonPre = document.getElementById('toolsJsonPre');
|
||||||
|
const frame = document.getElementById('toolsFrame');
|
||||||
|
|
||||||
|
function showJson(obj, ttl) {
|
||||||
|
title.textContent = ttl || 'Antwort (JSON)';
|
||||||
|
const txt = (typeof obj === 'string') ? obj : JSON.stringify(obj, null, 2);
|
||||||
|
jsonPre.textContent = txt;
|
||||||
|
jsonWrap.classList.remove('hidden');
|
||||||
|
frame.classList.add('hidden');
|
||||||
|
btnCopy.classList.remove('hidden');
|
||||||
|
btnDl.classList.remove('hidden');
|
||||||
|
try { dlg.showModal(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFrame(url, ttl) {
|
||||||
|
title.textContent = ttl || 'Werkzeug';
|
||||||
|
frame.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||||
|
frame.classList.remove('hidden');
|
||||||
|
jsonWrap.classList.add('hidden');
|
||||||
|
btnCopy.classList.add('hidden');
|
||||||
|
btnDl.classList.add('hidden');
|
||||||
|
try { dlg.showModal(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnX?.addEventListener('click', () => {
|
||||||
|
try { dlg.close(); } catch {}
|
||||||
|
frame.src = 'about:blank';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnCopy?.addEventListener('click', async () => {
|
||||||
|
const txt = jsonPre.textContent || '';
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(txt);
|
||||||
|
// optional: kleines Feedback
|
||||||
|
(window.Toast?.show || window.toast || (()=>{}))('In Zwischenablage kopiert');
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnDl?.addEventListener('click', () => {
|
||||||
|
const blob = new Blob([jsonPre.textContent || ''], { type:'application/json;charset=utf-8' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g,'-');
|
||||||
|
a.download = `response-${stamp}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 1) Offizieller Weg: Links mit data-popup="json" | "frame"
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
document.addEventListener('click', async (ev) => {
|
||||||
|
const a = ev.target.closest('a[data-popup]');
|
||||||
|
if (!a) return;
|
||||||
|
|
||||||
|
const mode = (a.getAttribute('data-popup') || '').toLowerCase();
|
||||||
|
const href = a.getAttribute('href') || '#';
|
||||||
|
const ttl = a.getAttribute('data-title') || a.textContent.trim() || 'Werkzeug';
|
||||||
|
|
||||||
|
if (!/^json|frame$/.test(mode)) return;
|
||||||
|
// Cmd/Strg-Klick & Mittelklick respektieren (neuer Tab)
|
||||||
|
if (ev.metaKey || ev.ctrlKey || ev.button === 1) return;
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (mode === 'frame') {
|
||||||
|
showFrame(href, ttl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode === 'json'
|
||||||
|
try {
|
||||||
|
const r = await fetch(href, { credentials: 'include' });
|
||||||
|
const txt = await r.text();
|
||||||
|
let data = null;
|
||||||
|
try { data = JSON.parse(txt); } catch { data = txt; }
|
||||||
|
showJson(data, ttl);
|
||||||
|
} catch (e) {
|
||||||
|
showJson({ ok:false, error: String(e) }, ttl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 2) Fallback: *alle* API-GET-Links (action=get) ohne data-popup
|
||||||
|
// -> automatisch im JSON-Popup öffnen
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
document.addEventListener('click', async (ev) => {
|
||||||
|
const a = ev.target.closest('a');
|
||||||
|
if (!a) return;
|
||||||
|
// bereits oben behandelt
|
||||||
|
if (a.hasAttribute('data-popup')) return;
|
||||||
|
|
||||||
|
const href = a.getAttribute('href') || '';
|
||||||
|
// nur api.php-GET-Routen mit action=get abfangen
|
||||||
|
if (!/api\.php/i.test(href) || !/[?&]action=get(&|$)/i.test(href)) return;
|
||||||
|
|
||||||
|
// Cmd/Strg/Mittelklick respektieren
|
||||||
|
if (ev.metaKey || ev.ctrlKey || ev.button === 1) return;
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const makeTitle = () => {
|
||||||
|
try {
|
||||||
|
const u = new URL(href, location.href);
|
||||||
|
const res = u.searchParams.get('resource') || 'resource';
|
||||||
|
const id = u.searchParams.get('id') || '';
|
||||||
|
// Optional: Name aus data-title wenn vorhanden
|
||||||
|
const custom = a.getAttribute('data-title');
|
||||||
|
return custom || `GET ${res}${id ? ` #${id}` : ''}`;
|
||||||
|
} catch { return 'GET'; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = makeTitle();
|
||||||
|
|
||||||
|
// Popup öffnen (wie oben)
|
||||||
|
try {
|
||||||
|
const r = await fetch(href, { credentials: 'include' });
|
||||||
|
const txt = await r.text();
|
||||||
|
let data = null;
|
||||||
|
try { data = JSON.parse(txt); } catch { data = txt; }
|
||||||
|
showJson(data, title);
|
||||||
|
} catch (e) {
|
||||||
|
showJson({ ok:false, error: String(e) }, title);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Utility: Öffnen aus Code
|
||||||
|
window.AdminTools = {
|
||||||
|
openJson(url, title) {
|
||||||
|
fetch(url, { credentials: 'include' })
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(txt => {
|
||||||
|
try { showJson(JSON.parse(txt), title); }
|
||||||
|
catch { showJson(txt, title); }
|
||||||
|
})
|
||||||
|
.catch(err => showJson({ ok:false, error:String(err) }, title));
|
||||||
|
},
|
||||||
|
openFrame(url, title) {
|
||||||
|
showFrame(url, title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
496
public/editor/bridge-core (Kopie).js
Normal file
496
public/editor/bridge-core (Kopie).js
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
/* /editor/bridge-core.js — Loader + Orchestrator (FINAL & LOG-KONTROLLIERT) */
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
// --- Initialisierung BridgeParts (B) und Plugin-Registry ---
|
||||||
|
if (!window.BridgeParts) window.BridgeParts = {};
|
||||||
|
const B = window.BridgeParts;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 🎯 LOKALE LOG-KONFIGURATION & WRAPPER
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const PluginName = 'bridge-core';
|
||||||
|
|
||||||
|
// Setzen Sie dies auf 'false', um alle Logs NUR für dieses Plugin zu deaktivieren.
|
||||||
|
if (B.LOG_CONFIG) {
|
||||||
|
B.LOG_CONFIG.PLUGINS[PluginName] = false; // bridge-core spezifisch deaktivieren (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEUER LOKALER WRAPPER, der die zentrale B.log Funktion verwendet.
|
||||||
|
* Der unformatierte Fallback WURDE ENTFERNT, da er das console.log erzeugt hat.
|
||||||
|
* Die ersten kritischen Logs WURDEN EBENFALLS ENTFERNT, da sie vor B.log lagen.
|
||||||
|
*/
|
||||||
|
const log = (type, message, color = '#1E90FF', logType = 'info', force = false) => {
|
||||||
|
// Loggt NUR, wenn B.log verfügbar ist (aus general-functions.js).
|
||||||
|
if (typeof B.log === 'function') {
|
||||||
|
B.log(PluginName, `[${type}] ${message}`, color, logType, force);
|
||||||
|
}
|
||||||
|
// Ansonsten wird NICHTS geloggt, bis general-functions.js geladen ist.
|
||||||
|
};
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 🛑 GLOBALER LOG ZUR BESTÄTIGUNG DER SKRIPT-AUSFÜHRUNG
|
||||||
|
// Dieser erste Log-Aufruf wird nun still ignoriert, da B.log noch fehlt.
|
||||||
|
// Er wird durch den SUCCESS-Log der general-functions.js ersetzt.
|
||||||
|
// log('START', `SKRIPT-AUSFÜHRUNG GESTARTET.`, '#DC143C', 'info', true); // DEAKTIVIERT/IGNORIERT DURCH FEHLENDEN B.log
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 🛑 KONFIGURATION: NEWSLETTER-PRESET-TOGGLE
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const LOAD_NEWSLETTER_PRESET = false; // <<< KRITISCHER FIX: Auf FALSE gesetzt, um den "defaults" Konflikt zu beheben!
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (window.__bridgeCoreInitialized) {
|
||||||
|
log('INIT ABORT', 'Bridge Core wurde bereits initialisiert.', 'orange');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.__bridgeCoreInitialized = true;
|
||||||
|
|
||||||
|
// --- Initialisierung BridgeParts (B) und Plugin-Registry ---
|
||||||
|
B.BASE_PATH_BRIDGE = '../assets/js/bridge/';
|
||||||
|
B.BASE_PATH_CONFIG = B.BASE_PATH_BRIDGE;
|
||||||
|
|
||||||
|
B.GrapesJSPlugins = [];
|
||||||
|
|
||||||
|
B.registerGrapesJSPlugin = (name, pluginFn) => {
|
||||||
|
B.GrapesJSPlugins.push({ name, pluginFn });
|
||||||
|
log('PLUGIN REGISTER', `Plugin zur Registry hinzugefügt: ${name}`, 'yellow');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEBUG-HELPER UND LOADER-HELPER ---
|
||||||
|
const badgeSay = (text, type = 'info') => {
|
||||||
|
const b=document.getElementById('badge');
|
||||||
|
if (!b) return;
|
||||||
|
b.textContent = text;
|
||||||
|
switch(type) {
|
||||||
|
case 'ok': b.style.background = '#dcfce7'; b.style.color = '#15803d'; b.style.borderColor = '#bbf7d0'; break;
|
||||||
|
case 'error': b.style.background = '#fee2e2'; b.style.color = '#7f1d1d'; b.style.borderColor = '#fecaca'; break;
|
||||||
|
default: b.style.background = '#eef2ff'; b.style.color = '#1e3a8a'; b.style.borderColor = '#c7d2fe';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadScript(url, done) {
|
||||||
|
const filename = url.split('/').pop();
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = url + (url.indexOf('?') === -1 ? '?v=' : '&v=') + Date.now();
|
||||||
|
s.async = false;
|
||||||
|
|
||||||
|
s.onload = function(){
|
||||||
|
log('LOAD SUCCESS', `Skript geladen: ${filename}`, 'green');
|
||||||
|
try {
|
||||||
|
done && done();
|
||||||
|
} catch(e){
|
||||||
|
if (e.message.includes('setting getter-only property "defaults"')) {
|
||||||
|
log('RUNTIME WARNING', `IGNORIERE Block-Konflikt in ${filename}: ${e.message}`, 'orange', 'warn');
|
||||||
|
} else {
|
||||||
|
// 🛑 KORREKTUR: force: true explizit auf false setzen (oder weglassen)
|
||||||
|
log('RUNTIME ERROR', `Fehler in Callback nach ${filename}: ${e.message}`, 'red', 'error', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
s.onerror = function(){
|
||||||
|
// 🛑 KORREKTUR: force: true explizit auf false setzen (oder weglassen)
|
||||||
|
log('LOAD FAILED', `Skript FEHLT oder Pfad falsch: ${filename}`, 'red', 'error', false);
|
||||||
|
badgeSay(`Ladefehler: ${filename}`, 'error');
|
||||||
|
try { done && done(); } catch(e){}
|
||||||
|
};
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HILFSFUNKTION: Wandelt den Dateinamen (z.B. blocks-standard.js) in den globalen
|
||||||
|
* Objektnamen (z.B. BridgeBlocksStandard) um.
|
||||||
|
*/
|
||||||
|
function getPluginObjectName(fileName) {
|
||||||
|
// 1. Entferne Dateiendung (.js)
|
||||||
|
let name = fileName.replace('.js', ''); // 'blocks-standard'
|
||||||
|
|
||||||
|
// 2. Teile in Bestandteile zerlegen und den ersten Buchstaben groß schreiben
|
||||||
|
const parts = name.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)); // ['Blocks', 'Standard']
|
||||||
|
|
||||||
|
// 3. Mit 'Bridge' prefixen und zusammenfügen
|
||||||
|
return 'Bridge' + parts.join(''); // 'BridgeBlocksStandard'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 NEUE FUNKTION: Erstellt alle in der Konfiguration definierten Kategorien.
|
||||||
|
function ensureConfiguredCategories(editor) {
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
const config = window.BridgeParts?.CATEGORY_CONFIG || {};
|
||||||
|
|
||||||
|
Object.keys(config)
|
||||||
|
.sort((a, b) => (config[a].ord || 999) - (config[b].ord || 999))
|
||||||
|
.forEach(catId => {
|
||||||
|
const catConf = config[catId];
|
||||||
|
// Category wird nur erstellt, wenn sie noch nicht existiert
|
||||||
|
if (!bm.getCategories().get(catId)) {
|
||||||
|
bm.getCategories().add({
|
||||||
|
id: catId,
|
||||||
|
label: catConf.label,
|
||||||
|
open: catConf.open !== false,
|
||||||
|
order: catConf.ord || 999
|
||||||
|
});
|
||||||
|
log('CAT INIT', `Kategorie '${catId}' explizit erstellt.`, 'green');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBridgeParts(cb){
|
||||||
|
const base = B.BASE_PATH_BRIDGE;
|
||||||
|
|
||||||
|
// 🛑 LOKALES LOGGING ENTFERNT, DA ES VOR B.log LIEGT.
|
||||||
|
// log('LOAD START', 'Starte Laden der modularen Bridge-Teile (Geordnet).');
|
||||||
|
|
||||||
|
const coreFiles = [
|
||||||
|
// base + 'category-config.js',
|
||||||
|
// base + 'general-functions.js',
|
||||||
|
base + 'library-parts.js',
|
||||||
|
base + 'categorization-master.js',
|
||||||
|
base + 'categorization-cleanup.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialLoadList = [...coreFiles];
|
||||||
|
|
||||||
|
function recursiveLoader(list, index = 0) {
|
||||||
|
if (index >= list.length) {
|
||||||
|
log('LOAD END', 'Initial-Bridge-Skripte geladen.', 'green');
|
||||||
|
|
||||||
|
const config = window.BridgeParts?.CATEGORY_CONFIG || {};
|
||||||
|
let allBlockFiles = [];
|
||||||
|
|
||||||
|
// Dynamisches Sammeln der Block-Dateien aus der Config
|
||||||
|
Object.keys(config)
|
||||||
|
.sort((a, b) => (config[a].ord || 999) - (config[b].ord || 999))
|
||||||
|
.forEach(key => {
|
||||||
|
// Sammelt alle Dateien, egal ob sync oder async
|
||||||
|
if (Array.isArray(config[key].files)) {
|
||||||
|
allBlockFiles.push(...config[key].files.map(file => base + file));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Duplikate entfernen (falls eine Datei in mehreren Kategorien gelistet ist)
|
||||||
|
allBlockFiles = Array.from(new Set(allBlockFiles));
|
||||||
|
|
||||||
|
function loadBlockFiles(blockIndex = 0) {
|
||||||
|
if (blockIndex >= allBlockFiles.length) {
|
||||||
|
log('LOAD END', 'Alle Blöcke geladen.', 'green');
|
||||||
|
return cb && cb(B);
|
||||||
|
}
|
||||||
|
log('LOADING BLOCKS', `Lade Block-Skript [${blockIndex + 1}/${allBlockFiles.length}]: ${allBlockFiles[blockIndex].split('/').pop()}`);
|
||||||
|
loadScript(allBlockFiles[blockIndex], function(){
|
||||||
|
loadBlockFiles(blockIndex + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadBlockFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 LOKALES LOGGING ENTFERNT, DA ES VOR B.log LIEGT.
|
||||||
|
log('LOADING CORE', `Lade Skript [${index + 1}/${initialLoadList.length}]: ${list[index].split('/').pop()}`); // Loggt ab dem 3. Skript, da general-functions.js an 2. Stelle geladen wird.
|
||||||
|
|
||||||
|
loadScript(list[index], function(){
|
||||||
|
recursiveLoader(list, index + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recursiveLoader(initialLoadList);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { parent.postMessage({ source:'bridge', type:'boot' }, '*'); } catch {}
|
||||||
|
var MODE = (window.__editorMode || 'templates').toLowerCase();
|
||||||
|
|
||||||
|
const replaceReferenceLibrary = (editor, ref, mode) => {
|
||||||
|
(window.BridgeParts?.addReferenceLibrary || (()=>{}))(editor, ref, mode);
|
||||||
|
};
|
||||||
|
const upsertCustomForBothCats = (editor, payload) => {
|
||||||
|
(window.BridgeParts?.upsertCustomForBothCats || (()=>{}))(editor, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Init & Events (Plugin integriert) ---------------------------------------------
|
||||||
|
loadBridgeParts(function(B){
|
||||||
|
log('INIT START', 'Alle Bridge-Teile geladen, starte GrapesJS-Initialisierung.', 'orange');
|
||||||
|
|
||||||
|
if (typeof grapesjs === 'undefined' || !grapesjs.init) {
|
||||||
|
// 🛑 KORREKTUR: force: true explizit auf false setzen (oder weglassen)
|
||||||
|
log('CRITICAL ERROR', 'Das globale Objekt grapesjs ist nicht verfügbar! Laden von grapes.min.js ist fehlgeschlagen.', 'red', 'error', false);
|
||||||
|
badgeSay('Fehler: GrapesJS nicht geladen!', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 KRITISCHER FIX TEIL 1: Registriere alle gesammelten Bridge-Plugins global.
|
||||||
|
if (typeof grapesjs.plugins.add === 'function') {
|
||||||
|
B.GrapesJSPlugins.forEach(p => {
|
||||||
|
grapesjs.plugins.add(p.name, p.pluginFn);
|
||||||
|
log('PLUGIN ACTIVATION', `GrapesJS Plugin global bereitgestellt: ${p.name}`, 'lime');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 🛑 KORREKTUR: force: true explizit auf false setzen (oder weglassen)
|
||||||
|
log('PLUGIN ERROR', `GrapesJS Plugin-API (grapesjs.plugins.add) fehlt. Plugins können nicht registriert werden.`, 'red', 'error', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 KRITISCHER FIX: Safety Plugin MUSS die fehlenden Views Panels hinzufügen.
|
||||||
|
function safetyPlugin(editor){
|
||||||
|
const pn = editor.Panels, orig = pn.getButton.bind(pn);
|
||||||
|
pn.getButton = (pid, id) => orig(pid, id) || { set(){}, get(){ return null; } };
|
||||||
|
|
||||||
|
// Fügen Sie das Panel 'views' hinzu, wenn es fehlt
|
||||||
|
if(!pn.getPanel('views')) {
|
||||||
|
pn.addPanel({ id: 'views', el: '.gjs-pn-views' });
|
||||||
|
log('PANEL FIX', "Das 'views' Panel wurde nachträglich hinzugefügt.", 'yellow', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stellen Sie sicher, dass der Block Manager in den Views-Container rendert
|
||||||
|
editor.Config.blockManager = editor.Config.blockManager || {};
|
||||||
|
editor.Config.blockManager.appendTo = editor.Config.blockManager.appendTo || '.gjs-blocks';
|
||||||
|
|
||||||
|
// Der fehlerhafte Timeout-Block wurde entfernt.
|
||||||
|
}
|
||||||
|
|
||||||
|
let pluginsList = [
|
||||||
|
safetyPlugin,
|
||||||
|
// 🛑 KRITISCHE ERGÄNZUNG: Aktiviert das registrierte API-Plugin
|
||||||
|
'bridge-blocks-api',
|
||||||
|
'bridge-categorization-master',
|
||||||
|
'bridge-categorization-cleanup',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (LOAD_NEWSLETTER_PRESET) {
|
||||||
|
pluginsList.push('gjs-preset-newsletter');
|
||||||
|
}
|
||||||
|
|
||||||
|
var ed = grapesjs.init({
|
||||||
|
container: '#gjs',
|
||||||
|
height: '100vh',
|
||||||
|
storageManager: false,
|
||||||
|
plugins: pluginsList,
|
||||||
|
pluginsOpts: {},
|
||||||
|
// 🛑 KRITISCHE ERGÄNZUNG: Verhindert das automatische Ausblenden leerer Kategorien
|
||||||
|
blockManager: {
|
||||||
|
hideEmpty: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.__gjs = ed;
|
||||||
|
|
||||||
|
// 🛑 KRITISCHE KORREKTUR 1: Explizite Erstellung aller konfigurierten Kategorien
|
||||||
|
ensureConfiguredCategories(ed);
|
||||||
|
|
||||||
|
// 🛑 KRITISCHE KORREKTUR 2: Sofortige Label-Korrektur
|
||||||
|
// Überschreibt den potenziell falschen, durch GrapesJS gesetzten Label-Namen
|
||||||
|
Object.keys(B.CATEGORY_CONFIG || {}).forEach(catId => {
|
||||||
|
const expectedLabel = B.CATEGORY_CONFIG[catId].label;
|
||||||
|
const categoryModel = ed.BlockManager.getCategories().get(catId);
|
||||||
|
|
||||||
|
if (categoryModel && categoryModel.get('label') !== expectedLabel) {
|
||||||
|
// Setzen ohne das 'change:label' Event auszulösen (optional, aber sauber)
|
||||||
|
categoryModel.set('label', expectedLabel, { silent: true });
|
||||||
|
log('LABEL FIX', `Kategorie '${catId}' Label auf korrigiert: '${expectedLabel}'`, 'yellow', 'warn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ---------------------------------------------------
|
||||||
|
|
||||||
|
B.ensureViews && B.ensureViews(ed);
|
||||||
|
|
||||||
|
log('BLOCK REGISTER', 'Registriere Bridge Blöcke, um Preset-Defaults zu überschreiben.', 'purple');
|
||||||
|
|
||||||
|
// 🛑 DYNAMISCHE AKTIVIERUNG DER SYNCHRONEN BLÖCKE (Ersetzt die fixen Aufrufe)
|
||||||
|
if (B.CATEGORY_CONFIG && ed) {
|
||||||
|
log('DYNAMIC ACTIVATION', 'Starte Aktivierung synchroner Block-Plugins (via Config).', 'purple');
|
||||||
|
|
||||||
|
// Iteriere über die konfigurierten Kategorien
|
||||||
|
Object.keys(B.CATEGORY_CONFIG).forEach(catId => {
|
||||||
|
const config = B.CATEGORY_CONFIG[catId];
|
||||||
|
|
||||||
|
// Verarbeite nur SYNCHRONE Plugins, die Dateien angeben
|
||||||
|
if (config.registration_mode === 'sync' && Array.isArray(config.files)) {
|
||||||
|
|
||||||
|
config.files.forEach(fileName => {
|
||||||
|
|
||||||
|
// Korrigierte Funktion liefert jetzt z.B. 'BridgeBlocksCustom'
|
||||||
|
const objectName = getPluginObjectName(fileName);
|
||||||
|
const plugin = window[objectName];
|
||||||
|
|
||||||
|
// Prüfen, ob das Skript geladen wurde und die Register-Funktion vorhanden ist
|
||||||
|
if (plugin && typeof plugin.register === 'function') {
|
||||||
|
log('DYNAMIC ACTIVATION', `Registriere sync Plugin: ${objectName} (${fileName})`, 'lime');
|
||||||
|
try {
|
||||||
|
plugin.register(ed);
|
||||||
|
} catch(e) {
|
||||||
|
log('DYNAMIC ACTIVATION ERROR', `Fehler beim Registrieren von ${objectName}: ${e.message}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('DYNAMIC ACTIVATION WARNING', `Sync Plugin Objekt oder .register() Methode nicht gefunden: ${objectName} (${fileName})`, 'orange', 'warn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------
|
||||||
|
|
||||||
|
log('INIT API', 'API-Elemente werden nun durch das Plugin bridge-blocks-api geladen.', 'orange');
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// DEBUGGING: ZÄHLE REKURSIVE EVENTS
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
let eventCounts = {};
|
||||||
|
let isParsing = false;
|
||||||
|
const MAX_CALLS = 1000;
|
||||||
|
|
||||||
|
const debugEvents = [
|
||||||
|
'component:add',
|
||||||
|
'component:update',
|
||||||
|
'change:components',
|
||||||
|
'block:add',
|
||||||
|
'change:attributes',
|
||||||
|
'comp:update:status'
|
||||||
|
];
|
||||||
|
|
||||||
|
const debugListener = (event, model) => {
|
||||||
|
if (!isParsing) return;
|
||||||
|
|
||||||
|
if (!eventCounts[event]) {
|
||||||
|
eventCounts[event] = 0;
|
||||||
|
}
|
||||||
|
eventCounts[event]++;
|
||||||
|
|
||||||
|
if (eventCounts[event] === MAX_CALLS + 1) {
|
||||||
|
// Diese kritischen Debug-Meldungen bleiben DIREKT im console-Objekt,
|
||||||
|
// da sie immer sichtbar sein müssen, um Endlosschleifen zu erkennen.
|
||||||
|
console.error(`%c[DEBUG RECURSION ALARM] 🚨 Event '${event}' hat den Grenzwert von ${MAX_CALLS} überschritten!`, 'color:red; font-size: 1.1em; font-weight: bold;');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventCounts[event] > MAX_CALLS && eventCounts[event] < (MAX_CALLS + 10)) {
|
||||||
|
const type = (model && typeof model.get === 'function') ? model.get('type') : 'N/A';
|
||||||
|
const parentType = (model && typeof model.parent === 'function' && model.parent()) ? model.parent().get('type') : 'N/A';
|
||||||
|
// Diese bleiben console.log aus demselben Grund
|
||||||
|
console.log(`%c [RECURSION SOURCE] Event: ${event}, Type: ${type}, Parent: ${parentType}`, 'color: #8b0000;');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ed.on('load', function() {
|
||||||
|
debugEvents.forEach(event => ed.on(event, (model) => debugListener(event, model)));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
(B.waitForBlocks ? B.waitForBlocks(ed) : Promise.resolve()).then(function(){
|
||||||
|
try {
|
||||||
|
log('CORE WARN', 'Führe finalen, verzögerten Cleanup-Lauf durch (2000ms).', 'orange', 'warn');
|
||||||
|
|
||||||
|
B.normalizeCategories && B.normalizeCategories(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
log('CORE ERROR', `Finaler Cleanup-Fehler: ${e.message}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MESSAGE HANDLER
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
window.addEventListener('message', async function(ev){
|
||||||
|
var data = ev.data || {};
|
||||||
|
if (data.source !== 'admin') return;
|
||||||
|
|
||||||
|
if (data.type === 'init'){
|
||||||
|
B.ensureViews && B.ensureViews(ed);
|
||||||
|
|
||||||
|
var html = (data.html || '').trim();
|
||||||
|
if (!html) html = '<table style="width:100%;font-family:Arial,sans-serif"><tr><td><h1>Neues Dokument</h1><p>Inhalt ...</p></td></tr></table>';
|
||||||
|
|
||||||
|
const applySnips = function(arr){
|
||||||
|
const list = (Array.isArray(arr)?arr:[]).map(s => ({ id:s.id, name:s.name, html: s.html || s.content || '' }));
|
||||||
|
|
||||||
|
B.replaceSnippetBlocks && B.replaceSnippetBlocks(ed, list);
|
||||||
|
|
||||||
|
upsertCustomForBothCats(ed, {
|
||||||
|
ref: (data.ref && (Array.isArray(data.ref.sections) || Array.isArray(data.ref.blocks))) ? {
|
||||||
|
sections: data.ref.sections || [],
|
||||||
|
blocks: data.ref.blocks || []
|
||||||
|
} : { sections: [], blocks: [] },
|
||||||
|
snippets: list
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
// Erneutes Normalisieren nach Laden der Snippets (falls nötig)
|
||||||
|
B.normalizeCategories && B.normalizeCategories(ed);
|
||||||
|
B.ensureViews && B.ensureViews(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
log('CORE WARN', 'normalize/render nach applySnips ausgeführt (1ms).', 'orange', 'warn');
|
||||||
|
} catch(e) {
|
||||||
|
log('CORE ERROR', `applySnips-Cleanup-Fehler: ${e.message}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(data.snippets) && data.snippets.length) applySnips(data.snippets);
|
||||||
|
else (B.fetchSnippets ? B.fetchSnippets() : Promise.resolve([])).then(applySnips);
|
||||||
|
|
||||||
|
if (data.ref && (Array.isArray(data.ref.sections) || Array.isArray(data.ref.blocks))) {
|
||||||
|
replaceReferenceLibrary(ed, {
|
||||||
|
sections: data.ref.sections || [],
|
||||||
|
blocks: data.ref.blocks || []
|
||||||
|
}, MODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finaler Aufruf nachrichtengesteuert (konsolidiert)
|
||||||
|
setTimeout(() => {
|
||||||
|
(B.waitForBlocks ? B.waitForBlocks(ed) : Promise.resolve()).then(function(){
|
||||||
|
try {
|
||||||
|
log('CORE WARN', 'Führe nachrichtengesteuerten Final-Cleanup-Lauf durch (100ms).', 'orange', 'warn');
|
||||||
|
|
||||||
|
if (!ed.__contentLoaded) {
|
||||||
|
window.__GJS_IS_PARSING = true;
|
||||||
|
isParsing = true;
|
||||||
|
eventCounts = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
ed.setComponents(html);
|
||||||
|
} catch (e) {
|
||||||
|
log('SET COMPONENTS FAILED', `setComponents Fehler: ${e.message}. Aufgerufene Event-Zähler: ${JSON.stringify(eventCounts)}`, 'red', 'error');
|
||||||
|
// console.table(eventCounts); bleibt eine direkte Debug-Ausgabe
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
window.__GJS_IS_PARSING = false;
|
||||||
|
isParsing = false;
|
||||||
|
log('CONTENT', 'HTML-Inhalt in den Editor geladen (FINAL FIX).', 'orange');
|
||||||
|
|
||||||
|
B.normalizeCategories && B.normalizeCategories(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
}
|
||||||
|
|
||||||
|
ed.__contentLoaded = true;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
B.normalizeCategories && B.normalizeCategories(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
log('CORE ERROR', `Nachrichten-Final-Cleanup-Fehler: ${e.message}. Event-Zähler (im Log-Objekt): ${JSON.stringify(eventCounts)}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
|
||||||
|
try { var b=ed.Panels.getButton('views','open-blocks'); b && b.set('active',true); } catch {}
|
||||||
|
badgeSay('Inhalt geladen','ok');
|
||||||
|
setTimeout(function(){ badgeSay('bereit'); }, 1200);
|
||||||
|
}
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
try { B.send && B.send('core-ready', { mode: MODE }); } catch {}
|
||||||
|
try { var bd=document.getElementById('badge'); if (bd) bd.remove(); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.onerror = function(message, source, lineno, colno, error) {
|
||||||
|
// Diese kritische Funktion MUSS console.error verwenden.
|
||||||
|
console.error(`%c[${PluginName} - GLOBAL ERROR] Uncaught JS Error: ${message} (Quelle: ${source}:${lineno})`, 'color:red; font-weight:bold;');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
624
public/editor/bridge-core.js
Normal file
624
public/editor/bridge-core.js
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
/* /editor/bridge-core.js — Loader + Orchestrator (FINAL & LOG-KONTROLLIERT) */
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
// --- Initialisierung BridgeParts (B) und Plugin-Registry ---
|
||||||
|
if (!window.BridgeParts) window.BridgeParts = {};
|
||||||
|
const B = window.BridgeParts;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 🎯 LOKALE LOG-KONFIGURATION & WRAPPER
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const PluginName = 'bridge-core';
|
||||||
|
|
||||||
|
// Setzen Sie dies auf 'false', um alle Logs NUR für dieses Plugin zu deaktivieren.
|
||||||
|
if (B.LOG_CONFIG) {
|
||||||
|
B.LOG_CONFIG.PLUGINS[PluginName] = true; // bridge-core spezifisch deaktivieren (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NEUER LOKALER WRAPPER, der die zentrale B.log Funktion verwendet.
|
||||||
|
*/
|
||||||
|
const log = (type, message, color = '#1E90FF', logType = 'info', force = false) => {
|
||||||
|
// Loggt NUR, wenn B.log verfügbar ist (aus general-functions.js).
|
||||||
|
if (typeof B.log === 'function') {
|
||||||
|
B.log(PluginName, `[${type}] ${message}`, color, logType, force);
|
||||||
|
}
|
||||||
|
// Ansonsten wird NICHTS geloggt, bis general-functions.js geladen ist.
|
||||||
|
};
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 🛑 GLOBALER LOG ZUR BESTÄTIGUNG DER SKRIPT-AUSFÜHRUNG
|
||||||
|
// log('START', `SKRIPT-AUSFÜHRUNG GESTARTET.`, '#DC143C', 'info', true); // DEAKTIVIERT/IGNORIERT DURCH FEHLENDEN B.log
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// 🛑 KONFIGURATION: NEWSLETTER-PRESET-TOGGLE
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const LOAD_NEWSLETTER_PRESET = false; // <<< KRITISCHER FIX: Auf FALSE gesetzt, um den "defaults" Konflikt zu beheben!
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (window.__bridgeCoreInitialized) {
|
||||||
|
log('INIT ABORT', 'Bridge Core wurde bereits initialisiert.', 'orange');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.__bridgeCoreInitialized = true;
|
||||||
|
|
||||||
|
// --- Initialisierung BridgeParts (B) und Plugin-Registry ---
|
||||||
|
B.BASE_PATH_BRIDGE = '../assets/js/bridge/';
|
||||||
|
B.BASE_PATH_CONFIG = B.BASE_PATH_BRIDGE;
|
||||||
|
// NEU: Standard-API-Endpunkt für Fallbacks, falls category-config.js ihn nicht setzt.
|
||||||
|
// **KORREKTUR**: Auf '/api/editor' FIX eingestellt.
|
||||||
|
B.API_BASE = '/api/editor'; // <<< FIX AUF /api/editor
|
||||||
|
B.STORAGE_URL_BASE = '/api/editor'; // <<< FIX: Erzwingt, dass auch der Storage Manager diesen Pfad verwendet
|
||||||
|
|
||||||
|
B.GrapesJSPlugins = [];
|
||||||
|
|
||||||
|
B.registerGrapesJSPlugin = (name, pluginFn) => {
|
||||||
|
B.GrapesJSPlugins.push({ name, pluginFn });
|
||||||
|
log('PLUGIN REGISTER', `Plugin zur Registry hinzugefügt: ${name}`, 'yellow');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEBUG-HELPER UND LOADER-HELPER ---
|
||||||
|
const badgeSay = (text, type = 'info') => {
|
||||||
|
const b=document.getElementById('badge');
|
||||||
|
if (!b) return;
|
||||||
|
b.textContent = text;
|
||||||
|
switch(type) {
|
||||||
|
case 'ok': b.style.background = '#dcfce7'; b.style.color = '#15803d'; b.style.borderColor = '#bbf7d0'; break;
|
||||||
|
case 'error': b.style.background = '#fee2e2'; b.style.color = '#7f1d1d'; b.style.borderColor = '#fecaca'; break;
|
||||||
|
default: b.style.background = '#eef2ff'; b.style.color = '#1e3a8a'; b.style.borderColor = '#c7d2fe';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadScript(url, done) {
|
||||||
|
const filename = url.split('/').pop();
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = url + (url.indexOf('?') === -1 ? '?v=' : '&v=') + Date.now();
|
||||||
|
s.async = false;
|
||||||
|
|
||||||
|
s.onload = function(){
|
||||||
|
log('LOAD SUCCESS', `Skript geladen: ${filename}`, 'green');
|
||||||
|
try {
|
||||||
|
done && done();
|
||||||
|
} catch(e){
|
||||||
|
if (e.message.includes('setting getter-only property "defaults"')) {
|
||||||
|
log('RUNTIME WARNING', `IGNORIERE Block-Konflikt in ${filename}: ${e.message}`, 'orange', 'warn');
|
||||||
|
} else {
|
||||||
|
// 🛑 KORREKTUR: force: true explizit auf false setzen (oder weglassen)
|
||||||
|
log('RUNTIME ERROR', `Fehler in Callback nach ${filename}: ${e.message}`, 'red', 'error', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
s.onerror = function(){
|
||||||
|
// 🛑 KORREKTUR: force: true explizit auf false setzen (oder weglassen)
|
||||||
|
log('LOAD FAILED', `Skript FEHLT oder Pfad falsch: ${filename}`, 'red', 'error', false);
|
||||||
|
badgeSay(`Ladefehler: ${filename}`, 'error');
|
||||||
|
try { done && done(); } catch(e){}
|
||||||
|
};
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HILFSFUNKTION: Wandelt den Dateinamen (z.B. blocks-standard.js) in den globalen
|
||||||
|
* Objektnamen (z.B. BridgeBlocksStandard) um.
|
||||||
|
*/
|
||||||
|
function getPluginObjectName(fileName) {
|
||||||
|
// 1. Entferne Dateiendung (.js)
|
||||||
|
let name = fileName.replace('.js', ''); // 'blocks-standard'
|
||||||
|
|
||||||
|
// 2. Teile in Bestandteile zerlegen und den ersten Buchstaben groß schreiben
|
||||||
|
const parts = name.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)); // ['Blocks', 'Standard']
|
||||||
|
|
||||||
|
// 3. Mit 'Bridge' prefixen und zusammenfügen
|
||||||
|
return 'Bridge' + parts.join(''); // 'BridgeBlocksStandard'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 NEUE FUNKTION: Erstellt alle in der Konfiguration definierten Kategorien.
|
||||||
|
function ensureConfiguredCategories(editor) {
|
||||||
|
const bm = editor.BlockManager;
|
||||||
|
// HINWEIS: B.CATEGORY_CONFIG wird in category-config.js befüllt (muss vorher geladen werden)
|
||||||
|
const config = window.BridgeParts?.CATEGORY_CONFIG || {};
|
||||||
|
|
||||||
|
Object.keys(config)
|
||||||
|
.sort((a, b) => (config[a].ord || 999) - (config[b].ord || 999))
|
||||||
|
.forEach(catId => {
|
||||||
|
const catConf = config[catId];
|
||||||
|
// Category wird nur erstellt, wenn sie noch nicht existiert
|
||||||
|
if (!bm.getCategories().get(catId)) {
|
||||||
|
bm.getCategories().add({
|
||||||
|
id: catId,
|
||||||
|
label: catConf.label,
|
||||||
|
open: catConf.open !== false,
|
||||||
|
order: catConf.ord || 999
|
||||||
|
});
|
||||||
|
log('CAT INIT', `Kategorie '${catId}' explizit erstellt.`, 'green');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBridgeParts(cb){
|
||||||
|
const base = B.BASE_PATH_BRIDGE;
|
||||||
|
|
||||||
|
// 🛑 LOKALES LOGGING ENTFERNT, DA ES VOR B.log LIEGT.
|
||||||
|
// log('LOAD START', 'Starte Laden der modularen Bridge-Teile (Geordnet).');
|
||||||
|
|
||||||
|
const coreFiles = [
|
||||||
|
base + 'general-functions.js', // <<< RE-AKTIVIERT: Für B.log
|
||||||
|
base + 'category-config.js', // <<< RE-AKTIVIERT: Für B.CATEGORY_CONFIG (und damit API-Flexibilität)
|
||||||
|
base + 'library-parts.js',
|
||||||
|
base + 'categorization-master.js',
|
||||||
|
base + 'categorization-cleanup.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialLoadList = [...coreFiles];
|
||||||
|
|
||||||
|
function recursiveLoader(list, index = 0) {
|
||||||
|
if (index >= list.length) {
|
||||||
|
log('LOAD END', 'Initial-Bridge-Skripte geladen.', 'green');
|
||||||
|
|
||||||
|
const config = window.BridgeParts?.CATEGORY_CONFIG || {};
|
||||||
|
let allBlockFiles = [];
|
||||||
|
|
||||||
|
// Dynamisches Sammeln der Block-Dateien aus der Config
|
||||||
|
Object.keys(config)
|
||||||
|
.sort((a, b) => (config[a].ord || 999) - (config[b].ord || 999))
|
||||||
|
.forEach(key => {
|
||||||
|
// Sammelt alle Dateien, egal ob sync oder async
|
||||||
|
if (Array.isArray(config[key].files)) {
|
||||||
|
allBlockFiles.push(...config[key].files.map(file => base + file));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Duplikate entfernen (falls eine Datei in mehreren Kategorien gelistet ist)
|
||||||
|
allBlockFiles = Array.from(new Set(allBlockFiles));
|
||||||
|
|
||||||
|
function loadBlockFiles(blockIndex = 0) {
|
||||||
|
if (blockIndex >= allBlockFiles.length) {
|
||||||
|
log('LOAD END', 'Alle Blöcke geladen.', 'green');
|
||||||
|
return cb && cb(B);
|
||||||
|
}
|
||||||
|
log('LOADING BLOCKS', `Lade Block-Skript [${blockIndex + 1}/${allBlockFiles.length}]: ${allBlockFiles[blockIndex].split('/').pop()}`);
|
||||||
|
loadScript(allBlockFiles[blockIndex], function(){
|
||||||
|
loadBlockFiles(blockIndex + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadBlockFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 LOKALES LOGGING ENTFERNT, DA ES VOR B.log LIEGT.
|
||||||
|
log('LOADING CORE', `Lade Skript [${index + 1}/${initialLoadList.length}]: ${list[index].split('/').pop()}`); // Loggt ab dem 3. Skript, da general-functions.js an 2. Stelle geladen wird.
|
||||||
|
|
||||||
|
loadScript(list[index], function(){
|
||||||
|
recursiveLoader(list, index + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recursiveLoader(initialLoadList);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { parent.postMessage({ source:'bridge', type:'boot' }, '*'); } catch {}
|
||||||
|
var MODE = (window.__editorMode || 'templates').toLowerCase();
|
||||||
|
|
||||||
|
const replaceReferenceLibrary = (editor, ref, mode) => {
|
||||||
|
(window.BridgeParts?.addReferenceLibrary || (()=>{}))(editor, ref, mode);
|
||||||
|
};
|
||||||
|
const upsertCustomForBothCats = (editor, payload) => {
|
||||||
|
(window.BridgeParts?.upsertCustomForBothCats || (()=>{}))(editor, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Init & Events (Plugin integriert) ---------------------------------------------
|
||||||
|
loadBridgeParts(function(B){
|
||||||
|
log('INIT START', 'Alle Bridge-Teile geladen, starte GrapesJS-Initialisierung.', 'orange');
|
||||||
|
|
||||||
|
if (typeof grapesjs === 'undefined' || !grapesjs.init) {
|
||||||
|
// 🛑 KORREKTUR: force: true explizit auf false setzen (oder weglassen)
|
||||||
|
log('CRITICAL ERROR', 'Das globale Objekt grapesjs ist nicht verfügbar! Laden von grapes.min.js ist fehlgeschlagen.', 'red', 'error', false);
|
||||||
|
badgeSay('Fehler: GrapesJS nicht geladen!', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 KRITISCHER FIX TEIL 1: Registriere alle gesammelten Bridge-Plugins global.
|
||||||
|
if (typeof grapesjs.plugins.add === 'function') {
|
||||||
|
B.GrapesJSPlugins.forEach(p => {
|
||||||
|
grapesjs.plugins.add(p.name, p.pluginFn);
|
||||||
|
log('PLUGIN ACTIVATION', `GrapesJS Plugin global bereitgestellt: ${p.name}`, 'lime');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 🛑 KORREKTUR: force: true explizit auf false setzen (oder weglassen)
|
||||||
|
log('PLUGIN ERROR', `GrapesJS Plugin-API (grapesjs.plugins.add) fehlt. Plugins können nicht registriert werden.`, 'red', 'error', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🛑 KRITISCHER FIX: Safety Plugin MUSS die fehlenden Views Panels hinzufügen.
|
||||||
|
function safetyPlugin(editor){
|
||||||
|
const pn = editor.Panels, orig = pn.getButton.bind(pn);
|
||||||
|
pn.getButton = (pid, id) => orig(pid, id) || { set(){}, get(){ return null; } };
|
||||||
|
|
||||||
|
// Fügen Sie das Panel 'views' hinzu, wenn es fehlt
|
||||||
|
if(!pn.getPanel('views')) {
|
||||||
|
pn.addPanel({ id: 'views', el: '.gjs-pn-views' });
|
||||||
|
log('PANEL FIX', "Das 'views' Panel wurde nachträglich hinzugefügt.", 'yellow', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stellen Sie sicher, dass der Block Manager in den Views-Container rendert
|
||||||
|
editor.Config.blockManager = editor.Config.blockManager || {};
|
||||||
|
editor.Config.blockManager.appendTo = editor.Config.blockManager.appendTo || '.gjs-blocks';
|
||||||
|
|
||||||
|
// Der fehlerhafte Timeout-Block wurde entfernt.
|
||||||
|
}
|
||||||
|
|
||||||
|
let pluginsList = [
|
||||||
|
safetyPlugin,
|
||||||
|
// 🛑 KRITISCHE ERGÄNZUNG: Aktiviert das registrierte API-Plugin
|
||||||
|
'bridge-blocks-api',
|
||||||
|
'bridge-categorization-master',
|
||||||
|
'bridge-categorization-cleanup',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (LOAD_NEWSLETTER_PRESET) {
|
||||||
|
pluginsList.push('gjs-preset-newsletter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speicherkonfiguration extrahieren, um die URL in onLoad zu verwenden.
|
||||||
|
// 🎯 KORREKTUR für mehr Flexibilität: Verwende B.STORAGE_URL_BASE, falls gesetzt, anstatt window.location.href.
|
||||||
|
// Verwenden Sie B.API_BASE (Standard /api/editor) als Fallback für die Storage-URL
|
||||||
|
const storageBase = B.STORAGE_URL_BASE || B.API_BASE; // B.API_BASE sollte jetzt korrekt sein
|
||||||
|
|
||||||
|
// Robustes Anhängen von Query-Parametern.
|
||||||
|
// Prüft, ob 'storageBase' bereits Query-Parameter enthält ('?')
|
||||||
|
const actionSeparator = storageBase.indexOf('?') === -1 ? '?' : '&';
|
||||||
|
|
||||||
|
const loadUrl = storageBase + actionSeparator + 'action=get&resource=' + (window.__editorMode || 'templates') + '&id=' + (window.__editorId || 0); // KRITISCHE ERGÄNZUNG: Resource und ID
|
||||||
|
const storeUrl = storageBase + actionSeparator + 'action=update&resource=' + (window.__editorMode || 'templates') + '&id=' + (window.__editorId || 0); // KRITISCHE ERGÄNZUNG: Resource und ID
|
||||||
|
|
||||||
|
const storageConf = {
|
||||||
|
type: 'remote',
|
||||||
|
// urlLoad: loadUrl, // ENTFERNT (korrekt, da customFetch verwendet wird)
|
||||||
|
urlStore: storeUrl,
|
||||||
|
|
||||||
|
// 🛑 KRITISCHER ABSCHNITT: customFetch MUSS DIE ERWARTETE SIGNATUR HABEN: customFetch(url, options)
|
||||||
|
customFetch: async (url, options) => { // <<< KORREKTUR DER SIGNATUR
|
||||||
|
// 1. Log Start
|
||||||
|
log('STORAGE START', 'Template wird geladen.', '#008080', 'info', true);
|
||||||
|
// 2. Log Link
|
||||||
|
log('API REQUEST', `Link für den API Request: ${loadUrl}`, '#4682B4', 'log', false);
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// Wichtig: Die übergebenen Optionen nicht vergessen zu mergen
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = {};
|
||||||
|
let rawResponse = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verwendung der intern definierten loadUrl
|
||||||
|
const response = await fetch(loadUrl, fetchOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP-Fehler ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holen des Raw Texts, um ihn loggen und parsen zu können
|
||||||
|
rawResponse = await response.text();
|
||||||
|
|
||||||
|
// 3. Log Result
|
||||||
|
log('API RESPONSE', 'Result vom API Request (Raw Text/JSON):', '#4682B4', 'log', false);
|
||||||
|
console.log(rawResponse); // Loggt den reinen String für die Analyse
|
||||||
|
|
||||||
|
// Versuch der JSON-Analyse (um den GrapesJS-Fehler zu vermeiden)
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawResponse);
|
||||||
|
log('STORAGE PARSE', 'Raw Response als JSON geparst.', 'green');
|
||||||
|
} catch (e) {
|
||||||
|
log('STORAGE PARSE ERROR', `Fehler beim Parsen der Antwort: ${e.message}. Antwort war wahrscheinlich kein gültiges JSON.`, 'red', 'error', true);
|
||||||
|
// Im Falle eines Parsing-Fehlers, leeres Objekt für Fallback-Logik
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log('STORAGE FETCH ERROR', `Fehler beim Abruf: ${e.message}`, 'red', 'error', true);
|
||||||
|
// Sicherstellen, dass die Promise mit einem leeren Zustand erfüllt wird
|
||||||
|
// Wir müssen dennoch den Log End ausführen, bevor wir zurückkehren
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Log End
|
||||||
|
log('STORAGE END', 'Template wurde geladen.', '#008080', 'info', true);
|
||||||
|
|
||||||
|
// --- Logik zur Extraktion des GrapesJS States aus der API-Antwort ---
|
||||||
|
let state = {};
|
||||||
|
|
||||||
|
if (data && data.gjs_data) {
|
||||||
|
log('STORAGE LOAD', 'Voller GrapesJS State aus "gjs_data" geladen.', 'green');
|
||||||
|
state = data.gjs_data;
|
||||||
|
}
|
||||||
|
else if (data && data.content) {
|
||||||
|
try {
|
||||||
|
const parsedState = JSON.parse(data.content);
|
||||||
|
log('STORAGE LOAD', 'Voller GrapesJS State aus "content" (JSON-String) geladen.', 'yellow');
|
||||||
|
state = parsedState;
|
||||||
|
} catch (e) {
|
||||||
|
log('STORAGE ERROR', `Fehler beim Parsen von "content": ${e.message}.`, 'red', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HINWEIS: Füge Fallback für "topContent" hinzu, basierend auf dem Server-Log
|
||||||
|
else if (data && data.topContent) {
|
||||||
|
try {
|
||||||
|
const parsedState = JSON.parse(data.topContent);
|
||||||
|
log('STORAGE LOAD', 'Voller GrapesJS State aus "topContent" (JSON-String) geladen.', 'green');
|
||||||
|
state = parsedState;
|
||||||
|
} catch (e) {
|
||||||
|
log('STORAGE ERROR', `Fehler beim Parsen von "topContent": ${e.message}.`, 'red', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log('STORAGE WARNING', 'Kein vollständiger GrapesJS State gefunden. Editor lädt leeren State.', 'orange', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
// customFetch MUSS den geladenen State zurückgeben
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
// --- ENDE customFetch ---
|
||||||
|
|
||||||
|
// onLoad ist bei customFetch nicht mehr nötig
|
||||||
|
// onLoad: (response) => { ... },
|
||||||
|
|
||||||
|
// KRITISCH: Speichert den vollen State als JSON-String im Feld 'json_content'.
|
||||||
|
onStore: (data) => {
|
||||||
|
// ACHTUNG: ed existiert hier nicht, muss über window.__gjs geladen werden ODER ed als Argument akzeptiert werden
|
||||||
|
const ed = window.__gjs;
|
||||||
|
return {
|
||||||
|
json_content: JSON.stringify(data),
|
||||||
|
html: ed ? ed.getHtml() : '' // Fügen Sie den HTML-Output zur Abwärtskompatibilität hinzu
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ed = grapesjs.init({
|
||||||
|
container: '#gjs',
|
||||||
|
height: '100vh',
|
||||||
|
|
||||||
|
// 🛑 KRITISCHE KORREKTUR: storageManager aktivieren und konfigurieren
|
||||||
|
storageManager: storageConf,
|
||||||
|
|
||||||
|
plugins: pluginsList,
|
||||||
|
pluginsOpts: {},
|
||||||
|
// 🛑 KRITISCHE ERGÄNZUNG: Verhindert das automatische Ausblenden leerer Kategorien
|
||||||
|
blockManager: {
|
||||||
|
hideEmpty: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.__gjs = ed;
|
||||||
|
|
||||||
|
// 🛑 KRITISCHE KORREKTUR 1: Explizite Erstellung aller konfigurierten Kategorien
|
||||||
|
ensureConfiguredCategories(ed);
|
||||||
|
|
||||||
|
// 🛑 KRITISCHE KORREKTUR 2: Sofortige Label-Korrektur
|
||||||
|
// Überschreibt den potenziell falschen, durch GrapesJS gesetzten Label-Namen
|
||||||
|
Object.keys(B.CATEGORY_CONFIG || {}).forEach(catId => {
|
||||||
|
const expectedLabel = B.CATEGORY_CONFIG[catId].label;
|
||||||
|
const categoryModel = ed.BlockManager.getCategories().get(catId);
|
||||||
|
|
||||||
|
if (categoryModel && categoryModel.get('label') !== expectedLabel) {
|
||||||
|
// Setzen ohne das 'change:label' Event auszulösen (optional, aber sauber)
|
||||||
|
categoryModel.set('label', expectedLabel, { silent: true });
|
||||||
|
log('LABEL FIX', `Kategorie '${catId}' Label auf korrigiert: '${expectedLabel}'`, 'yellow', 'warn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ---------------------------------------------------
|
||||||
|
|
||||||
|
B.ensureViews && B.ensureViews(ed);
|
||||||
|
|
||||||
|
log('BLOCK REGISTER', 'Registriere Bridge Blöcke, um Preset-Defaults zu überschreiben.', 'purple');
|
||||||
|
|
||||||
|
// 🛑 DYNAMISCHE AKTIVIERUNG DER SYNCHRONEN BLÖCKE (Ersetzt die fixen Aufrufe)
|
||||||
|
if (B.CATEGORY_CONFIG && ed) {
|
||||||
|
log('DYNAMIC ACTIVATION', 'Starte Aktivierung synchroner Block-Plugins (via Config).', 'purple');
|
||||||
|
|
||||||
|
// Iteriere über die konfigurierten Kategorien
|
||||||
|
Object.keys(B.CATEGORY_CONFIG).forEach(catId => {
|
||||||
|
const config = B.CATEGORY_CONFIG[catId];
|
||||||
|
|
||||||
|
// Verarbeite nur SYNCHRONE Plugins, die Dateien angeben
|
||||||
|
if (config.registration_mode === 'sync' && Array.isArray(config.files)) {
|
||||||
|
|
||||||
|
config.files.forEach(fileName => {
|
||||||
|
|
||||||
|
// Korrigierte Funktion liefert jetzt z.B. 'BridgeBlocksCustom'
|
||||||
|
const objectName = getPluginObjectName(fileName);
|
||||||
|
const plugin = window[objectName];
|
||||||
|
|
||||||
|
// Prüfen, ob das Skript geladen wurde und die Register-Funktion vorhanden ist
|
||||||
|
if (plugin && typeof plugin.register === 'function') {
|
||||||
|
log('DYNAMIC ACTIVATION', `Registriere sync Plugin: ${objectName} (${fileName})`, 'lime');
|
||||||
|
try {
|
||||||
|
plugin.register(ed);
|
||||||
|
} catch(e) {
|
||||||
|
log('DYNAMIC ACTIVATION ERROR', `Fehler beim Registrieren von ${objectName}: ${e.message}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('DYNAMIC ACTIVATION WARNING', `Sync Plugin Objekt oder .register() Methode nicht gefunden: ${objectName} (${fileName})`, 'orange', 'warn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------
|
||||||
|
|
||||||
|
log('INIT API', 'API-Elemente werden nun durch das Plugin bridge-blocks-api geladen. (ASYNCHRON)', 'orange');
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// DEBUGGING: ZÄHLE REKURSIVE EVENTS
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
let eventCounts = {};
|
||||||
|
let isParsing = false;
|
||||||
|
const MAX_CALLS = 1000;
|
||||||
|
|
||||||
|
const debugEvents = [
|
||||||
|
'component:add',
|
||||||
|
'component:update',
|
||||||
|
'change:components',
|
||||||
|
'block:add',
|
||||||
|
'change:attributes',
|
||||||
|
'comp:update:status'
|
||||||
|
];
|
||||||
|
|
||||||
|
const debugListener = (event, model) => {
|
||||||
|
if (!isParsing) return;
|
||||||
|
|
||||||
|
if (!eventCounts[event]) {
|
||||||
|
eventCounts[event] = 0;
|
||||||
|
}
|
||||||
|
eventCounts[event]++;
|
||||||
|
|
||||||
|
if (eventCounts[event] === MAX_CALLS + 1) {
|
||||||
|
// Diese kritischen Debug-Meldungen bleiben DIREKT im console-Objekt,
|
||||||
|
// da sie immer sichtbar sein müssen, um Endlosschleifen zu erkennen.
|
||||||
|
console.error(`%c[DEBUG RECURSION ALARM] 🚨 Event '${event}' hat den Grenzwert von ${MAX_CALLS} überschritten!`, 'color:red; font-size: 1.1em; font-weight: bold;');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventCounts[event] > MAX_CALLS && eventCounts[event] < (MAX_CALLS + 10)) {
|
||||||
|
const type = (model && typeof model.get === 'function') ? model.get('type') : 'N/A';
|
||||||
|
const parentType = (model && typeof model.parent === 'function' && model.parent()) ? model.parent().get('type') : 'N/A';
|
||||||
|
// Diese bleiben console.log aus demselben Grund
|
||||||
|
console.log(`%c [RECURSION SOURCE] Event: ${event}, Type: ${type}, Parent: ${parentType}`, 'color: #8b0000;');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ed.on('load', function() {
|
||||||
|
debugEvents.forEach(event => ed.on(event, (model) => debugListener(event, model)));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
(B.waitForBlocks ? B.waitForBlocks(ed) : Promise.resolve()).then(function(){
|
||||||
|
try {
|
||||||
|
log('CORE WARN', 'Führe finalen, verzögerten Cleanup-Lauf durch (2000ms).', 'orange', 'warn');
|
||||||
|
|
||||||
|
B.normalizeCategories && B.normalizeCategories(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
log('CORE ERROR', `Finaler Cleanup-Fehler: ${e.message}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MESSAGE HANDLER
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
window.addEventListener('message', async function(ev){
|
||||||
|
var data = ev.data || {};
|
||||||
|
if (data.source !== 'admin') return;
|
||||||
|
|
||||||
|
if (data.type === 'init'){
|
||||||
|
B.ensureViews && B.ensureViews(ed);
|
||||||
|
|
||||||
|
var html = (data.html || '').trim();
|
||||||
|
if (!html) html = '<table style="width:100%;font-family:Arial,sans-serif"><tr><td><h1>Neues Dokument</h1><p>Inhalt ...</p></td></tr></table>';
|
||||||
|
|
||||||
|
const applySnips = function(arr){
|
||||||
|
const list = (Array.isArray(arr)?arr:[]).map(s => ({ id:s.id, name:s.name, html: s.html || s.content || '' }));
|
||||||
|
|
||||||
|
B.replaceSnippetBlocks && B.replaceSnippetBlocks(ed, list);
|
||||||
|
|
||||||
|
upsertCustomForBothCats(ed, {
|
||||||
|
ref: (data.ref && (Array.isArray(data.ref.sections) || Array.isArray(data.ref.blocks))) ? {
|
||||||
|
sections: data.ref.sections || [],
|
||||||
|
blocks: data.ref.blocks || []
|
||||||
|
} : { sections: [], blocks: [] },
|
||||||
|
snippets: list
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
// Erneutes Normalisieren nach Laden der Snippets (falls nötig)
|
||||||
|
B.normalizeCategories && B.normalizeCategories(ed);
|
||||||
|
B.ensureViews && B.ensureViews(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
log('CORE WARN', 'normalize/render nach applySnips ausgeführt (1ms).', 'orange', 'warn');
|
||||||
|
} catch(e) {
|
||||||
|
log('CORE ERROR', `applySnips-Cleanup-Fehler: ${e.message}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(data.snippets) && data.snippets.length) applySnips(data.snippets);
|
||||||
|
else (B.fetchSnippets ? B.fetchSnippets() : Promise.resolve([])).then(applySnips);
|
||||||
|
|
||||||
|
if (data.ref && (Array.isArray(data.ref.sections) || Array.isArray(data.ref.blocks))) {
|
||||||
|
replaceReferenceLibrary(ed, {
|
||||||
|
sections: data.ref.sections || [],
|
||||||
|
blocks: data.ref.blocks || []
|
||||||
|
}, MODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finaler Aufruf nachrichtengesteuert (konsolidiert)
|
||||||
|
setTimeout(() => {
|
||||||
|
(B.waitForBlocks ? B.waitForBlocks(ed) : Promise.resolve()).then(function(){
|
||||||
|
try {
|
||||||
|
log('CORE WARN', 'Führe nachrichtengesteuerten Final-Cleanup-Lauf durch (100ms).', 'orange', 'warn');
|
||||||
|
|
||||||
|
// 🛑 KRITISCHE KORREKTUR: Entferne das erzwungene ed.setComponents(html)
|
||||||
|
// Das Laden des Inhalts wird jetzt vom storageManager übernommen (via customFetch).
|
||||||
|
if (!ed.__contentLoaded) {
|
||||||
|
log('CONTENT', 'Erster Ladevorgang (storageManager) ist abgeschlossen.', 'orange');
|
||||||
|
|
||||||
|
// HINWEIS: Wenn der Editor initial leer lädt (z.B. neue Vorlage),
|
||||||
|
// MUSS hier der initiale HTML-Code eingefügt werden.
|
||||||
|
// Da der storageManager aber automatisch lädt,
|
||||||
|
// sollte dieser Block nur für den Initialfall "Neu" greifen.
|
||||||
|
if (html && !ed.getComponents().length) {
|
||||||
|
window.__GJS_IS_PARSING = true;
|
||||||
|
isParsing = true;
|
||||||
|
eventCounts = {};
|
||||||
|
try {
|
||||||
|
ed.setComponents(html);
|
||||||
|
} catch (e) {
|
||||||
|
log('SET COMPONENTS FAILED', `setComponents Fehler: ${e.message}. Aufgerufene Event-Zähler: ${JSON.stringify(eventCounts)}`, 'red', 'error');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
window.__GJS_IS_PARSING = false;
|
||||||
|
isParsing = false;
|
||||||
|
log('CONTENT', 'HTML-Inhalt in den Editor geladen (FALLBACK).', 'orange');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ed.__contentLoaded = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalisierung am Ende
|
||||||
|
B.normalizeCategories && B.normalizeCategories(ed);
|
||||||
|
B.renderBlocks && B.renderBlocks(ed);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
log('CORE ERROR', `Nachrichten-Final-Cleanup-Fehler: ${e.message}. Event-Zähler (im Log-Objekt): ${JSON.stringify(eventCounts)}`, 'red', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
|
||||||
|
try { var b=ed.Panels.getButton('views','open-blocks'); b && b.set('active',true); } catch {}
|
||||||
|
badgeSay('Inhalt geladen','ok');
|
||||||
|
setTimeout(function(){ badgeSay('bereit'); }, 1200);
|
||||||
|
}
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
try { B.send && B.send('core-ready', { mode: MODE }); } catch {}
|
||||||
|
try { var bd=document.getElementById('badge'); if (bd) bd.remove(); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.onerror = function(message, source, lineno, colno, error) {
|
||||||
|
// Diese kritische Funktion MUSS console.error verwenden.
|
||||||
|
console.error(`%c[${PluginName} - GLOBAL ERROR] Uncaught JS Error: ${message} (Quelle: ${source}:${lineno})`, 'color:red; font-weight:bold;');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
76
public/editor/config.js
Normal file
76
public/editor/config.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/* /editor/config.js (SCHRITT 35: LOG-EBENEN-KONTROLLE) */
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
// Stelle sicher, dass BridgeParts existiert und hole die registrierten Plugins.
|
||||||
|
const B = window.BridgeParts || {};
|
||||||
|
|
||||||
|
// --- 🎯 ZENTRALE LOG-KONFIGURATION (Überschreibt general-functions.js Defaults) ---
|
||||||
|
// HINWEIS: Dies muss NACH general-functions.js geladen werden.
|
||||||
|
B.LOG_CONFIG = B.LOG_CONFIG || {};
|
||||||
|
|
||||||
|
// 1. HAUPTSCHALTER: Deaktiviert alle normalen Logs (muss auf 'true' sein, damit die Ebenen-Schalter wirken)
|
||||||
|
B.LOG_CONFIG.GLOBAL_DEBUG = true;
|
||||||
|
|
||||||
|
// 2. EBENEN-SCHALTER (wirken nur, wenn GLOBAL_DEBUG = true):
|
||||||
|
B.LOG_CONFIG.INFO_ENABLED = true; // Aktiviert/Deaktiviert alle Info-Logs (B.log mit type='info')
|
||||||
|
B.LOG_CONFIG.WARN_ENABLED = true; // Aktiviert/Deaktiviert alle Warn-Logs (B.log mit type='warn')
|
||||||
|
B.LOG_CONFIG.ERROR_ENABLED = true; // Aktiviert/Deaktiviert alle Error-Logs (B.log mit type='error')
|
||||||
|
|
||||||
|
// 3. DATEN-SCHALTER: Aktiviert/Deaktiviert die Ausgabe großer Array-Daten (B.logData)
|
||||||
|
B.LOG_CONFIG.DATA_ENABLED = true;
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
// Sammle alle dynamisch registrierten Plugin-Namen.
|
||||||
|
// Der Array B.GrapesJSPlugins wurde von bridge-core.js, blocks-api.js etc. gefüllt.
|
||||||
|
const dynamicPluginNames = B.GrapesJSPlugins
|
||||||
|
? B.GrapesJSPlugins.map(p => p.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Optional: Fügen Sie statische GrapesJS-Plugins hinzu
|
||||||
|
const staticPlugins = [
|
||||||
|
'gjs-preset-newsletter' // Beispiel: Fügt den Newsletter-Preset hinzu, falls gewünscht
|
||||||
|
];
|
||||||
|
|
||||||
|
// Kombiniere alle Plugin-Namen zu einer eindeutigen Liste
|
||||||
|
const uniquePlugins = [...new Set([
|
||||||
|
...dynamicPluginNames,
|
||||||
|
...staticPlugins
|
||||||
|
])];
|
||||||
|
|
||||||
|
|
||||||
|
// Definiere die Haupt-Konfiguration für GrapesJS
|
||||||
|
const editorConfig = {
|
||||||
|
// 1. WICHTIG: Ersetze 'gjs' durch die ID des Containers
|
||||||
|
container: '#gjs',
|
||||||
|
|
||||||
|
// 2. KRITISCH: Die dynamisch erstellte Plugin-Liste
|
||||||
|
plugins: uniquePlugins,
|
||||||
|
|
||||||
|
// 3. Plugin-Optionen (können leer bleiben, wenn keine Optionen benötigt werden)
|
||||||
|
pluginsOpts: {
|
||||||
|
// Hier Optionen für einzelne Plugins eintragen
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Andere Basis-Konfigurationen ---
|
||||||
|
panels: {
|
||||||
|
defaults: [
|
||||||
|
{ id: 'options', el: '.panel__options', buttons: [{ id: 'save', label: 'Speichern', className: 'fa fa-floppy-o' }] },
|
||||||
|
{ id: 'views', el: '.panel__views' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ... Fügen Sie hier weitere GrapesJS-Optionen ein (z.B. device buttons)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Starte GrapesJS
|
||||||
|
// window.GrapesJS.init wurde in bridge-core.js definiert, um GrapesJS zu starten.
|
||||||
|
if (window.GrapesJS && window.GrapesJS.init) {
|
||||||
|
// Übergebe editorConfig und die Liste der Plugin-Funktionen
|
||||||
|
window.GrapesJS.init(editorConfig, B.GrapesJSPlugins.map(p => p.name), B.GrapesJSPlugins);
|
||||||
|
} else {
|
||||||
|
console.error('GrapesJS.init ist in window.GrapesJS nicht verfügbar. Wurde bridge-core.js geladen?');
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
64
public/editor/editor-core (Kopie).php
Normal file
64
public/editor/editor-core (Kopie).php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
$mode = strtolower($_GET['mode'] ?? 'templates');
|
||||||
|
$ts = time();
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Editor</title>
|
||||||
|
<link rel="stylesheet" href="../vendor/grapesjs/grapes.min.css" />
|
||||||
|
<style>
|
||||||
|
html,body{height:100%}body{margin:0;background:#f8fafc;color:#0f172a}#gjs{height:100vh}
|
||||||
|
|
||||||
|
.gjs-one-bg{background-color:#fff!important}.gjs-two-color{color:#0f172a!important}
|
||||||
|
.gjs-three-bg{background-color:#f8fafc!important}.gjs-four-color{color:#334155!important}
|
||||||
|
#badge{position:fixed;right:8px;top:8px;background:#eef2ff;color:#1e3a8a;border:1px solid #c7d2fe;border-radius:999px;padding:4px 10px;font:12px system-ui;z-index:2147483647;opacity:.9}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="badge">lädt …</div>
|
||||||
|
<div id="gjs"></div>
|
||||||
|
|
||||||
|
<div id="blocks"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function logToParent(type, detail){ try{ parent.postMessage({source:'editor-core',type:type,detail:String(detail||'')},'*'); }catch(e){} }
|
||||||
|
window.addEventListener('error', function(e){
|
||||||
|
var b=document.getElementById('badge');
|
||||||
|
if(b){ b.textContent='Fehler: '+(e&&e.message?e.message:'unbekannt'); b.style.background='#fee2e2'; b.style.color='#7f1d1d'; b.style.borderColor='#fecaca'; }
|
||||||
|
logToParent('window-error', e && e.message ? e.message : 'unknown');
|
||||||
|
});
|
||||||
|
function loadLocalScript(src, onok){
|
||||||
|
var s=document.createElement('script'); s.src=src; s.async=false;
|
||||||
|
s.onload=function(){ logToParent('script-ok', src); onok&&onok(); };
|
||||||
|
s.onerror=function(){ var b=document.getElementById('badge'); if(b){ b.textContent='Fehlt: '+src; b.style.background='#fee2e2'; b.style.color='#7f1d1d'; b.style.borderColor='#fecaca'; } logToParent('script-missing', src); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
logToParent('boot','start');
|
||||||
|
|
||||||
|
// 1) GrapesJS laden
|
||||||
|
loadLocalScript('../vendor/grapesjs/grapes.min.js?v=<?=$ts?>', function(){
|
||||||
|
if(typeof window.grapesjs==='undefined'){ document.getElementById('badge').textContent='grapesjs nicht verfügbar'; logToParent('gjs-missing','window.grapesjs undefined'); return; }
|
||||||
|
|
||||||
|
// 2) Plugin laden
|
||||||
|
loadLocalScript('../vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js?v=<?=$ts?>', function(){
|
||||||
|
|
||||||
|
// 3) BRIDGE ZUERST (mit Cache-Bust) – meldet sich sofort mit bridge:boot
|
||||||
|
loadLocalScript('bridge-core.js?v=<?=$ts?>', function(){
|
||||||
|
// 4) Danach config.js (Bibliothek)
|
||||||
|
// loadLocalScript('config.js?v=<?=$ts?>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat vom Core (sichtbar im Hauptfenster)
|
||||||
|
var hb=0, timer=setInterval(function(){ hb++; if(hb>60){clearInterval(timer);return;} logToParent('hb','tick '+hb); }, 200);
|
||||||
|
|
||||||
|
// Mode für die Bridge bereitstellen
|
||||||
|
window.__editorMode = "<?=htmlspecialchars($mode,ENT_QUOTES)?>";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
79
public/editor/editor-core.php
Normal file
79
public/editor/editor-core.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
$mode = strtolower($_GET['mode'] ?? 'templates');
|
||||||
|
$ts = time();
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Editor</title>
|
||||||
|
<link rel="stylesheet" href="../vendor/grapesjs/grapes.min.css" />
|
||||||
|
<style>
|
||||||
|
html,body{height:100%}body{margin:0;background:#f8fafc;color:#0f172a}#gjs{height:100vh}
|
||||||
|
|
||||||
|
.gjs-one-bg{background-color:#fff!important}.gjs-two-color{color:#0f172a!important}
|
||||||
|
.gjs-three-bg{background-color:#f8fafc!important}.gjs-four-color{color:#334155!important}
|
||||||
|
#badge{position:fixed;right:8px;top:8px;background:#eef2ff;color:#1e3a8a;border:1px solid #c7d2fe;border-radius:999px;padding:4px 10px;font:12px system-ui;z-index:2147483647;opacity:.9}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="badge">lädt …</div>
|
||||||
|
<div id="gjs"></div>
|
||||||
|
|
||||||
|
<div id="blocks"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function logToParent(type, detail){ try{ parent.postMessage({source:'editor-core',type:type,detail:String(detail||'')},'*'); }catch(e){} }
|
||||||
|
window.addEventListener('error', function(e){
|
||||||
|
var b=document.getElementById('badge');
|
||||||
|
if(b){ b.textContent='Fehler: '+(e&&e.message?e.message:'unbekannt'); b.style.background='#fee2e2'; b.style.color='#7f1d1d'; b.style.borderColor='#fecaca'; }
|
||||||
|
logToParent('window-error', e && e.message ? e.message : 'unknown');
|
||||||
|
});
|
||||||
|
function loadLocalScript(src, onok){
|
||||||
|
// Hinzufügen des Cache-Bust-Parameters zur URL
|
||||||
|
// Die Variable $ts wird durch PHP im HTML-Kontext eingefügt
|
||||||
|
const url = src + (src.indexOf('?') === -1 ? '?v=' : '&v=') + <?=$ts?>;
|
||||||
|
var s=document.createElement('script'); s.src=url; s.async=false;
|
||||||
|
s.onload=function(){ logToParent('script-ok', src); onok&&onok(); };
|
||||||
|
s.onerror=function(){ var b=document.getElementById('badge'); if(b){ b.textContent='Fehlt: '+src; b.style.background='#fee2e2'; b.style.color='#7f1d1d'; b.style.borderColor='#fecaca'; } logToParent('script-missing', src); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
logToParent('boot','start');
|
||||||
|
|
||||||
|
// 1) GrapesJS laden
|
||||||
|
loadLocalScript('../vendor/grapesjs/grapes.min.js', function(){
|
||||||
|
if(typeof window.grapesjs==='undefined'){
|
||||||
|
document.getElementById('badge').textContent='grapesjs nicht verfügbar';
|
||||||
|
logToParent('gjs-missing','window.grapesjs undefined');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.A) KRITISCHE HELPER ZUERST LADEN (Category Config)
|
||||||
|
loadLocalScript('../assets/js/bridge/category-config.js', function() {
|
||||||
|
|
||||||
|
// 2.B) Dann die zentrale Log-Funktion
|
||||||
|
// Diese muss geladen sein, bevor bridge-core.js startet!
|
||||||
|
loadLocalScript('../assets/js/bridge/general-functions.js', function() {
|
||||||
|
|
||||||
|
// 3) Plugin laden (GrapesJS Preset Newsletter)
|
||||||
|
loadLocalScript('../vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js', function(){
|
||||||
|
|
||||||
|
// 4) BRIDGE-CORE laden – jetzt kann es B.LOG_CONFIG auf false setzen!
|
||||||
|
loadLocalScript('bridge-core.js', function(){
|
||||||
|
// 5) Danach config.js (Bibliothek)
|
||||||
|
// loadLocalScript('config.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat vom Core (sichtbar im Hauptfenster)
|
||||||
|
var hb=0, timer=setInterval(function(){ hb++; if(hb>60){clearInterval(timer);return;} logToParent('hb','tick '+hb); }, 200);
|
||||||
|
|
||||||
|
// Mode für die Bridge bereitstellen
|
||||||
|
window.__editorMode = "<?=htmlspecialchars($mode,ENT_QUOTES)?>";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
169
public/index.php
Normal file
169
public/index.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
$base = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/') ?: '';
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Email Template System – Admin</title>
|
||||||
|
|
||||||
|
<!-- UI bis zur Auth verdecken -->
|
||||||
|
<script>document.documentElement.classList.add('auth-pending');</script>
|
||||||
|
<style>
|
||||||
|
html.auth-pending body { visibility: hidden; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Tailwind zuerst -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Admin-Theme (neu) -->
|
||||||
|
<link rel="stylesheet" href="assets/css/admin.css?v=2025-10-061">
|
||||||
|
|
||||||
|
<!-- Toast danach -->
|
||||||
|
<link rel="stylesheet" href="assets/css/toast.css">
|
||||||
|
|
||||||
|
<!-- Kleine Hilfs-Utilities (belassen, falls admin.css andere Werte hat) -->
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:.5rem;padding:.35rem .7rem;border-radius:.7rem;border:1px solid #e5e7eb;background:#fff;font-size:.9rem;cursor:pointer;}
|
||||||
|
.btn:hover{background:#f8fafc}.btn-danger{border-color:#fecaca;color:#b91c1c}.btn-danger:hover{background:#fef2f2}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:.35rem;padding:.15rem .5rem;border-radius:999px;background:#f1f5f9;color:#334155;font-size:.75rem;border:1px solid #e5e7eb}
|
||||||
|
.chip .dot{width:.5rem;height:.5rem;border-radius:999px;background:#64748b}
|
||||||
|
dialog::backdrop{background:rgba(15,23,42,.3)}
|
||||||
|
#toast-root{z-index:2147483647}
|
||||||
|
.truncate{max-width:22rem;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
||||||
|
.hidden{display:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="page-admin bg-slate-50 text-slate-800">
|
||||||
|
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-3">
|
||||||
|
<h1 class="font-semibold text-lg">Email Template System</h1>
|
||||||
|
<nav class="isolate inline-flex rounded-2xl shadow-sm border bg-white overflow-hidden ms-6">
|
||||||
|
<button type="button" data-tab="templates" class="px-4 py-2 text-sm border-e bg-sky-50 text-sky-700">Templates</button>
|
||||||
|
<button type="button" data-tab="sections" class="px-4 py-2 text-sm border-e">Sections</button>
|
||||||
|
<button type="button" data-tab="blocks" class="px-4 py-2 text-sm border-e">Blocks</button>
|
||||||
|
<button type="button" data-tab="snippets" class="px-4 py-2 text-sm">Snippets</button>
|
||||||
|
</nav>
|
||||||
|
<div class="ms-auto flex gap-2">
|
||||||
|
<button id="btn-new" type="button" class="btn">Neu …</button>
|
||||||
|
|
||||||
|
<!-- Tools: werden von ui-tools.js abgefangen und im Popup gezeigt -->
|
||||||
|
<a href="api.php?action=health"
|
||||||
|
class="btn btn-light"
|
||||||
|
data-popup="json"
|
||||||
|
data-title="API Health">API-Health</a>
|
||||||
|
|
||||||
|
<a href="tools/db-doctor.php?profile=template"
|
||||||
|
class="btn btn-light"
|
||||||
|
data-popup="frame"
|
||||||
|
data-title="DB-Doctor">DB-Doctor</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-6xl mx-auto p-4">
|
||||||
|
<section id="view-templates" class="view"></section>
|
||||||
|
<section id="view-sections" class="view hidden"></section>
|
||||||
|
<section id="view-blocks" class="view hidden"></section>
|
||||||
|
<section id="view-snippets" class="view hidden"></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Create Dialog -->
|
||||||
|
<dialog id="createDialog" class="rounded-2xl p-0 w-[540px]">
|
||||||
|
<form id="createForm" method="dialog" class="p-4 bg-white rounded-2xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Neues Element erstellen</h3>
|
||||||
|
<p id="createHint" class="text-sm text-slate-600 mb-3"></p>
|
||||||
|
<div class="space-y-3" id="createFields"></div>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button type="button" id="createCancel" class="btn">Abbrechen</button>
|
||||||
|
<button type="submit" id="createSubmit" class="btn">Erstellen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirm Dialog -->
|
||||||
|
<dialog id="deleteDialog" class="rounded-2xl p-0 w-[520px]">
|
||||||
|
<form id="deleteForm" method="dialog" class="p-4 bg-white rounded-2xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Eintrag löschen?</h3>
|
||||||
|
<p id="deleteText" class="text-sm text-slate-600 mb-4"></p>
|
||||||
|
<div class="mt-1 flex justify-end gap-2">
|
||||||
|
<button type="button" id="deleteCancel" class="btn">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-danger">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Editor Dialog -->
|
||||||
|
<dialog id="editorDialog" class="rounded-2xl p-0 w-[95vw] h-[90vh]">
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="px-4 py-2 border-b flex items-center gap-2 bg-white/80 backdrop-blur">
|
||||||
|
<strong class="me-auto">E-Mail Editor</strong>
|
||||||
|
<button id="btn-clear-main" type="button" class="btn" title="Leeren">🧹</button>
|
||||||
|
<button id="btn-save" type="button" class="btn">Speichern</button>
|
||||||
|
<button id="btn-close" type="button" class="btn">Schließen</button>
|
||||||
|
</div>
|
||||||
|
<iframe id="editorFrame" src="about:blank" class="flex-1 w-full"></iframe>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Preview Dialog -->
|
||||||
|
<dialog id="previewDialog" class="rounded-2xl p-0 w-[90vw] h-[90vh]">
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="px-4 py-2 border-b flex items-center gap-2 bg-white/80 backdrop-blur">
|
||||||
|
<strong class="me-auto">Vorschau</strong>
|
||||||
|
<button id="btn-close-preview" type="button" class="btn">Schließen</button>
|
||||||
|
</div>
|
||||||
|
<iframe id="previewFrame" class="flex-1 w-full"></iframe>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Edit Snippet Dialog -->
|
||||||
|
<dialog id="editSnippetDialog" class="rounded-2xl p-0 w-[700px]">
|
||||||
|
<form id="editSnippetForm" method="dialog" class="p-4 bg-white rounded-2xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Snippet bearbeiten</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-slate-600">Name</span>
|
||||||
|
<input id="edit_snip_name" type="text" class="w-full border rounded-lg px-3 py-2" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-slate-600">Content (HTML)</span>
|
||||||
|
<textarea id="edit_snip_content" class="w-full border rounded-lg px-3 py-2 h-64 font-mono text-sm"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button type="button" id="editSnippetCancel" class="btn">Abbrechen</button>
|
||||||
|
<button type="submit" id="editSnippetSave" class="btn">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Tools Dialog (NEU) -->
|
||||||
|
<dialog id="toolsDialog" class="rounded-2xl p-0 w-[92vw] h-[86vh]">
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div class="px-4 py-2 border-b bg-white/80 backdrop-blur flex items-center gap-3">
|
||||||
|
<strong id="toolsTitle" class="me-auto">Werkzeug</strong>
|
||||||
|
<button id="toolsCopy" type="button" class="btn hidden">Kopieren</button>
|
||||||
|
<button id="toolsDownload" type="button" class="btn hidden">Download</button>
|
||||||
|
<button id="toolsClose" type="button" class="btn">Schließen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JSON Ansicht -->
|
||||||
|
<div id="toolsJsonWrap" class="flex-1 overflow-auto hidden bg-slate-50">
|
||||||
|
<pre id="toolsJsonPre" class="p-4 text-sm leading-5 font-mono text-slate-800"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Iframe Ansicht -->
|
||||||
|
<iframe id="toolsFrame" class="flex-1 w-full hidden bg-white"></iframe>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div id="toast-root"></div>
|
||||||
|
|
||||||
|
<script src="assets/js/toast.js"></script>
|
||||||
|
<script type="module" src="assets/js/app.js?v=20250907"></script>
|
||||||
|
<script type="module" src="assets/js/ui-tools.js?v=20250907"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
public/login.php
Normal file
70
public/login.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
// login.php – Staging Login
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Login – EmailTemplate</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<!-- Admin-Theme (neu) -->
|
||||||
|
<link rel="stylesheet" href="/assets/css/app.css?v=2025-10-061">
|
||||||
|
<link rel="stylesheet" href="/assets/css/admin.css?v=2025-10-061">
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<link rel="stylesheet" href="/assets/css/toast.css">
|
||||||
|
<script src="/assets/js/toast.js" defer></script>
|
||||||
|
|
||||||
|
<!-- Klein & lokal: Nur falls admin.css kein eigenes Login-Layout setzt -->
|
||||||
|
<style>
|
||||||
|
:root{--bg:#f6f7fb;--card:#fff;--bd:#e5e7eb;--txt:#0f172a}
|
||||||
|
body.login-fallback{background:var(--bg)}
|
||||||
|
.wrap{max-width:380px;margin:10vh auto;background:var(--card);border:1px solid var(--bd);
|
||||||
|
border-radius:16px;box-shadow:0 10px 30px rgba(2,6,23,.06);padding:28px}
|
||||||
|
h1{margin:0 0 8px;font-size:20px}
|
||||||
|
p{margin:0 0 18px;color:#475569}
|
||||||
|
label{display:block;margin:12px 0 6px;color:#334155}
|
||||||
|
input{width:100%;padding:12px;border:1px solid #cbd5e1;border-radius:10px;font-size:15px}
|
||||||
|
button{width:100%;margin-top:16px;padding:12px;border:0;border-radius:12px;background:#111827;color:#fff;font-weight:600;cursor:pointer}
|
||||||
|
.mini{margin-top:10px;text-align:center}
|
||||||
|
a{color:#111827}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function already(){
|
||||||
|
try{
|
||||||
|
const r = await fetch('api.php?action=auth.me',{credentials:'include'});
|
||||||
|
const j = await r.json();
|
||||||
|
if(j && j.ok){ location.replace('index.php'); }
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
already();
|
||||||
|
|
||||||
|
async function doLogin(e){
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('email').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const res = await fetch('api.php?action=auth.login', {
|
||||||
|
method:'POST', credentials:'include',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({email, password})
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(()=>({}));
|
||||||
|
if(j.ok){ location.replace('index.php'); }
|
||||||
|
else { window.showToast?.('Login fehlgeschlagen', {type:'error'}); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="page-login login-fallback">
|
||||||
|
<form class="wrap" onsubmit="doLogin(event)">
|
||||||
|
<h1>Willkommen zurück</h1>
|
||||||
|
<p>Melde dich an, um deine kundenspezifischen Templates zu verwalten.</p>
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input id="email" type="email" required autocomplete="username" />
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input id="password" type="password" required autocomplete="current-password" />
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
<div class="mini"><a href="#" onclick="window.showToast?.('Passwort-Reset kommt später');return false;">Passwort vergessen?</a></div>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
public/tools/config-doctor.php
Normal file
9
public/tools/config-doctor.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
$path = realpath(__DIR__ . '/../../inc/config.php');
|
||||||
|
$out = ['path_expected'=>$path,'exists'=>false,'readable'=>false,'type'=>null,'keys'=>[], 'notes'=>[]];
|
||||||
|
if (is_file($path)) { $out['exists']=true; $out['readable']=is_readable($path);
|
||||||
|
try { $cfg = require $path; $out['type']=gettype($cfg); if (is_array($cfg)) { $out['keys']=array_keys($cfg); } }
|
||||||
|
catch (Throwable $e) { $out['notes'][] = $e->getMessage(); }
|
||||||
|
} else { $out['notes'][]='file not found'; }
|
||||||
|
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
118
public/tools/db-doctor.php
Normal file
118
public/tools/db-doctor.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
$conf = @include __DIR__ . '/../../inc/config.php';
|
||||||
|
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); }
|
||||||
|
|
||||||
|
if (!is_array($conf) || !isset($conf['templates'])) {
|
||||||
|
echo 'Invalid config.php (expected return array with keys templates/project)'; exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = $_GET['profile'] ?? 'templates';
|
||||||
|
$cfg = ($profile==='project') ? ($conf['project'] ?? null) : $conf['templates'];
|
||||||
|
$prefix = (string)(($profile==='project') ? ($conf['project']['prefix'] ?? '') : ($conf['templates']['prefix'] ?? ''));
|
||||||
|
|
||||||
|
$attempts=[]; $pdo=null;
|
||||||
|
$mkPdo=function(array $cfg) use(&$attempts){
|
||||||
|
$host = $cfg['db_host'] ?? null;
|
||||||
|
$socket = $cfg['db_socket'] ?? null;
|
||||||
|
$name = $cfg['db_name'] ?? '';
|
||||||
|
$user = $cfg['db_user'] ?? '';
|
||||||
|
$pass = $cfg['db_pass'] ?? '';
|
||||||
|
$charset = $cfg['db_charset'] ?? 'utf8mb4';
|
||||||
|
$port = (int)($cfg['db_port'] ?? 3306);
|
||||||
|
|
||||||
|
$dsn = $socket
|
||||||
|
? "mysql:unix_socket={$socket};dbname={$name};charset={$charset}"
|
||||||
|
: "mysql:host=".($host?:'127.0.0.1').";port={$port};dbname={$name};charset={$charset}";
|
||||||
|
|
||||||
|
try{
|
||||||
|
$pdo = new PDO($dsn,$user,$pass,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE=>PDO::FETCH_ASSOC,PDO::ATTR_EMULATE_PREPARES=>false]);
|
||||||
|
$attempts[]=['dsn'=>$dsn,'ok'=>true];
|
||||||
|
return $pdo;
|
||||||
|
}catch(Throwable $e){
|
||||||
|
$attempts[]=['dsn'=>$dsn,'ok'=>false,'error'=>$e->getMessage()];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (is_array($cfg)) $pdo=$mkPdo($cfg);
|
||||||
|
|
||||||
|
$tables = [
|
||||||
|
$prefix.'templates',
|
||||||
|
$prefix.'sections',
|
||||||
|
$prefix.'blocks',
|
||||||
|
$prefix.'snippets',
|
||||||
|
$prefix.'template_items',
|
||||||
|
$prefix.'section_items',
|
||||||
|
];
|
||||||
|
|
||||||
|
$tblStatus=[];
|
||||||
|
if ($pdo){
|
||||||
|
foreach($tables as $t){
|
||||||
|
try{ $pdo->query("SELECT 1 FROM {$t} LIMIT 1"); $tblStatus[$t]='ok'; }
|
||||||
|
catch(Throwable $e){ $tblStatus[$t]='missing/invalid: '.$e->getMessage(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>DB-Doctor (<?=h($profile)?>)</title>
|
||||||
|
<style>
|
||||||
|
body{font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#f8fafc;color:#0f172a;margin:0;padding:24px;}
|
||||||
|
.nav a{display:inline-block;margin-right:8px;padding:8px 12px;border:1px solid #e5e7eb;border-radius:8px;background:#fff;text-decoration:none;color:#0f172a}
|
||||||
|
.nav .active{background:#eef2ff;border-color:#c7d2fe;}
|
||||||
|
.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin:16px 0;}
|
||||||
|
table{border-collapse:collapse;width:100%;}
|
||||||
|
th,td{border-bottom:1px solid #e5e7eb;padding:8px 6px;text-align:left;}
|
||||||
|
.ok{color:#166534} .bad{color:#991b1b}
|
||||||
|
code{background:#0b1020;color:#e5e7eb;padding:2px 6px;border-radius:6px}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1>DB-Doctor <small style="font-weight:400;color:#475569">(Profil: <?=h($profile)?>)</small></h1>
|
||||||
|
|
||||||
|
<div class="nav">
|
||||||
|
<a href="?profile=templates" class="<?= $profile==='templates'?'active':'' ?>">Templates</a>
|
||||||
|
<a href="?profile=project" class="<?= $profile==='project' ?'active':'' ?>">Project</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Verbindungsversuche</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>DSN</th><th>Ergebnis</th><th>Detail</th></tr>
|
||||||
|
<?php foreach($attempts as $a): ?>
|
||||||
|
<tr>
|
||||||
|
<td><code><?=h($a['dsn'])?></code></td>
|
||||||
|
<td><?= !empty($a['ok']) ? '<span class="ok">OK</span>' : '<span class="bad">FAIL</span>' ?></td>
|
||||||
|
<td><?= h($a['error'] ?? '') ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Tabellen-Check (Templates-Schema)</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Tabelle</th><th>Status</th></tr>
|
||||||
|
<?php foreach($tables as $t): ?>
|
||||||
|
<tr>
|
||||||
|
<td><code><?=h($t)?></code></td>
|
||||||
|
<td>
|
||||||
|
<?php $s=$tblStatus[$t]??'not checked';
|
||||||
|
echo ($s==='ok') ? '<span class="ok">OK</span>' : '<span class="bad">'.h($s).'</span>'; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Rohdaten</h3>
|
||||||
|
<pre><?=h(json_encode([
|
||||||
|
'prefix'=>$prefix,
|
||||||
|
'hasPdo'=>!!$pdo,
|
||||||
|
'configKeys'=>array_keys($conf),
|
||||||
|
], JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES))?></pre>
|
||||||
|
</div>
|
||||||
|
</html>
|
||||||
|
|
||||||
1
public/vendor/grapesjs-preset-newsletter/.keep
vendored
Normal file
1
public/vendor/grapesjs-preset-newsletter/.keep
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Place vendor files here (grapesjs, grapesjs-preset-newsletter).
|
||||||
15
public/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js
vendored
Normal file
15
public/vendor/grapesjs-preset-newsletter/grapesjs-preset-newsletter.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/vendor/grapesjs/.keep
vendored
Normal file
1
public/vendor/grapesjs/.keep
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Place vendor files here (grapesjs, grapesjs-preset-newsletter).
|
||||||
1
public/vendor/grapesjs/grapes.min.css
vendored
Normal file
1
public/vendor/grapesjs/grapes.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
public/vendor/grapesjs/grapes.min.js
vendored
Normal file
3
public/vendor/grapesjs/grapes.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
public/version.php
Normal file
3
public/version.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
echo phpinfo();
|
||||||
|
?>
|
||||||
177
schema.sql
Normal file
177
schema.sql
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/* =======================================================================
|
||||||
|
Email Template System — Schema passend zur api.php (2025-09-06)
|
||||||
|
Charset: utf8mb4 | Engine: InnoDB
|
||||||
|
Hinweis:
|
||||||
|
- Mandantenfähigkeit über customer_id (alle Haupttabellen + Items + Assets).
|
||||||
|
- Contentspalten: Templates/Sections/Blocks = `html`, Snippets = `content`.
|
||||||
|
- Referenzen: template_items (Section/Block) & section_items (Block).
|
||||||
|
======================================================================= */
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
/* Drop-Reihenfolge: erst Items, dann Master-Tabellen */
|
||||||
|
DROP TABLE IF EXISTS `emailtemplate_section_items`;
|
||||||
|
DROP TABLE IF EXISTS `emailtemplate_template_items`;
|
||||||
|
DROP TABLE IF EXISTS `emailtemplate_assets`;
|
||||||
|
DROP TABLE IF EXISTS `emailtemplate_snippets`;
|
||||||
|
DROP TABLE IF EXISTS `emailtemplate_blocks`;
|
||||||
|
DROP TABLE IF EXISTS `emailtemplate_sections`;
|
||||||
|
DROP TABLE IF EXISTS `emailtemplate_templates`;
|
||||||
|
/*DROP TABLE IF EXISTS `customers`; -- optional (nur falls lokal hier gepflegt) */
|
||||||
|
/*DROP TABLE IF EXISTS `customer_users`; -- optional */
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Master-Tabellen
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
/* 1) Templates (oberste Ebene) */
|
||||||
|
CREATE TABLE `emailtemplate_templates` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`json_content` MEDIUMTEXT NULL, -- NEU: Speichert GrapesJS JSON-Zustand (Komponenten + Stile)
|
||||||
|
`html` MEDIUMTEXT NULL, -- BLEIBT: Speichert finalen, exportierten HTML-Code
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_tpl_customer` (`customer_id`),
|
||||||
|
KEY `idx_tpl_updated` (`updated_at`),
|
||||||
|
KEY `idx_tpl_name` (`name`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
/* 2) Sections (optional einem Template zugeordnet) */
|
||||||
|
CREATE TABLE `emailtemplate_sections` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` INT UNSIGNED NOT NULL,
|
||||||
|
`template_id` INT UNSIGNED NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`type` VARCHAR(50) NOT NULL DEFAULT 'html',
|
||||||
|
`json_content` MEDIUMTEXT NULL, -- NEU: Speichert GrapesJS JSON-Zustand (Komponenten + Stile)
|
||||||
|
`z_index` INT NOT NULL DEFAULT 0,
|
||||||
|
`html` MEDIUMTEXT NULL, -- BLEIBT: Speichert finalen, exportierten HTML-Code
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_sec_customer` (`customer_id`),
|
||||||
|
KEY `idx_sec_template` (`template_id`),
|
||||||
|
KEY `idx_sec_sort` (`template_id`,`z_index`,`id`),
|
||||||
|
CONSTRAINT `fk_sections_template`
|
||||||
|
FOREIGN KEY (`template_id`) REFERENCES `emailtemplate_templates` (`id`)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
/* 3) Blocks (optional einer Section zugeordnet) */
|
||||||
|
CREATE TABLE `emailtemplate_blocks` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` INT UNSIGNED NOT NULL,
|
||||||
|
`section_id` INT UNSIGNED NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`category` VARCHAR(100) NOT NULL DEFAULT 'Default',
|
||||||
|
`json_content` MEDIUMTEXT NULL, -- NEU: Speichert GrapesJS JSON-Zustand (Komponenten + Stile)
|
||||||
|
`html` MEDIUMTEXT NULL, -- BLEIBT: Speichert finalen, exportierten HTML-Code
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_blk_customer` (`customer_id`),
|
||||||
|
KEY `idx_blk_section` (`section_id`),
|
||||||
|
KEY `idx_blk_name` (`name`),
|
||||||
|
CONSTRAINT `fk_blocks_section`
|
||||||
|
FOREIGN KEY (`section_id`) REFERENCES `emailtemplate_sections` (`id`)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
/* 4) Snippets (kleine Bausteine; BY VALUE) */
|
||||||
|
CREATE TABLE `emailtemplate_snippets` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`category` VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
`json_content` MEDIUMTEXT NULL, -- NEU: Speichert GrapesJS JSON-Zustand
|
||||||
|
`content` MEDIUMTEXT NULL, -- BLEIBT: Speichert finalen, exportierten HTML-Code
|
||||||
|
`block_id` INT UNSIGNED NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_snp_customer` (`customer_id`),
|
||||||
|
KEY `idx_snp_name` (`name`),
|
||||||
|
KEY `idx_snp_block` (`block_id`),
|
||||||
|
CONSTRAINT `fk_snippets_block`
|
||||||
|
FOREIGN KEY (`block_id`) REFERENCES `emailtemplate_blocks` (`id`)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
/* 5) Assets (READ-only in api.php) */
|
||||||
|
CREATE TABLE `emailtemplate_assets` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`type` VARCHAR(50) NOT NULL,
|
||||||
|
`mime_type` VARCHAR(100) NOT NULL,
|
||||||
|
`size_bytes` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`public_url` VARCHAR(1000) NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ast_customer` (`customer_id`),
|
||||||
|
KEY `idx_ast_updated` (`updated_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Referenz-Item-Tabellen
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
/* 6) Items im Template (Sections ODER Blocks als Referenz) */
|
||||||
|
CREATE TABLE `emailtemplate_template_items` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` INT UNSIGNED NOT NULL,
|
||||||
|
`template_id` INT UNSIGNED NOT NULL,
|
||||||
|
`sort` INT NOT NULL DEFAULT 0,
|
||||||
|
`ref_type` ENUM('section','block') NOT NULL,
|
||||||
|
`ref_id` INT UNSIGNED NOT NULL,
|
||||||
|
`overrides_json` JSON NULL,
|
||||||
|
`lock_to_version` INT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_titems_template_sort` (`template_id`,`sort`),
|
||||||
|
KEY `idx_titems_customer` (`customer_id`),
|
||||||
|
KEY `idx_titems_ref` (`ref_type`,`ref_id`),
|
||||||
|
CONSTRAINT `fk_titems_template`
|
||||||
|
FOREIGN KEY (`template_id`) REFERENCES `emailtemplate_templates` (`id`)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
/* 7) Items in einer Section (NUR Blocks als Referenz) */
|
||||||
|
CREATE TABLE `emailtemplate_section_items` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` INT UNSIGNED NOT NULL,
|
||||||
|
`section_id` INT UNSIGNED NOT NULL,
|
||||||
|
`sort` INT NOT NULL DEFAULT 0,
|
||||||
|
`ref_type` ENUM('block') NOT NULL,
|
||||||
|
`ref_id` INT UNSIGNED NOT NULL,
|
||||||
|
`overrides_json` JSON NULL,
|
||||||
|
`lock_to_version` INT NULL,
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_sitems_section_sort` (`section_id`,`sort`),
|
||||||
|
KEY `idx_sitems_customer` (`customer_id`),
|
||||||
|
KEY `idx_sitems_ref` (`ref_type`,`ref_id`),
|
||||||
|
CONSTRAINT `fk_sitems_section`
|
||||||
|
FOREIGN KEY (`section_id`) REFERENCES `emailtemplate_sections` (`id`)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
Optionale Seed-Daten
|
||||||
|
========================= */
|
||||||
|
-- INSERT INTO `emailtemplate_templates` (`customer_id`,`name`,`html`) VALUES (1,'Newsletter-Template','');
|
||||||
|
-- INSERT INTO `emailtemplate_sections` (`customer_id`,`name`,`type`,`z_index`,`html`,`template_id`) VALUES (1,'Hero','html',10,'<table role="presentation" width="100%"><tr><td>{{children}}</td></tr></table>', NULL);
|
||||||
|
-- INSERT INTO `emailtemplate_blocks` (`customer_id`,`name`,`category`,`html`,`section_id`) VALUES (1,'CTA-Block','CTA','<table role="presentation"><tr><td>...</td></tr></table>', NULL);
|
||||||
|
-- INSERT INTO `emailtemplate_snippets` (`customer_id`,`name`,`category`,`content`) VALUES (1,'Textabsatz','Text','<p style="margin:0 0 12px 0;">Lorem ipsum…</p>');
|
||||||
|
|
||||||
|
-- FK-Prüfung (falls temporär deaktiviert) wieder aktivieren
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
22
vendor/autoload.php
vendored
Normal file
22
vendor/autoload.php
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload.php @generated by Composer
|
||||||
|
|
||||||
|
if (PHP_VERSION_ID < 50600) {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('HTTP/1.1 500 Internal Server Error');
|
||||||
|
}
|
||||||
|
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
|
||||||
|
if (!ini_get('display_errors')) {
|
||||||
|
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||||
|
fwrite(STDERR, $err);
|
||||||
|
} elseif (!headers_sent()) {
|
||||||
|
echo $err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RuntimeException($err);
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/composer/autoload_real.php';
|
||||||
|
|
||||||
|
return ComposerAutoloaderInit5dd4a204e737e7907bc2050330271446::getLoader();
|
||||||
579
vendor/composer/ClassLoader.php
vendored
Normal file
579
vendor/composer/ClassLoader.php
vendored
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Composer.
|
||||||
|
*
|
||||||
|
* (c) Nils Adermann <naderman@naderman.de>
|
||||||
|
* Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer\Autoload;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
||||||
|
*
|
||||||
|
* $loader = new \Composer\Autoload\ClassLoader();
|
||||||
|
*
|
||||||
|
* // register classes with namespaces
|
||||||
|
* $loader->add('Symfony\Component', __DIR__.'/component');
|
||||||
|
* $loader->add('Symfony', __DIR__.'/framework');
|
||||||
|
*
|
||||||
|
* // activate the autoloader
|
||||||
|
* $loader->register();
|
||||||
|
*
|
||||||
|
* // to enable searching the include path (eg. for PEAR packages)
|
||||||
|
* $loader->setUseIncludePath(true);
|
||||||
|
*
|
||||||
|
* In this example, if you try to use a class in the Symfony\Component
|
||||||
|
* namespace or one of its children (Symfony\Component\Console for instance),
|
||||||
|
* the autoloader will first look for the class under the component/
|
||||||
|
* directory, and it will then fallback to the framework/ directory if not
|
||||||
|
* found before giving up.
|
||||||
|
*
|
||||||
|
* This class is loosely based on the Symfony UniversalClassLoader.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
* @see https://www.php-fig.org/psr/psr-0/
|
||||||
|
* @see https://www.php-fig.org/psr/psr-4/
|
||||||
|
*/
|
||||||
|
class ClassLoader
|
||||||
|
{
|
||||||
|
/** @var \Closure(string):void */
|
||||||
|
private static $includeFile;
|
||||||
|
|
||||||
|
/** @var string|null */
|
||||||
|
private $vendorDir;
|
||||||
|
|
||||||
|
// PSR-4
|
||||||
|
/**
|
||||||
|
* @var array<string, array<string, int>>
|
||||||
|
*/
|
||||||
|
private $prefixLengthsPsr4 = array();
|
||||||
|
/**
|
||||||
|
* @var array<string, list<string>>
|
||||||
|
*/
|
||||||
|
private $prefixDirsPsr4 = array();
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private $fallbackDirsPsr4 = array();
|
||||||
|
|
||||||
|
// PSR-0
|
||||||
|
/**
|
||||||
|
* List of PSR-0 prefixes
|
||||||
|
*
|
||||||
|
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
||||||
|
*
|
||||||
|
* @var array<string, array<string, list<string>>>
|
||||||
|
*/
|
||||||
|
private $prefixesPsr0 = array();
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private $fallbackDirsPsr0 = array();
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
private $useIncludePath = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private $classMap = array();
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
private $classMapAuthoritative = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, bool>
|
||||||
|
*/
|
||||||
|
private $missingClasses = array();
|
||||||
|
|
||||||
|
/** @var string|null */
|
||||||
|
private $apcuPrefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, self>
|
||||||
|
*/
|
||||||
|
private static $registeredLoaders = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $vendorDir
|
||||||
|
*/
|
||||||
|
public function __construct($vendorDir = null)
|
||||||
|
{
|
||||||
|
$this->vendorDir = $vendorDir;
|
||||||
|
self::initializeIncludeClosure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<string>>
|
||||||
|
*/
|
||||||
|
public function getPrefixes()
|
||||||
|
{
|
||||||
|
if (!empty($this->prefixesPsr0)) {
|
||||||
|
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<string>>
|
||||||
|
*/
|
||||||
|
public function getPrefixesPsr4()
|
||||||
|
{
|
||||||
|
return $this->prefixDirsPsr4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getFallbackDirs()
|
||||||
|
{
|
||||||
|
return $this->fallbackDirsPsr0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getFallbackDirsPsr4()
|
||||||
|
{
|
||||||
|
return $this->fallbackDirsPsr4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> Array of classname => path
|
||||||
|
*/
|
||||||
|
public function getClassMap()
|
||||||
|
{
|
||||||
|
return $this->classMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $classMap Class to filename map
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function addClassMap(array $classMap)
|
||||||
|
{
|
||||||
|
if ($this->classMap) {
|
||||||
|
$this->classMap = array_merge($this->classMap, $classMap);
|
||||||
|
} else {
|
||||||
|
$this->classMap = $classMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a set of PSR-0 directories for a given prefix, either
|
||||||
|
* appending or prepending to the ones previously set for this prefix.
|
||||||
|
*
|
||||||
|
* @param string $prefix The prefix
|
||||||
|
* @param list<string>|string $paths The PSR-0 root directories
|
||||||
|
* @param bool $prepend Whether to prepend the directories
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function add($prefix, $paths, $prepend = false)
|
||||||
|
{
|
||||||
|
$paths = (array) $paths;
|
||||||
|
if (!$prefix) {
|
||||||
|
if ($prepend) {
|
||||||
|
$this->fallbackDirsPsr0 = array_merge(
|
||||||
|
$paths,
|
||||||
|
$this->fallbackDirsPsr0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->fallbackDirsPsr0 = array_merge(
|
||||||
|
$this->fallbackDirsPsr0,
|
||||||
|
$paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = $prefix[0];
|
||||||
|
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
||||||
|
$this->prefixesPsr0[$first][$prefix] = $paths;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($prepend) {
|
||||||
|
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||||
|
$paths,
|
||||||
|
$this->prefixesPsr0[$first][$prefix]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||||
|
$this->prefixesPsr0[$first][$prefix],
|
||||||
|
$paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a set of PSR-4 directories for a given namespace, either
|
||||||
|
* appending or prepending to the ones previously set for this namespace.
|
||||||
|
*
|
||||||
|
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||||
|
* @param list<string>|string $paths The PSR-4 base directories
|
||||||
|
* @param bool $prepend Whether to prepend the directories
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function addPsr4($prefix, $paths, $prepend = false)
|
||||||
|
{
|
||||||
|
$paths = (array) $paths;
|
||||||
|
if (!$prefix) {
|
||||||
|
// Register directories for the root namespace.
|
||||||
|
if ($prepend) {
|
||||||
|
$this->fallbackDirsPsr4 = array_merge(
|
||||||
|
$paths,
|
||||||
|
$this->fallbackDirsPsr4
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->fallbackDirsPsr4 = array_merge(
|
||||||
|
$this->fallbackDirsPsr4,
|
||||||
|
$paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
||||||
|
// Register directories for a new namespace.
|
||||||
|
$length = strlen($prefix);
|
||||||
|
if ('\\' !== $prefix[$length - 1]) {
|
||||||
|
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||||
|
}
|
||||||
|
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||||
|
$this->prefixDirsPsr4[$prefix] = $paths;
|
||||||
|
} elseif ($prepend) {
|
||||||
|
// Prepend directories for an already registered namespace.
|
||||||
|
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||||
|
$paths,
|
||||||
|
$this->prefixDirsPsr4[$prefix]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Append directories for an already registered namespace.
|
||||||
|
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||||
|
$this->prefixDirsPsr4[$prefix],
|
||||||
|
$paths
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a set of PSR-0 directories for a given prefix,
|
||||||
|
* replacing any others previously set for this prefix.
|
||||||
|
*
|
||||||
|
* @param string $prefix The prefix
|
||||||
|
* @param list<string>|string $paths The PSR-0 base directories
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function set($prefix, $paths)
|
||||||
|
{
|
||||||
|
if (!$prefix) {
|
||||||
|
$this->fallbackDirsPsr0 = (array) $paths;
|
||||||
|
} else {
|
||||||
|
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a set of PSR-4 directories for a given namespace,
|
||||||
|
* replacing any others previously set for this namespace.
|
||||||
|
*
|
||||||
|
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||||
|
* @param list<string>|string $paths The PSR-4 base directories
|
||||||
|
*
|
||||||
|
* @throws \InvalidArgumentException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setPsr4($prefix, $paths)
|
||||||
|
{
|
||||||
|
if (!$prefix) {
|
||||||
|
$this->fallbackDirsPsr4 = (array) $paths;
|
||||||
|
} else {
|
||||||
|
$length = strlen($prefix);
|
||||||
|
if ('\\' !== $prefix[$length - 1]) {
|
||||||
|
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||||
|
}
|
||||||
|
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||||
|
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns on searching the include path for class files.
|
||||||
|
*
|
||||||
|
* @param bool $useIncludePath
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setUseIncludePath($useIncludePath)
|
||||||
|
{
|
||||||
|
$this->useIncludePath = $useIncludePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can be used to check if the autoloader uses the include path to check
|
||||||
|
* for classes.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function getUseIncludePath()
|
||||||
|
{
|
||||||
|
return $this->useIncludePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns off searching the prefix and fallback directories for classes
|
||||||
|
* that have not been registered with the class map.
|
||||||
|
*
|
||||||
|
* @param bool $classMapAuthoritative
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setClassMapAuthoritative($classMapAuthoritative)
|
||||||
|
{
|
||||||
|
$this->classMapAuthoritative = $classMapAuthoritative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should class lookup fail if not found in the current class map?
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isClassMapAuthoritative()
|
||||||
|
{
|
||||||
|
return $this->classMapAuthoritative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
||||||
|
*
|
||||||
|
* @param string|null $apcuPrefix
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setApcuPrefix($apcuPrefix)
|
||||||
|
{
|
||||||
|
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The APCu prefix in use, or null if APCu caching is not enabled.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getApcuPrefix()
|
||||||
|
{
|
||||||
|
return $this->apcuPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers this instance as an autoloader.
|
||||||
|
*
|
||||||
|
* @param bool $prepend Whether to prepend the autoloader or not
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function register($prepend = false)
|
||||||
|
{
|
||||||
|
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
||||||
|
|
||||||
|
if (null === $this->vendorDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prepend) {
|
||||||
|
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
||||||
|
} else {
|
||||||
|
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||||
|
self::$registeredLoaders[$this->vendorDir] = $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregisters this instance as an autoloader.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function unregister()
|
||||||
|
{
|
||||||
|
spl_autoload_unregister(array($this, 'loadClass'));
|
||||||
|
|
||||||
|
if (null !== $this->vendorDir) {
|
||||||
|
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the given class or interface.
|
||||||
|
*
|
||||||
|
* @param string $class The name of the class
|
||||||
|
* @return true|null True if loaded, null otherwise
|
||||||
|
*/
|
||||||
|
public function loadClass($class)
|
||||||
|
{
|
||||||
|
if ($file = $this->findFile($class)) {
|
||||||
|
$includeFile = self::$includeFile;
|
||||||
|
$includeFile($file);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the path to the file where the class is defined.
|
||||||
|
*
|
||||||
|
* @param string $class The name of the class
|
||||||
|
*
|
||||||
|
* @return string|false The path if found, false otherwise
|
||||||
|
*/
|
||||||
|
public function findFile($class)
|
||||||
|
{
|
||||||
|
// class map lookup
|
||||||
|
if (isset($this->classMap[$class])) {
|
||||||
|
return $this->classMap[$class];
|
||||||
|
}
|
||||||
|
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (null !== $this->apcuPrefix) {
|
||||||
|
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
||||||
|
if ($hit) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $this->findFileWithExtension($class, '.php');
|
||||||
|
|
||||||
|
// Search for Hack files if we are running on HHVM
|
||||||
|
if (false === $file && defined('HHVM_VERSION')) {
|
||||||
|
$file = $this->findFileWithExtension($class, '.hh');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $this->apcuPrefix) {
|
||||||
|
apcu_add($this->apcuPrefix.$class, $file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $file) {
|
||||||
|
// Remember that this class does not exist.
|
||||||
|
$this->missingClasses[$class] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
||||||
|
*
|
||||||
|
* @return array<string, self>
|
||||||
|
*/
|
||||||
|
public static function getRegisteredLoaders()
|
||||||
|
{
|
||||||
|
return self::$registeredLoaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $class
|
||||||
|
* @param string $ext
|
||||||
|
* @return string|false
|
||||||
|
*/
|
||||||
|
private function findFileWithExtension($class, $ext)
|
||||||
|
{
|
||||||
|
// PSR-4 lookup
|
||||||
|
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
||||||
|
|
||||||
|
$first = $class[0];
|
||||||
|
if (isset($this->prefixLengthsPsr4[$first])) {
|
||||||
|
$subPath = $class;
|
||||||
|
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
||||||
|
$subPath = substr($subPath, 0, $lastPos);
|
||||||
|
$search = $subPath . '\\';
|
||||||
|
if (isset($this->prefixDirsPsr4[$search])) {
|
||||||
|
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
||||||
|
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
||||||
|
if (file_exists($file = $dir . $pathEnd)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSR-4 fallback dirs
|
||||||
|
foreach ($this->fallbackDirsPsr4 as $dir) {
|
||||||
|
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSR-0 lookup
|
||||||
|
if (false !== $pos = strrpos($class, '\\')) {
|
||||||
|
// namespaced class name
|
||||||
|
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
||||||
|
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
||||||
|
} else {
|
||||||
|
// PEAR-like class name
|
||||||
|
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->prefixesPsr0[$first])) {
|
||||||
|
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
||||||
|
if (0 === strpos($class, $prefix)) {
|
||||||
|
foreach ($dirs as $dir) {
|
||||||
|
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSR-0 fallback dirs
|
||||||
|
foreach ($this->fallbackDirsPsr0 as $dir) {
|
||||||
|
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PSR-0 include paths.
|
||||||
|
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function initializeIncludeClosure()
|
||||||
|
{
|
||||||
|
if (self::$includeFile !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope isolated include.
|
||||||
|
*
|
||||||
|
* Prevents access to $this/self from included files.
|
||||||
|
*
|
||||||
|
* @param string $file
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
self::$includeFile = \Closure::bind(static function($file) {
|
||||||
|
include $file;
|
||||||
|
}, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
396
vendor/composer/InstalledVersions.php
vendored
Normal file
396
vendor/composer/InstalledVersions.php
vendored
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of Composer.
|
||||||
|
*
|
||||||
|
* (c) Nils Adermann <naderman@naderman.de>
|
||||||
|
* Jordi Boggiano <j.boggiano@seld.be>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Composer;
|
||||||
|
|
||||||
|
use Composer\Autoload\ClassLoader;
|
||||||
|
use Composer\Semver\VersionParser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is copied in every Composer installed project and available to all
|
||||||
|
*
|
||||||
|
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
||||||
|
*
|
||||||
|
* To require its presence, you can require `composer-runtime-api ^2.0`
|
||||||
|
*
|
||||||
|
* @final
|
||||||
|
*/
|
||||||
|
class InstalledVersions
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
private static $selfDir = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mixed[]|null
|
||||||
|
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||||
|
*/
|
||||||
|
private static $installed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
private static $installedIsLocalDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool|null
|
||||||
|
*/
|
||||||
|
private static $canGetVendors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array[]
|
||||||
|
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||||
|
*/
|
||||||
|
private static $installedByVendor = array();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
* @psalm-return list<string>
|
||||||
|
*/
|
||||||
|
public static function getInstalledPackages()
|
||||||
|
{
|
||||||
|
$packages = array();
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
$packages[] = array_keys($installed['versions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 === \count($packages)) {
|
||||||
|
return $packages[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of all package names with a specific type e.g. 'library'
|
||||||
|
*
|
||||||
|
* @param string $type
|
||||||
|
* @return string[]
|
||||||
|
* @psalm-return list<string>
|
||||||
|
*/
|
||||||
|
public static function getInstalledPackagesByType($type)
|
||||||
|
{
|
||||||
|
$packagesByType = array();
|
||||||
|
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
foreach ($installed['versions'] as $name => $package) {
|
||||||
|
if (isset($package['type']) && $package['type'] === $type) {
|
||||||
|
$packagesByType[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $packagesByType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the given package is installed
|
||||||
|
*
|
||||||
|
* This also returns true if the package name is provided or replaced by another package
|
||||||
|
*
|
||||||
|
* @param string $packageName
|
||||||
|
* @param bool $includeDevRequirements
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isInstalled($packageName, $includeDevRequirements = true)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (isset($installed['versions'][$packageName])) {
|
||||||
|
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the given package satisfies a version constraint
|
||||||
|
*
|
||||||
|
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
||||||
|
*
|
||||||
|
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
||||||
|
*
|
||||||
|
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
||||||
|
* @param string $packageName
|
||||||
|
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
||||||
|
{
|
||||||
|
$constraint = $parser->parseConstraints((string) $constraint);
|
||||||
|
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
||||||
|
|
||||||
|
return $provided->matches($constraint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a version constraint representing all the range(s) which are installed for a given package
|
||||||
|
*
|
||||||
|
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
||||||
|
* whether a given version of a package is installed, and not just whether it exists
|
||||||
|
*
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string Version constraint usable with composer/semver
|
||||||
|
*/
|
||||||
|
public static function getVersionRanges($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ranges = array();
|
||||||
|
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||||
|
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
||||||
|
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
||||||
|
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
||||||
|
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' || ', $ranges);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||||
|
*/
|
||||||
|
public static function getVersion($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($installed['versions'][$packageName]['version'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $installed['versions'][$packageName]['version'];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||||
|
*/
|
||||||
|
public static function getPrettyVersion($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $installed['versions'][$packageName]['pretty_version'];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
||||||
|
*/
|
||||||
|
public static function getReference($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($installed['versions'][$packageName]['reference'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $installed['versions'][$packageName]['reference'];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $packageName
|
||||||
|
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
||||||
|
*/
|
||||||
|
public static function getInstallPath($packageName)
|
||||||
|
{
|
||||||
|
foreach (self::getInstalled() as $installed) {
|
||||||
|
if (!isset($installed['versions'][$packageName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
||||||
|
*/
|
||||||
|
public static function getRootPackage()
|
||||||
|
{
|
||||||
|
$installed = self::getInstalled();
|
||||||
|
|
||||||
|
return $installed[0]['root'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw installed.php data for custom implementations
|
||||||
|
*
|
||||||
|
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
||||||
|
* @return array[]
|
||||||
|
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
||||||
|
*/
|
||||||
|
public static function getRawData()
|
||||||
|
{
|
||||||
|
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
||||||
|
|
||||||
|
if (null === self::$installed) {
|
||||||
|
// only require the installed.php file if this file is loaded from its dumped location,
|
||||||
|
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||||
|
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||||
|
self::$installed = include __DIR__ . '/installed.php';
|
||||||
|
} else {
|
||||||
|
self::$installed = array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
||||||
|
*
|
||||||
|
* @return array[]
|
||||||
|
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||||
|
*/
|
||||||
|
public static function getAllRawData()
|
||||||
|
{
|
||||||
|
return self::getInstalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets you reload the static array from another file
|
||||||
|
*
|
||||||
|
* This is only useful for complex integrations in which a project needs to use
|
||||||
|
* this class but then also needs to execute another project's autoloader in process,
|
||||||
|
* and wants to ensure both projects have access to their version of installed.php.
|
||||||
|
*
|
||||||
|
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
||||||
|
* the data it needs from this class, then call reload() with
|
||||||
|
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
||||||
|
* the project in which it runs can then also use this class safely, without
|
||||||
|
* interference between PHPUnit's dependencies and the project's dependencies.
|
||||||
|
*
|
||||||
|
* @param array[] $data A vendor/composer/installed.php data set
|
||||||
|
* @return void
|
||||||
|
*
|
||||||
|
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
||||||
|
*/
|
||||||
|
public static function reload($data)
|
||||||
|
{
|
||||||
|
self::$installed = $data;
|
||||||
|
self::$installedByVendor = array();
|
||||||
|
|
||||||
|
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
|
||||||
|
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
|
||||||
|
// so we have to assume it does not, and that may result in duplicate data being returned when listing
|
||||||
|
// all installed packages for example
|
||||||
|
self::$installedIsLocalDir = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private static function getSelfDir()
|
||||||
|
{
|
||||||
|
if (self::$selfDir === null) {
|
||||||
|
self::$selfDir = strtr(__DIR__, '\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$selfDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array[]
|
||||||
|
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||||
|
*/
|
||||||
|
private static function getInstalled()
|
||||||
|
{
|
||||||
|
if (null === self::$canGetVendors) {
|
||||||
|
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
||||||
|
}
|
||||||
|
|
||||||
|
$installed = array();
|
||||||
|
$copiedLocalDir = false;
|
||||||
|
|
||||||
|
if (self::$canGetVendors) {
|
||||||
|
$selfDir = self::getSelfDir();
|
||||||
|
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||||
|
$vendorDir = strtr($vendorDir, '\\', '/');
|
||||||
|
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||||
|
$installed[] = self::$installedByVendor[$vendorDir];
|
||||||
|
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||||
|
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||||
|
$required = require $vendorDir.'/composer/installed.php';
|
||||||
|
self::$installedByVendor[$vendorDir] = $required;
|
||||||
|
$installed[] = $required;
|
||||||
|
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
|
||||||
|
self::$installed = $required;
|
||||||
|
self::$installedIsLocalDir = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
|
||||||
|
$copiedLocalDir = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === self::$installed) {
|
||||||
|
// only require the installed.php file if this file is loaded from its dumped location,
|
||||||
|
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||||
|
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||||
|
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||||
|
$required = require __DIR__ . '/installed.php';
|
||||||
|
self::$installed = $required;
|
||||||
|
} else {
|
||||||
|
self::$installed = array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::$installed !== array() && !$copiedLocalDir) {
|
||||||
|
$installed[] = self::$installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $installed;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
vendor/composer/LICENSE
vendored
Normal file
21
vendor/composer/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
10
vendor/composer/autoload_classmap.php
vendored
Normal file
10
vendor/composer/autoload_classmap.php
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_classmap.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||||
|
);
|
||||||
9
vendor/composer/autoload_namespaces.php
vendored
Normal file
9
vendor/composer/autoload_namespaces.php
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_namespaces.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
);
|
||||||
11
vendor/composer/autoload_psr4.php
vendored
Normal file
11
vendor/composer/autoload_psr4.php
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_psr4.php @generated by Composer
|
||||||
|
|
||||||
|
$vendorDir = dirname(__DIR__);
|
||||||
|
$baseDir = dirname($vendorDir);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'TijsVerkoyen\\CssToInlineStyles\\' => array($vendorDir . '/tijsverkoyen/css-to-inline-styles/src'),
|
||||||
|
'Symfony\\Component\\CssSelector\\' => array($vendorDir . '/symfony/css-selector'),
|
||||||
|
);
|
||||||
38
vendor/composer/autoload_real.php
vendored
Normal file
38
vendor/composer/autoload_real.php
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_real.php @generated by Composer
|
||||||
|
|
||||||
|
class ComposerAutoloaderInit5dd4a204e737e7907bc2050330271446
|
||||||
|
{
|
||||||
|
private static $loader;
|
||||||
|
|
||||||
|
public static function loadClassLoader($class)
|
||||||
|
{
|
||||||
|
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||||
|
require __DIR__ . '/ClassLoader.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Composer\Autoload\ClassLoader
|
||||||
|
*/
|
||||||
|
public static function getLoader()
|
||||||
|
{
|
||||||
|
if (null !== self::$loader) {
|
||||||
|
return self::$loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
require __DIR__ . '/platform_check.php';
|
||||||
|
|
||||||
|
spl_autoload_register(array('ComposerAutoloaderInit5dd4a204e737e7907bc2050330271446', 'loadClassLoader'), true, true);
|
||||||
|
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||||
|
spl_autoload_unregister(array('ComposerAutoloaderInit5dd4a204e737e7907bc2050330271446', 'loadClassLoader'));
|
||||||
|
|
||||||
|
require __DIR__ . '/autoload_static.php';
|
||||||
|
call_user_func(\Composer\Autoload\ComposerStaticInit5dd4a204e737e7907bc2050330271446::getInitializer($loader));
|
||||||
|
|
||||||
|
$loader->register(true);
|
||||||
|
|
||||||
|
return $loader;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
vendor/composer/autoload_static.php
vendored
Normal file
44
vendor/composer/autoload_static.php
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// autoload_static.php @generated by Composer
|
||||||
|
|
||||||
|
namespace Composer\Autoload;
|
||||||
|
|
||||||
|
class ComposerStaticInit5dd4a204e737e7907bc2050330271446
|
||||||
|
{
|
||||||
|
public static $prefixLengthsPsr4 = array (
|
||||||
|
'T' =>
|
||||||
|
array (
|
||||||
|
'TijsVerkoyen\\CssToInlineStyles\\' => 31,
|
||||||
|
),
|
||||||
|
'S' =>
|
||||||
|
array (
|
||||||
|
'Symfony\\Component\\CssSelector\\' => 30,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
public static $prefixDirsPsr4 = array (
|
||||||
|
'TijsVerkoyen\\CssToInlineStyles\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/tijsverkoyen/css-to-inline-styles/src',
|
||||||
|
),
|
||||||
|
'Symfony\\Component\\CssSelector\\' =>
|
||||||
|
array (
|
||||||
|
0 => __DIR__ . '/..' . '/symfony/css-selector',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
public static $classMap = array (
|
||||||
|
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||||
|
);
|
||||||
|
|
||||||
|
public static function getInitializer(ClassLoader $loader)
|
||||||
|
{
|
||||||
|
return \Closure::bind(function () use ($loader) {
|
||||||
|
$loader->prefixLengthsPsr4 = ComposerStaticInit5dd4a204e737e7907bc2050330271446::$prefixLengthsPsr4;
|
||||||
|
$loader->prefixDirsPsr4 = ComposerStaticInit5dd4a204e737e7907bc2050330271446::$prefixDirsPsr4;
|
||||||
|
$loader->classMap = ComposerStaticInit5dd4a204e737e7907bc2050330271446::$classMap;
|
||||||
|
|
||||||
|
}, null, ClassLoader::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
vendor/composer/installed.json
vendored
Normal file
132
vendor/composer/installed.json
vendored
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
{
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "symfony/css-selector",
|
||||||
|
"version": "v7.3.0",
|
||||||
|
"version_normalized": "7.3.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/css-selector.git",
|
||||||
|
"reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2",
|
||||||
|
"reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2"
|
||||||
|
},
|
||||||
|
"time": "2024-09-25T14:21:43+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\CssSelector\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jean-François Simon",
|
||||||
|
"email": "jeanfrancois.simon@sensiolabs.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Converts CSS selectors to XPath expressions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/css-selector/tree/v7.3.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"install-path": "../symfony/css-selector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
|
"version": "v2.3.0",
|
||||||
|
"version_normalized": "2.3.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
|
||||||
|
"reference": "0d72ac1c00084279c1816675284073c5a337c20d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d",
|
||||||
|
"reference": "0d72ac1c00084279c1816675284073c5a337c20d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"symfony/css-selector": "^5.4 || ^6.0 || ^7.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^2.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^2.0",
|
||||||
|
"phpunit/phpunit": "^8.5.21 || ^9.5.10"
|
||||||
|
},
|
||||||
|
"time": "2024-12-21T16:25:41+00:00",
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"installation-source": "dist",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"TijsVerkoyen\\CssToInlineStyles\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Tijs Verkoyen",
|
||||||
|
"email": "css_to_inline_styles@verkoyen.eu",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.",
|
||||||
|
"homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
|
||||||
|
"source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0"
|
||||||
|
},
|
||||||
|
"install-path": "../tijsverkoyen/css-to-inline-styles"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"dev-package-names": []
|
||||||
|
}
|
||||||
41
vendor/composer/installed.php
vendored
Normal file
41
vendor/composer/installed.php
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php return array(
|
||||||
|
'root' => array(
|
||||||
|
'name' => 'ssh-w020abd8/staging',
|
||||||
|
'pretty_version' => '1.0.0+no-version-set',
|
||||||
|
'version' => '1.0.0.0',
|
||||||
|
'reference' => null,
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../../',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev' => true,
|
||||||
|
),
|
||||||
|
'versions' => array(
|
||||||
|
'ssh-w020abd8/staging' => array(
|
||||||
|
'pretty_version' => '1.0.0+no-version-set',
|
||||||
|
'version' => '1.0.0.0',
|
||||||
|
'reference' => null,
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../../',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'symfony/css-selector' => array(
|
||||||
|
'pretty_version' => 'v7.3.0',
|
||||||
|
'version' => '7.3.0.0',
|
||||||
|
'reference' => '601a5ce9aaad7bf10797e3663faefce9e26c24e2',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../symfony/css-selector',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
'tijsverkoyen/css-to-inline-styles' => array(
|
||||||
|
'pretty_version' => 'v2.3.0',
|
||||||
|
'version' => '2.3.0.0',
|
||||||
|
'reference' => '0d72ac1c00084279c1816675284073c5a337c20d',
|
||||||
|
'type' => 'library',
|
||||||
|
'install_path' => __DIR__ . '/../tijsverkoyen/css-to-inline-styles',
|
||||||
|
'aliases' => array(),
|
||||||
|
'dev_requirement' => false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
25
vendor/composer/platform_check.php
vendored
Normal file
25
vendor/composer/platform_check.php
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// platform_check.php @generated by Composer
|
||||||
|
|
||||||
|
$issues = array();
|
||||||
|
|
||||||
|
if (!(PHP_VERSION_ID >= 80200)) {
|
||||||
|
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issues) {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('HTTP/1.1 500 Internal Server Error');
|
||||||
|
}
|
||||||
|
if (!ini_get('display_errors')) {
|
||||||
|
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||||
|
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
|
||||||
|
} elseif (!headers_sent()) {
|
||||||
|
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Composer detected issues in your platform: ' . implode(' ', $issues)
|
||||||
|
);
|
||||||
|
}
|
||||||
29
vendor/symfony/css-selector/CHANGELOG.md
vendored
Normal file
29
vendor/symfony/css-selector/CHANGELOG.md
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
CHANGELOG
|
||||||
|
=========
|
||||||
|
|
||||||
|
7.1
|
||||||
|
---
|
||||||
|
|
||||||
|
* Add support for `:is()`
|
||||||
|
* Add support for `:where()`
|
||||||
|
|
||||||
|
6.3
|
||||||
|
---
|
||||||
|
|
||||||
|
* Add support for `:scope`
|
||||||
|
|
||||||
|
4.4.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Added support for `*:only-of-type`
|
||||||
|
|
||||||
|
2.8.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Added the `CssSelectorConverter` class as a non-static API for the component.
|
||||||
|
* Deprecated the `CssSelector` static API of the component.
|
||||||
|
|
||||||
|
2.1.0
|
||||||
|
-----
|
||||||
|
|
||||||
|
* none
|
||||||
67
vendor/symfony/css-selector/CssSelectorConverter.php
vendored
Normal file
67
vendor/symfony/css-selector/CssSelectorConverter.php
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector;
|
||||||
|
|
||||||
|
use Symfony\Component\CssSelector\Parser\Shortcut\ClassParser;
|
||||||
|
use Symfony\Component\CssSelector\Parser\Shortcut\ElementParser;
|
||||||
|
use Symfony\Component\CssSelector\Parser\Shortcut\EmptyStringParser;
|
||||||
|
use Symfony\Component\CssSelector\Parser\Shortcut\HashParser;
|
||||||
|
use Symfony\Component\CssSelector\XPath\Extension\HtmlExtension;
|
||||||
|
use Symfony\Component\CssSelector\XPath\Translator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CssSelectorConverter is the main entry point of the component and can convert CSS
|
||||||
|
* selectors to XPath expressions.
|
||||||
|
*
|
||||||
|
* @author Christophe Coevoet <stof@notk.org>
|
||||||
|
*/
|
||||||
|
class CssSelectorConverter
|
||||||
|
{
|
||||||
|
private Translator $translator;
|
||||||
|
private array $cache;
|
||||||
|
|
||||||
|
private static array $xmlCache = [];
|
||||||
|
private static array $htmlCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $html Whether HTML support should be enabled. Disable it for XML documents
|
||||||
|
*/
|
||||||
|
public function __construct(bool $html = true)
|
||||||
|
{
|
||||||
|
$this->translator = new Translator();
|
||||||
|
|
||||||
|
if ($html) {
|
||||||
|
$this->translator->registerExtension(new HtmlExtension($this->translator));
|
||||||
|
$this->cache = &self::$htmlCache;
|
||||||
|
} else {
|
||||||
|
$this->cache = &self::$xmlCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->translator
|
||||||
|
->registerParserShortcut(new EmptyStringParser())
|
||||||
|
->registerParserShortcut(new ElementParser())
|
||||||
|
->registerParserShortcut(new ClassParser())
|
||||||
|
->registerParserShortcut(new HashParser())
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a CSS expression to its XPath equivalent.
|
||||||
|
*
|
||||||
|
* Optionally, a prefix can be added to the resulting XPath
|
||||||
|
* expression with the $prefix parameter.
|
||||||
|
*/
|
||||||
|
public function toXPath(string $cssExpr, string $prefix = 'descendant-or-self::'): string
|
||||||
|
{
|
||||||
|
return $this->cache[$prefix][$cssExpr] ??= $this->translator->cssToXPath($cssExpr, $prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
vendor/symfony/css-selector/Exception/ExceptionInterface.php
vendored
Normal file
24
vendor/symfony/css-selector/Exception/ExceptionInterface.php
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for exceptions.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*/
|
||||||
|
interface ExceptionInterface extends \Throwable
|
||||||
|
{
|
||||||
|
}
|
||||||
24
vendor/symfony/css-selector/Exception/ExpressionErrorException.php
vendored
Normal file
24
vendor/symfony/css-selector/Exception/ExpressionErrorException.php
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParseException is thrown when a CSS selector syntax is not valid.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*/
|
||||||
|
class ExpressionErrorException extends ParseException
|
||||||
|
{
|
||||||
|
}
|
||||||
24
vendor/symfony/css-selector/Exception/InternalErrorException.php
vendored
Normal file
24
vendor/symfony/css-selector/Exception/InternalErrorException.php
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParseException is thrown when a CSS selector syntax is not valid.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*/
|
||||||
|
class InternalErrorException extends ParseException
|
||||||
|
{
|
||||||
|
}
|
||||||
24
vendor/symfony/css-selector/Exception/ParseException.php
vendored
Normal file
24
vendor/symfony/css-selector/Exception/ParseException.php
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParseException is thrown when a CSS selector syntax is not valid.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Fabien Potencier <fabien@symfony.com>
|
||||||
|
*/
|
||||||
|
class ParseException extends \Exception implements ExceptionInterface
|
||||||
|
{
|
||||||
|
}
|
||||||
55
vendor/symfony/css-selector/Exception/SyntaxErrorException.php
vendored
Normal file
55
vendor/symfony/css-selector/Exception/SyntaxErrorException.php
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Exception;
|
||||||
|
|
||||||
|
use Symfony\Component\CssSelector\Parser\Token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParseException is thrown when a CSS selector syntax is not valid.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*/
|
||||||
|
class SyntaxErrorException extends ParseException
|
||||||
|
{
|
||||||
|
public static function unexpectedToken(string $expectedValue, Token $foundToken): self
|
||||||
|
{
|
||||||
|
return new self(\sprintf('Expected %s, but %s found.', $expectedValue, $foundToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pseudoElementFound(string $pseudoElement, string $unexpectedLocation): self
|
||||||
|
{
|
||||||
|
return new self(\sprintf('Unexpected pseudo-element "::%s" found %s.', $pseudoElement, $unexpectedLocation));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unclosedString(int $position): self
|
||||||
|
{
|
||||||
|
return new self(\sprintf('Unclosed/invalid string at %s.', $position));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function nestedNot(): self
|
||||||
|
{
|
||||||
|
return new self('Got nested ::not().');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notAtTheStartOfASelector(string $pseudoElement): self
|
||||||
|
{
|
||||||
|
return new self(\sprintf('Got immediate child pseudo-element ":%s" not at the start of a selector', $pseudoElement));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function stringAsFunctionArgument(): self
|
||||||
|
{
|
||||||
|
return new self('String not allowed as function argument.');
|
||||||
|
}
|
||||||
|
}
|
||||||
19
vendor/symfony/css-selector/LICENSE
vendored
Normal file
19
vendor/symfony/css-selector/LICENSE
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) 2004-present Fabien Potencier
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
32
vendor/symfony/css-selector/Node/AbstractNode.php
vendored
Normal file
32
vendor/symfony/css-selector/Node/AbstractNode.php
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base node class.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
abstract class AbstractNode implements NodeInterface
|
||||||
|
{
|
||||||
|
private string $nodeName;
|
||||||
|
|
||||||
|
public function getNodeName(): string
|
||||||
|
{
|
||||||
|
return $this->nodeName ??= preg_replace('~.*\\\\([^\\\\]+)Node$~', '$1', static::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
vendor/symfony/css-selector/Node/AttributeNode.php
vendored
Normal file
73
vendor/symfony/css-selector/Node/AttributeNode.php
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a "<selector>[<namespace>|<attribute> <operator> <value>]" node.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class AttributeNode extends AbstractNode
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private NodeInterface $selector,
|
||||||
|
private ?string $namespace,
|
||||||
|
private string $attribute,
|
||||||
|
private string $operator,
|
||||||
|
private ?string $value,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSelector(): NodeInterface
|
||||||
|
{
|
||||||
|
return $this->selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNamespace(): ?string
|
||||||
|
{
|
||||||
|
return $this->namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAttribute(): string
|
||||||
|
{
|
||||||
|
return $this->attribute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOperator(): string
|
||||||
|
{
|
||||||
|
return $this->operator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValue(): ?string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpecificity(): Specificity
|
||||||
|
{
|
||||||
|
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
$attribute = $this->namespace ? $this->namespace.'|'.$this->attribute : $this->attribute;
|
||||||
|
|
||||||
|
return 'exists' === $this->operator
|
||||||
|
? \sprintf('%s[%s[%s]]', $this->getNodeName(), $this->selector, $attribute)
|
||||||
|
: \sprintf("%s[%s[%s %s '%s']]", $this->getNodeName(), $this->selector, $attribute, $this->operator, $this->value);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
vendor/symfony/css-selector/Node/ClassNode.php
vendored
Normal file
51
vendor/symfony/css-selector/Node/ClassNode.php
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a "<selector>.<name>" node.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class ClassNode extends AbstractNode
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private NodeInterface $selector,
|
||||||
|
private string $name,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSelector(): NodeInterface
|
||||||
|
{
|
||||||
|
return $this->selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpecificity(): Specificity
|
||||||
|
{
|
||||||
|
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return \sprintf('%s[%s.%s]', $this->getNodeName(), $this->selector, $this->name);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
vendor/symfony/css-selector/Node/CombinedSelectorNode.php
vendored
Normal file
59
vendor/symfony/css-selector/Node/CombinedSelectorNode.php
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a combined node.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class CombinedSelectorNode extends AbstractNode
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private NodeInterface $selector,
|
||||||
|
private string $combinator,
|
||||||
|
private NodeInterface $subSelector,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSelector(): NodeInterface
|
||||||
|
{
|
||||||
|
return $this->selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCombinator(): string
|
||||||
|
{
|
||||||
|
return $this->combinator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubSelector(): NodeInterface
|
||||||
|
{
|
||||||
|
return $this->subSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpecificity(): Specificity
|
||||||
|
{
|
||||||
|
return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
$combinator = ' ' === $this->combinator ? '<followed>' : $this->combinator;
|
||||||
|
|
||||||
|
return \sprintf('%s[%s %s %s]', $this->getNodeName(), $this->selector, $combinator, $this->subSelector);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
vendor/symfony/css-selector/Node/ElementNode.php
vendored
Normal file
53
vendor/symfony/css-selector/Node/ElementNode.php
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a "<namespace>|<element>" node.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class ElementNode extends AbstractNode
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ?string $namespace = null,
|
||||||
|
private ?string $element = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNamespace(): ?string
|
||||||
|
{
|
||||||
|
return $this->namespace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getElement(): ?string
|
||||||
|
{
|
||||||
|
return $this->element;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpecificity(): Specificity
|
||||||
|
{
|
||||||
|
return new Specificity(0, 0, $this->element ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
$element = $this->element ?: '*';
|
||||||
|
|
||||||
|
return \sprintf('%s[%s]', $this->getNodeName(), $this->namespace ? $this->namespace.'|'.$element : $element);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
vendor/symfony/css-selector/Node/FunctionNode.php
vendored
Normal file
70
vendor/symfony/css-selector/Node/FunctionNode.php
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
use Symfony\Component\CssSelector\Parser\Token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a "<selector>:<name>(<arguments>)" node.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class FunctionNode extends AbstractNode
|
||||||
|
{
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Token[] $arguments
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private NodeInterface $selector,
|
||||||
|
string $name,
|
||||||
|
private array $arguments = [],
|
||||||
|
) {
|
||||||
|
$this->name = strtolower($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSelector(): NodeInterface
|
||||||
|
{
|
||||||
|
return $this->selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Token[]
|
||||||
|
*/
|
||||||
|
public function getArguments(): array
|
||||||
|
{
|
||||||
|
return $this->arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpecificity(): Specificity
|
||||||
|
{
|
||||||
|
return $this->selector->getSpecificity()->plus(new Specificity(0, 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
$arguments = implode(', ', array_map(fn (Token $token) => "'".$token->getValue()."'", $this->arguments));
|
||||||
|
|
||||||
|
return \sprintf('%s[%s:%s(%s)]', $this->getNodeName(), $this->selector, $this->name, $arguments ? '['.$arguments.']' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
vendor/symfony/css-selector/Node/HashNode.php
vendored
Normal file
51
vendor/symfony/css-selector/Node/HashNode.php
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a "<selector>#<id>" node.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class HashNode extends AbstractNode
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private NodeInterface $selector,
|
||||||
|
private string $id,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSelector(): NodeInterface
|
||||||
|
{
|
||||||
|
return $this->selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpecificity(): Specificity
|
||||||
|
{
|
||||||
|
return $this->selector->getSpecificity()->plus(new Specificity(1, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return \sprintf('%s[%s#%s]', $this->getNodeName(), $this->selector, $this->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
vendor/symfony/css-selector/Node/MatchingNode.php
vendored
Normal file
55
vendor/symfony/css-selector/Node/MatchingNode.php
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a "<selector>:is(<subSelectorList>)" node.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Hubert Lenoir <lenoir.hubert@gmail.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class MatchingNode extends AbstractNode
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<NodeInterface> $arguments
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly NodeInterface $selector,
|
||||||
|
public readonly array $arguments = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpecificity(): Specificity
|
||||||
|
{
|
||||||
|
$argumentsSpecificity = array_reduce(
|
||||||
|
$this->arguments,
|
||||||
|
fn ($c, $n) => 1 === $n->getSpecificity()->compareTo($c) ? $n->getSpecificity() : $c,
|
||||||
|
new Specificity(0, 0, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->selector->getSpecificity()->plus($argumentsSpecificity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
$selectorArguments = array_map(
|
||||||
|
fn ($n): string => ltrim((string) $n, '*'),
|
||||||
|
$this->arguments,
|
||||||
|
);
|
||||||
|
|
||||||
|
return \sprintf('%s[%s:is(%s)]', $this->getNodeName(), $this->selector, implode(', ', $selectorArguments));
|
||||||
|
}
|
||||||
|
}
|
||||||
51
vendor/symfony/css-selector/Node/NegationNode.php
vendored
Normal file
51
vendor/symfony/css-selector/Node/NegationNode.php
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\CssSelector\Node;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a "<selector>:not(<identifier>)" node.
|
||||||
|
*
|
||||||
|
* This component is a port of the Python cssselect library,
|
||||||
|
* which is copyright Ian Bicking, @see https://github.com/SimonSapin/cssselect.
|
||||||
|
*
|
||||||
|
* @author Jean-François Simon <jeanfrancois.simon@sensiolabs.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class NegationNode extends AbstractNode
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private NodeInterface $selector,
|
||||||
|
private NodeInterface $subSelector,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSelector(): NodeInterface
|
||||||
|
{
|
||||||
|
return $this->selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubSelector(): NodeInterface
|
||||||
|
{
|
||||||
|
return $this->subSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpecificity(): Specificity
|
||||||
|
{
|
||||||
|
return $this->selector->getSpecificity()->plus($this->subSelector->getSpecificity());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return \sprintf('%s[%s:not(%s)]', $this->getNodeName(), $this->selector, $this->subSelector);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user