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'; + + +
+

Template bearbeiten

+
+ + +
+
+ + +
+
+
+
$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, ]); }