This commit is contained in:
2025-12-04 22:33:05 +01:00
parent 316175e158
commit 9dee06cdd6
145 changed files with 16865 additions and 88 deletions

128
README.md
View File

@@ -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. *NULLfä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 EMailEditor ö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
View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"name": "ssh-w020abd8/staging",
"require": {
"tijsverkoyen/css-to-inline-styles": "^2.3"
}
}

139
composer.lock generated Normal file
View 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
View File

@@ -0,0 +1,4 @@
<?php
define('APP_DOMAIN_NAME', 'usbcheck.it');
define('APP_PREFIX', 'usbcheck');

121
config/fileload.php Normal file
View 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
View 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. &amp;)
$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
View 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
View 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
View 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
View 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
View 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
View 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
View 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];
}
}

View 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
View 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);
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

15
public/api.php Normal file
View 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
View 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
View 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
View 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}

View 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
View 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
View 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);

View 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 = {}));

View 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 = {}));

View 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');
}
})();

View 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');
}
})();

View 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 = {}));

View File

@@ -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 = {}));

View 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 = {}));

View 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 = {}));

View 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);

View 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);

View 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);

View 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 = {}));

View 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 = {}));

View 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);

View 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 = {}));

View 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 = {}));

View 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 = {}));

View 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;">&nbsp;</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);

View 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
View 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 };
})();

View 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);
}

View 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());
}

View 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
View File

@@ -0,0 +1,170 @@
import { apiList, apiGet, apiDelete, apiUpdate, toast } from './api.js';
function esc(s=''){
return String(s)
.replace(/&/g,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.replace(/"/g,'&quot;')
.replace(/'/g,'&#39;');
}
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&nbsp;#${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&nbsp;#${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&nbsp;#${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;
}

View 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);
}));
}

View 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);
}
};
})();

View 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;
};
})();

View 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
View 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?');
}
})();

View 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>

View 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
View 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
View 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>

View 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
View 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>

View File

@@ -0,0 +1 @@
Place vendor files here (grapesjs, grapesjs-preset-newsletter).

File diff suppressed because one or more lines are too long

1
public/vendor/grapesjs/.keep vendored Normal file
View File

@@ -0,0 +1 @@
Place vendor files here (grapesjs, grapesjs-preset-newsletter).

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

File diff suppressed because one or more lines are too long

3
public/version.php Normal file
View File

@@ -0,0 +1,3 @@
<?php
echo phpinfo();
?>

177
schema.sql Normal file
View 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
View 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
View 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
View 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
View 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
View 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',
);

View 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
View 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
View 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
View 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
View 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
View 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
View 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)
);
}

View 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

View 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);
}
}

View 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
{
}

View 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
{
}

View 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
{
}

View 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
{
}

View 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
View 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.

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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.']' : '');
}
}

View 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);
}
}

View 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));
}
}

View 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