up
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -23,6 +23,16 @@ function esc(s=''){
|
||||
.replace(/'/g,''');
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user