diff --git a/public/assets/js/ui-create.js b/public/assets/js/ui-create.js
index eed1a7c..50c2c70 100644
--- a/public/assets/js/ui-create.js
+++ b/public/assets/js/ui-create.js
@@ -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=``; 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;
diff --git a/public/assets/js/ui-list.js b/public/assets/js/ui-list.js
index 8fce648..8204516 100644
--- a/public/assets/js/ui-list.js
+++ b/public/assets/js/ui-list.js
@@ -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) ? `
API: ${apiName}
` : '';
+ const nameCell = `
+
${name || '(ohne Name)'}
+ ${apiLine}
+
`;
const openBtn = (['templates','sections','blocks'].includes(resource))
? `` : '';
const editBtn = (resource==='snippets')
? `` : '';
+ const editTplBtn = (resource==='templates')
+ ? `` : '';
+
const testBtn = resource==='templates'
? `` : '';
@@ -104,10 +178,10 @@ export async function loadList(resource){
const delBtn = ``;
return `
-
${name || '(ohne Name)'}
+ ${nameCell}
#${item.id}
${parentBadge(resource,item)}
-
${[openBtn, editBtn, testBtn, prevBtn, delBtn].filter(Boolean).join('')}
+
${[openBtn, editBtn, editTplBtn, testBtn, prevBtn, delBtn].filter(Boolean).join('')}
`;
}).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
diff --git a/public/index.php b/public/index.php
index 4a51094..98c5fd7 100644
--- a/public/index.php
+++ b/public/index.php
@@ -130,6 +130,28 @@ require __DIR__ . '/../partials/structure/layout_start.php';
+
+
+
$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,8 +842,13 @@ class ApiKernel
if ($name === '') {
$this->fail('template required', null, 422);
}
- $where .= "AND `$nameCol` = :name ";
- $params[':name'] = $name;
+ if ($apiCol) {
+ $where .= "AND `$apiCol` = :name ";
+ $params[':name'] = $name;
+ } else {
+ $where .= "AND `$nameCol` = :name ";
+ $params[':name'] = $name;
+ }
}
$sql = "SELECT * FROM `$templatesTable` $where LIMIT 1";
@@ -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,
]);
}