This commit is contained in:
2026-01-11 01:54:38 +01:00
parent aeea724854
commit af865bc6e7
5 changed files with 220 additions and 9 deletions

View File

@@ -3,10 +3,38 @@ 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'; };
const normalizeApiName=(v='')=>{
return String(v)
.trim()
.toLowerCase()
.replace(/\s+/g,'-')
.replace(/[^a-z0-9_-]+/g,'-')
.replace(/-+/g,'-')
.replace(/^-|-$/g,'');
};
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);
let apiName = null;
let apiTouched = false;
if(tab==='templates'){
apiName=document.createElement('input');
apiName.type='text';
apiName.required=true;
apiName.placeholder='API Name* (ohne Leerzeichen)';
apiName.className='w-full border rounded-lg px-3 py-2';
apiName.id='f-api-name';
fields.appendChild(apiName);
apiName.addEventListener('input', ()=>{
apiTouched = true;
const next = normalizeApiName(apiName.value);
if (next !== apiName.value) apiName.value = next;
});
name.addEventListener('input', ()=>{
if (!apiTouched) apiName.value = normalizeApiName(name.value);
});
}
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');
@@ -15,6 +43,10 @@ export function initCreate(){
form.onsubmit=async(e)=>{ e.preventDefault();
const payload={ name:(document.getElementById('f-name')?.value||'').trim() }; if(!payload.name) return;
if(tab==='templates') {
payload.api_name=(document.getElementById('f-api-name')?.value||'').trim();
if(!payload.api_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;

View File

@@ -23,6 +23,16 @@ function esc(s=''){
.replace(/'/g,'&#39;');
}
function normalizeApiName(v=''){
return String(v)
.trim()
.toLowerCase()
.replace(/\s+/g,'-')
.replace(/[^a-z0-9_-]+/g,'-')
.replace(/-+/g,'-')
.replace(/^-|-$/g,'');
}
async function openSnippetEditor(id){
const dlg = document.getElementById('editSnippetDialog');
const form = document.getElementById('editSnippetForm');
@@ -67,6 +77,61 @@ async function openSnippetEditor(id){
dlg && dlg.showModal();
}
async function openTemplateEditor(id){
const dlg = document.getElementById('editTemplateDialog');
const form = document.getElementById('editTemplateForm');
const inpName = document.getElementById('edit_tpl_name');
const inpApiName = document.getElementById('edit_tpl_api_name');
const apiWarn = document.getElementById('edit_tpl_api_warn');
const btnCancel = document.getElementById('editTemplateCancel');
let resp = {};
try { resp = await apiGet('templates', id) || {}; } catch(e){}
const row = resp?.item || resp?.data || resp || {};
const initialApi = row.api_name || '';
if (inpName) inpName.value = row.name || '';
if (inpApiName) inpApiName.value = initialApi;
if (apiWarn) apiWarn.classList.add('hidden');
const onApiInput = () => {
if (!inpApiName) return;
const next = normalizeApiName(inpApiName.value);
if (next !== inpApiName.value) inpApiName.value = next;
if (apiWarn) {
apiWarn.classList.toggle('hidden', inpApiName.value.trim() === initialApi);
}
};
function cleanup(){
form && form.removeEventListener('submit', onSubmit);
btnCancel && (btnCancel.onclick = null);
inpApiName && inpApiName.removeEventListener('input', onApiInput);
}
async function onSubmit(ev){
ev.preventDefault();
try{
const res = await apiUpdate('templates', id, {
name: inpName ? inpName.value : '',
api_name: inpApiName ? inpApiName.value : ''
});
toast(res && res.ok ? 'Template gespeichert' : 'Speichern fehlgeschlagen', !!(res && res.ok));
dlg && dlg.close();
cleanup();
loadList('templates');
}catch(e){
toast('Speichern fehlgeschlagen', false);
}
}
inpApiName && inpApiName.addEventListener('input', onApiInput);
form && form.addEventListener('submit', onSubmit, { once:false });
btnCancel && (btnCancel.onclick = () => { dlg && dlg.close(); cleanup(); });
dlg && dlg.showModal();
}
export async function loadList(resource){
const el=document.getElementById(`view-${resource}`); if(!el) return;
@@ -91,12 +156,21 @@ export async function loadList(resource){
list.innerHTML=data.map(item=>{
const name = esc(item.name||'');
const apiName = resource==='templates' ? esc(item.api_name||'') : '';
const apiLine = (resource==='templates' && apiName) ? `<div class='text-xs text-slate-500'>API: ${apiName}</div>` : '';
const nameCell = `<div class='min-w-48'>
<div class='font-medium truncate' title="${name}">${name || '(ohne Name)'}</div>
${apiLine}
</div>`;
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 editTplBtn = (resource==='templates')
? `<button class='btn' data-edit='templates:${item.id}'>Bearbeiten</button>` : '';
const testBtn = resource==='templates'
? `<button class='btn' data-test='${item.id}' data-name='${name}'>Testversand</button>` : '';
@@ -104,10 +178,10 @@ export async function loadList(resource){
const delBtn = `<button class='btn btn-danger' data-del='${resource}:${item.id}' data-name='${name}'>Löschen</button>`;
return `<div class='p-3 flex items-center gap-3'>
<div class='min-w-48 font-medium truncate' title="${name}">${name || '(ohne Name)'}</div>
${nameCell}
<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, testBtn, prevBtn, delBtn].filter(Boolean).join('')}</div>
<div class='ms-auto flex gap-2'>${[openBtn, editBtn, editTplBtn, testBtn, prevBtn, delBtn].filter(Boolean).join('')}</div>
</div>`;
}).join('');
@@ -138,8 +212,9 @@ export async function loadList(resource){
// edit snippet
list.querySelectorAll('[data-edit]').forEach(b=>b.addEventListener('click', async ()=>{
const [, id] = b.dataset.edit.split(':');
await openSnippetEditor(id);
const [res, id] = b.dataset.edit.split(':');
if (res === 'snippets') await openSnippetEditor(id);
if (res === 'templates') await openTemplateEditor(id);
}));
// preview

View File

@@ -130,6 +130,28 @@ require __DIR__ . '/../partials/structure/layout_start.php';
</form>
</dialog>
<!-- Edit Template Dialog -->
<dialog id="editTemplateDialog" class="rounded-2xl p-0 w-[700px]">
<form id="editTemplateForm" method="dialog" class="p-4 bg-white rounded-2xl">
<h3 class="text-lg font-semibold mb-2">Template bearbeiten</h3>
<div class="space-y-3">
<label class="block">
<span class="text-sm text-slate-600">Name</span>
<input id="edit_tpl_name" type="text" class="w-full border rounded-lg px-3 py-2" />
</label>
<label class="block">
<span class="text-sm text-slate-600">API Name (ohne Leerzeichen)</span>
<input id="edit_tpl_api_name" type="text" class="w-full border rounded-lg px-3 py-2" />
<p id="edit_tpl_api_warn" class="text-xs text-amber-700 mt-1 hidden">Warnung: Das Ändern des API-Namens kann bestehende API-Integrationen brechen.</p>
</label>
</div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" id="editTemplateCancel" class="btn">Abbrechen</button>
<button type="submit" id="editTemplateSave" class="btn">Speichern</button>
</div>
</form>
</dialog>
<div id="toast-root"></div>
<?php
tpl_add_script(app_asset_url('/assets/js/toast.js'));

View File

@@ -219,6 +219,7 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_templates` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`customer_id` int(10) unsigned NOT NULL,
`name` varchar(255) NOT NULL,
`api_name` varchar(190) NOT NULL,
`json_content` mediumtext DEFAULT NULL,
`html` mediumtext DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
@@ -226,7 +227,8 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_templates` (
PRIMARY KEY (`id`),
KEY `idx_tpl_customer` (`customer_id`),
KEY `idx_tpl_updated` (`updated_at`),
KEY `idx_tpl_name` (`name`)
KEY `idx_tpl_name` (`name`),
UNIQUE KEY `uidx_tpl_customer_api_name` (`customer_id`, `api_name`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Tabelle: emailtemplate_template_items

View File

@@ -312,6 +312,37 @@ class ApiKernel
return $json === false ? '' : $json;
}
private function normalizeApiName(string $value): string
{
$value = trim($value);
$value = preg_replace('/\s+/', '-', $value);
$value = preg_replace('/[^A-Za-z0-9_-]+/', '-', $value);
$value = preg_replace('/-+/', '-', $value);
return trim($value, '-');
}
private function assertTemplateApiNameUnique(
string $table,
string $apiCol,
string $idCol,
int $customerId,
string $apiName,
?int $excludeId
): void {
$sql = "SELECT COUNT(*) FROM `$table` WHERE `$apiCol` = :api AND `customer_id` = :cid";
$params = [':api' => $apiName, ':cid' => $customerId];
if ($excludeId !== null && $excludeId > 0) {
$sql .= " AND `$idCol` <> :id";
$params[':id'] = $excludeId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$count = (int)$stmt->fetchColumn();
if ($count > 0) {
$this->fail('api_name already exists', ['api_name' => $apiName], 409);
}
}
// =================================================================
// 🚀 CRUD HANDLER METHODEN
@@ -330,6 +361,8 @@ class ApiKernel
$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']);
$apiCol = null;
$apiCol = ($kind === 'templates') ? $this->firstExisting($allCols, ['api_name']) : null;
$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));
@@ -378,6 +411,7 @@ class ApiKernel
'id' => $r[$idCol] ?? null,
'name' => $r[$nameCol] ?? null,
];
if ($apiCol && isset($r[$apiCol])) $item['api_name'] = $r[$apiCol];
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];
@@ -481,6 +515,27 @@ class ApiKernel
$blockId = $this->val($this->in, ['block_id', 'blk_id'], null);
$data = [$nameCol => $name];
if ($kind === 'templates') {
$apiCol = $this->firstExisting($allCols, ['api_name']);
if ($apiCol) {
$apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null);
if ($apiRaw === null || trim((string)$apiRaw) === '') {
$apiName = $this->normalizeApiName($name);
if ($apiName === '') {
$this->fail('api_name required', null, 422);
}
} else {
$apiName = trim((string)$apiRaw);
if (preg_match('/\s/', $apiName)) {
$this->fail('api_name must not contain spaces', null, 422);
}
}
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$this->assertTemplateApiNameUnique($t, $apiCol, $idCol, $customerId, $apiName, null);
$data[$apiCol] = $apiName;
}
}
if ($desc !== null && $descCol) $data[$descCol] = $desc;
if ($cat !== null && $catCol) $data[$catCol] = $cat;
@@ -538,6 +593,9 @@ class ApiKernel
$newId = $this->pdo->lastInsertId();
$out = ['id' => $newId, 'name' => $name];
if (!empty($apiCol) && isset($data[$apiCol])) {
$out['api_name'] = $data[$apiCol];
}
if ($desc !== null) $out['desc'] = $desc;
if ($cat !== null) $out['category'] = $cat;
$this->respond(['ok' => true, 'kind' => $kind, 'id' => $newId, 'item' => $out, 'data' => $out]);
@@ -556,6 +614,7 @@ class ApiKernel
$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']);
$apiCol = ($kind === 'templates') ? $this->firstExisting($allCols, ['api_name']) : null;
$id = $this->pullId($this->in);
if ($id === null || $id === '') $this->fail('id required', null, 422);
@@ -576,6 +635,20 @@ class ApiKernel
if ($name !== null) $data[$nameCol] = (string)$name;
if ($desc !== null && $descCol) $data[$descCol] = (string)$desc;
if ($cat !== null && $catCol) $data[$catCol] = (string)$cat;
if ($apiCol) {
$apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null);
if ($apiRaw !== null) {
$apiName = trim((string)$apiRaw);
if ($apiName === '') $this->fail('api_name required', null, 422);
if (preg_match('/\s/', $apiName)) {
$this->fail('api_name must not contain spaces', null, 422);
}
$customerId = (int)($auth['customer_id'] ?? 0);
if ($customerId <= 0) $this->fail('Customer context missing', null, 500);
$this->assertTemplateApiNameUnique($t, $apiCol, $idCol, $customerId, $apiName, (int)$id);
$data[$apiCol] = $apiName;
}
}
$htmlDbCol = $this->firstExisting($allCols, ($kind === 'snippets' ? ['content'] : ['html', 'body', 'markup']));
$jsonDbCol = $this->firstExisting($allCols, ['json_content']);
@@ -754,8 +827,9 @@ class ApiKernel
[$idCol, $allCols] = $this->resolveIdCol('templates');
$nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol);
$apiCol = $this->firstExisting($allCols, ['api_name']);
$templateKey = $this->val($this->in, ['template', 'template_id', 'id', 'name'], '');
$templateKey = $this->val($this->in, ['api_name', 'template', 'template_id', 'id', 'name'], '');
$templateId = is_numeric($templateKey) ? (int)$templateKey : null;
$where = "WHERE `customer_id` = :cid ";
@@ -768,9 +842,14 @@ class ApiKernel
if ($name === '') {
$this->fail('template required', null, 422);
}
if ($apiCol) {
$where .= "AND `$apiCol` = :name ";
$params[':name'] = $name;
} else {
$where .= "AND `$nameCol` = :name ";
$params[':name'] = $name;
}
}
$sql = "SELECT * FROM `$templatesTable` $where LIMIT 1";
$stmt = $this->pdo->prepare($sql);
@@ -800,6 +879,7 @@ class ApiKernel
'ok' => true,
'template_id' => (int)($tpl[$idCol] ?? 0),
'name' => $tpl[$nameCol] ?? null,
'api_name' => $apiCol ? ($tpl[$apiCol] ?? null) : null,
'html' => $html,
]);
}