diff --git a/config/emailtemplate.conf.php b/config/emailtemplate.conf.php index 6b8edb4..daf459e 100644 --- a/config/emailtemplate.conf.php +++ b/config/emailtemplate.conf.php @@ -110,6 +110,8 @@ $tablesDefaults = [ 'sections' => 'emailtemplate_sections', 'blocks' => 'emailtemplate_blocks', 'snippets' => 'emailtemplate_snippets', + 'content_items' => 'emailtemplate_content_items', + 'content_sections' => 'emailtemplate_content_sections', ]; $tables = array_replace($tablesDefaults, $overrides['tables'] ?? []); @@ -127,6 +129,8 @@ $columnsDefaults = [ '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'], + 'content' => ['id'=>'id','name'=>'name','cat'=>'category','upd'=>'updated_at'], + 'sections_config' => ['id'=>'id','name'=>'name','cat'=>null,'upd'=>'updated_at'], ]; $columns = array_replace_recursive($columnsDefaults, $overrides['columns'] ?? []); diff --git a/partials/landingpage/accountsetup/settings.php b/partials/landingpage/accountsetup/settings.php index aec388a..467b4b9 100644 --- a/partials/landingpage/accountsetup/settings.php +++ b/partials/landingpage/accountsetup/settings.php @@ -83,6 +83,20 @@ require dirname(__DIR__) . '/../structure/layout_start.php'; + + + + Sections verwalten + Die Sortierung steuert, welche Inhalte in anderen Sections eingebunden werden dürfen. + + + + + Section hinzufügen + + + + @@ -253,6 +267,21 @@ require dirname(__DIR__) . '/../structure/layout_start.php'; + + + + Section löschen + Abbrechen + + + Inhalte verschieben nach + + + Löschen + + + + Beispiel: Mapping einer Config-Datei diff --git a/public/assets/js/api.js b/public/assets/js/api.js index a6519c1..c96fc30 100644 --- a/public/assets/js/api.js +++ b/public/assets/js/api.js @@ -81,11 +81,7 @@ export async function apiAction( * 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 }); + const r = await apiAction(`${res}.list`, { method: "GET", data: query }); return r?.items ?? []; } diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 53e2729..35aab14 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -74,29 +74,9 @@ async function handleEditorMessages(ev) { 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') }) - }); - } + return; } catch (e) { - console.error('refs sync failed', e); + console.error('refs sync skipped', e); } } diff --git a/public/assets/js/bridge/blocks-api.js b/public/assets/js/bridge/blocks-api.js index 689453b..41c6bf6 100644 --- a/public/assets/js/bridge/blocks-api.js +++ b/public/assets/js/bridge/blocks-api.js @@ -18,6 +18,7 @@ const requestedMode = (qs.get('mode') || 'templates').toLowerCase(); B.EDITOR_MODE = (B.EDITOR_MODE || requestedMode.toUpperCase()); const EDITOR_MODE = (B.EDITOR_MODE || 'TEMPLATES').toLowerCase(); + const SECTION_ID = Number(qs.get('section_id') || B.CURRENT_SECTION_ID || 0); log(`START: SKRIPT-AUSFÜHRUNG GESTARTET. Editor Modus: ${EDITOR_MODE}.`, '#DC143C'); const TARGET_CAT_ID = 'custom'; @@ -479,7 +480,7 @@ jsonProjectDataRaw = ''; } - const resource = EDITOR_MODE; + const resource = 'content'; const action = `${resource}.update`; const debugSave = (() => { try { @@ -501,6 +502,9 @@ // 🚨 KRITISCH: Server erwartet das Feld 'json' json: jsonProjectDataRaw, }; + if (SECTION_ID) { + dataToSend.section_id = SECTION_ID; + } if (debugSave) { dataToSend.debug = 1; console.log('[ET DEBUG] save-data payload', { diff --git a/public/assets/js/bridge/library-api.js b/public/assets/js/bridge/library-api.js index 3998f8e..60076b1 100644 --- a/public/assets/js/bridge/library-api.js +++ b/public/assets/js/bridge/library-api.js @@ -61,28 +61,32 @@ }; const shouldLoad = (resource) => { + if (Array.isArray(B.ALLOWED_SECTION_SLUGS) && B.ALLOWED_SECTION_SLUGS.length) { + return B.ALLOWED_SECTION_SLUGS.includes(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'; - + return true; default: - log('MODE WARN', `Unbekannter Editor Modus '${mode}' festgestellt.`, 'orange', 'warn'); - return resource === 'snippets'; + return true; } }; + const fetchSectionsConfig = async () => { + const rows = await fetchData('sections_config', 'list'); + return Array.isArray(rows) ? rows : []; + }; + + const resolveAllowedSections = async () => { + const sections = await fetchSectionsConfig(); + if (!sections.length) return []; + const modeSlug = String(B.EDITOR_MODE || '').toLowerCase(); + const current = sections.find(s => String(s.slug || '').toLowerCase() === modeSlug); + if (!current) return sections; + return sections.filter(s => Number(s.position) > Number(current.position)); + }; + const fetchData = (resource, action='list', params = {}) => { // ... (Rest der fetchData-Funktion bleibt unverändert, nutzt aber die korrigierte buildApiUrl) @@ -151,13 +155,13 @@ // --- Exportierte Core-Funktionen (jetzt generisch) --- // NEU: Generische Fetch-Funktion für jeden Ressourcentyp ('kind') - B.fetchResource = (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'); @@ -173,37 +177,40 @@ }; // 🚀 Zentrale Funktion zum Laden und Registrieren der Blöcke - B.loadAndRegisterApiBlocks = (editor) => { + 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 }))) - ); + const loadDynamic = async () => { + const sections = await resolveAllowedSections(); + B.ALLOWED_SECTION_SLUGS = sections.map(s => String(s.slug || '').toLowerCase()); + if (!sections.length) return []; + const promises = sections.map(section => + fetchData('content', 'list', { section_id: section.id }) + .then(items => (Array.isArray(items) ? items : []).map(i => ({ + ...i, + kind: String(section.slug || '').toLowerCase(), + section_name: section.name || '', + }))) + ); + const results = await Promise.all(promises); + return results.flat(); + }; - log('API START', `Starte Promise.all für API-Abruf der Blöcke/Sektionen (${resourceKindsToLoad.join(', ')})...`, '#1E90FF'); + const startLoad = (B.USE_DYNAMIC_SECTIONS ? loadDynamic() : Promise.resolve([])); - Promise.all(fetchPromises) - .then(results => { - const apiItems = results.flat().filter(item => item && item.id); + log('API START', 'Starte API-Abruf für dynamische Sections...', '#1E90FF'); + + startLoad + .then(apiItems => { + const filtered = Array.isArray(apiItems) ? apiItems.filter(item => item && item.id) : []; - log(`API SUCCESS`, `${apiItems.length} Elemente gefunden.`, '#9400D3'); - logApiData(apiItems); + log(`API SUCCESS`, `${filtered.length} Elemente gefunden.`, '#9400D3'); + logApiData(filtered); - if (apiItems.length === 0) { + if (filtered.length === 0) { log('NO DATA', 'Keine API-Daten gefunden.', 'orange', 'warn', true); } else { - apiItems.forEach(item => { + filtered.forEach(item => { const blockId = `lib-${item.kind}-${item.id}`; const label = item.name || item.label || 'Unbenannter Block'; const itemKindUpper = item.kind.toUpperCase(); diff --git a/public/assets/js/ui-create.js b/public/assets/js/ui-create.js index f08fa82..448147e 100644 --- a/public/assets/js/ui-create.js +++ b/public/assets/js/ui-create.js @@ -1,8 +1,7 @@ -import { apiList, apiCreate, toast } from './api.js'; +import { apiAction, 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'; }; const normalizeApiName=(v='')=>{ return String(v) .trim() @@ -14,7 +13,13 @@ export function initCreate(){ }; btn.onclick = async ()=>{ - fields.innerHTML=''; const tab=curTab(); + const section = window.__activeSection || null; + if (!section) { + toast('Bitte zuerst eine Section auswählen', false); + return; + } + const isTemplate = Number(section.is_template) === 1; + fields.innerHTML=''; 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; @@ -31,7 +36,7 @@ export function initCreate(){ fields.appendChild(editorSelect); }; - if(tab==='templates'){ + if(isTemplate){ apiName=document.createElement('input'); apiName.type='text'; apiName.required=true; @@ -49,25 +54,27 @@ export function initCreate(){ 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=`(ohne ${label}-Zuordnung)`; 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'); - if (tab !== 'templates') addEditorSelect(); - hint.textContent=`Neues ${tab} anlegen`; dlg.showModal(); + if (!isTemplate) addEditorSelect(); + hint.textContent=`Neues Element in ${section.name}`; dlg.showModal(); form.onsubmit=async(e)=>{ e.preventDefault(); const payload={ name:(document.getElementById('f-name')?.value||'').trim() }; if(!payload.name) return; - if(tab==='templates') { + if(isTemplate) { payload.api_name=(document.getElementById('f-api-name')?.value||'').trim(); if(!payload.api_name) return; } payload.editor_type=(document.getElementById('f-editor-type')?.value||'grapesjs'); - 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); } + payload.html=''; + payload.section_id = section.id; + const r=await apiAction('content.create', { method:'POST', data: payload }); + if(r&&r.id){ + dlg.close(); + toast('Erstellt',true); + window.loadList && window.loadList(section); + } else { + toast('Erstellen fehlgeschlagen',false,{duration:3000}); + console.error('Create failed',r); + } }; }; const cancel=document.getElementById('createCancel'); cancel && (cancel.onclick=()=>dlg.close()); diff --git a/public/assets/js/ui-editor.js b/public/assets/js/ui-editor.js index 51696bc..73660cf 100644 --- a/public/assets/js/ui-editor.js +++ b/public/assets/js/ui-editor.js @@ -1,7 +1,7 @@ /* /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'; +import { apiUpdate, toast, apiAction } from './api.js'; import { initCraftEditor } from './craft-editor.js'; export function initEditor() { @@ -29,7 +29,7 @@ export function initEditor() { const prevFrame = document.getElementById('previewFrame'); const btnPrevClose = document.getElementById('btn-close-preview'); - let current = null; // { resource, id, name } + let current = null; // { resource, id, name, section } let bridgeListener = null; let reqToken = 0; // steigender Token pro Öffnen -> ignoriert verspätete Events let senderOptions = []; @@ -40,12 +40,12 @@ export function initEditor() { 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 activeMode() { + const activeSection = window.__activeSection || current?.section || null; + return (activeSection?.slug) || (current?.resource) || 'emailtemplate'; + } - function setSendContext(id, name = '') { + function setSendContext(id, name = '') { if (sendDlg) { if (id) { sendDlg.dataset.templateId = String(id); @@ -59,12 +59,12 @@ export function initEditor() { sendInfo.textContent = 'Kein Template ausgewählt.'; sendInfo.classList.add('text-rose-600'); } else { - const label = name ? `${name} – Template #${id}` : `Template #${id}`; - sendInfo.textContent = label; - sendInfo.classList.remove('text-rose-600'); - } - } - } + const label = name ? `${name} – Template #${id}` : `Template #${id}`; + sendInfo.textContent = label; + sendInfo.classList.remove('text-rose-600'); + } + } + } function writeHtmlToFrame(html) { iframe.srcdoc = ` @@ -210,67 +210,13 @@ export function initEditor() { 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 || '' })); - } + async function buildRefLibForContext() { + return { sections: [], blocks: [] }; + } + + async function buildSnippetsForContext() { + return []; + } async function loadSenderOptions(force = false) { if (!sendSender) return; @@ -338,19 +284,22 @@ export function initEditor() { } // ---------- Öffnen ---------- - async function open(item, resource) { + async function open(item, resource, sectionOverride) { + const section = item?.section || sectionOverride || window.__activeSection || null; current = { - resource: String(resource || activeMode() || 'templates').toLowerCase(), + resource: 'content', id: Number(item?.id || 0), - name: item?.name || '' + name: item?.name || '', + section: section, }; if (!current.id) return err('Ungültige ID'); + if (!current.section) return err('Section nicht gefunden'); // globaler Kontext window.__currentItemId = current.id; - window.__currentEditorCtx = { id: current.id, mode: current.resource }; - setSendContext(current.id, current.name); - if (btnTest) btnTest.classList.toggle('hidden', current.resource !== 'templates'); + window.__currentEditorCtx = { id: current.id, mode: current.section.slug, section: current.section }; + setSendContext(current.section?.is_template ? current.id : 0, current.name); + if (btnTest) btnTest.classList.toggle('hidden', !current.section?.is_template); // Neuen Token erzeugen & alten Listener entfernen reqToken++; @@ -374,7 +323,7 @@ export function initEditor() { await Promise.all([ (async() => { try { - const row = await apiGet(current.resource, current.id); + const row = await apiAction('content.get', { method: 'GET', data: { id: current.id, section_id: current.section.id } }); const rawContent = row?.content ?? row?.item?.content ?? ''; const trimmed = String(rawContent || '').trim(); const looksJson = trimmed.startsWith('{') || trimmed.startsWith('['); @@ -403,10 +352,11 @@ export function initEditor() { if (!looksCraftSerialized(craftJson) && craftEditor?.serializeFromHtml) { const seed = craftEditor.serializeFromHtml(craftHtml); try { - await apiUpdate(current.resource, current.id, { + await apiUpdate('content', current.id, { editor_type: 'craftjs', html: craftHtml, - craft_json: seed + craft_json: seed, + section_id: current.section.id, }); } catch {} } @@ -436,7 +386,7 @@ export function initEditor() { ok('Gespeichert'); try { if (typeof window.reloadActiveList === 'function') window.reloadActiveList(); - else if (typeof window.__reloadList === 'function') window.__reloadList(current.resource); + else if (typeof window.__reloadList === 'function') window.__reloadList(current.section); } catch {} return; } @@ -444,7 +394,7 @@ export function initEditor() { // 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, + mode: current.section.slug, html: fresh, snippets, ref: { @@ -462,7 +412,7 @@ export function initEditor() { // Fallback, falls kein Ready ankommt setTimeout(() => { pushInitialHtmlToEditor({ - mode: current.resource, + mode: current.section.slug, html: fresh, snippets, ref: { @@ -474,10 +424,10 @@ export function initEditor() { json: jsonState }); }, 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()}`; + iframe.src = `editor/editor-core.php?mode=${encodeURIComponent(current.section.slug)}&id=${current.id}§ion_id=${current.section.id}&t=${Date.now()}`; dlg?.showModal?.(); } @@ -492,8 +442,8 @@ export function initEditor() { const craftJson = craftEditor && craftEditor.getCraftJson ? craftEditor.getCraftJson() : JSON.stringify({ html }); - const payload = { html, craft_json: craftJson, editor_type: 'craftjs' }; - const res = await apiUpdate(current.resource, current.id, payload); + const payload = { html, craft_json: craftJson, editor_type: 'craftjs', section_id: current.section.id }; + const res = await apiUpdate('content', current.id, payload); if (res?.ok) ok('Gespeichert'); else err(res?.error || 'Speichern fehlgeschlagen'); return res?.ok; @@ -525,7 +475,11 @@ export function initEditor() { } async function openSend(ctx = null) { - const ctxId = ctx?.id ? Number(ctx.id) : (window.__currentItemId || current?.id || 0); + if (!current?.section?.is_template) { + err('Kein Template geladen'); + return; + } + const ctxId = ctx?.id ? Number(ctx.id) : (window.__currentItemId || current?.id || 0); if (!ctxId) { err('Kein Template geladen'); return; @@ -600,10 +554,11 @@ export function initEditor() { const craftJson = craftEditor && craftEditor.serializeFromHtml ? craftEditor.serializeFromHtml(html) : JSON.stringify({ html }); - const res = await apiUpdate(current.resource, current.id, { + const res = await apiUpdate('content', current.id, { editor_type: 'craftjs', html, - craft_json: craftJson + craft_json: craftJson, + section_id: current.section.id, }); if (!res?.ok) { err(res?.error || 'Editorwechsel fehlgeschlagen'); @@ -618,9 +573,10 @@ export function initEditor() { } if (currentEditorType === 'craftjs' && target === 'grapesjs') { const html = craftEditor ? craftEditor.getContent() : ''; - const res = await apiUpdate(current.resource, current.id, { + const res = await apiUpdate('content', current.id, { editor_type: 'grapesjs', - html + html, + section_id: current.section.id, }); if (!res?.ok) { err(res?.error || 'Editorwechsel fehlgeschlagen'); @@ -628,7 +584,7 @@ export function initEditor() { return; } ok('Editor gewechselt'); - await open({ id: current.id, name: current.name }, current.resource); + await open({ id: current.id, name: current.name, section: current.section }, 'content', current.section); } } @@ -643,17 +599,21 @@ export function initEditor() { sendForm && (sendForm.onsubmit = doSend); editorSelect && (editorSelect.onchange = () => switchEditor(editorSelect.value)); - window.AdminTestSend = window.AdminTestSend || {}; - window.AdminTestSend.open = (opts = {}) => { - const targetId = Number(opts.id || window.__currentItemId || 0); - if (!targetId) { - err('Testversand: Keine ID vorhanden'); - return; - } - window.__currentItemId = targetId; - setSendContext(targetId, opts.name || ''); - openSend({ id: targetId, name: opts.name || '' }); - }; + window.AdminTestSend = window.AdminTestSend || {}; + window.AdminTestSend.open = (opts = {}) => { + const targetId = Number(opts.id || window.__currentItemId || 0); + if (!targetId) { + err('Testversand: Keine ID vorhanden'); + return; + } + if (!current?.section?.is_template) { + err('Kein Template geladen'); + return; + } + window.__currentItemId = targetId; + setSendContext(targetId, opts.name || ''); + openSend({ id: targetId, name: opts.name || '' }); + }; // Public API window.EditorUI = { open, save, close, clear: clearEditor, preview: openPreview }; diff --git a/public/assets/js/ui-list.js b/public/assets/js/ui-list.js index 4b55ab4..aac5673 100644 --- a/public/assets/js/ui-list.js +++ b/public/assets/js/ui-list.js @@ -1,57 +1,53 @@ -import { apiList, apiGet, apiDelete, apiUpdate, apiAction, toast } from './api.js'; +import { apiAction, apiUpdate, toast } from './api.js'; -function formatUsage(usage){ - if (!usage || !usage.total) return ''; - const parts=[]; - if (usage.templates) parts.push(`${usage.templates} Template${usage.templates!==1?'s':''}`); - if (usage.sections) parts.push(`${usage.sections} Section${usage.sections!==1?'s':''}`); - if (usage.blocks) parts.push(`${usage.blocks} Block${usage.blocks!==1?'s':''}`); - if (usage.snippets) parts.push(`${usage.snippets} Snippet${usage.snippets!==1?'s':''}`); - if (!parts.length) return ''; - return ` - Dieses Element wird aktuell verwendet in: ${parts.join(', ')}. - Das Löschen entfernt diese Referenzen. - `; -} - -function esc(s=''){ +function esc(s = '') { return String(s) - .replace(/&/g,'&') - .replace(//g,'>') - .replace(/"/g,'"') - .replace(/'/g,'''); + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } -function normalizeApiName(v=''){ +function normalizeApiName(v = '') { return String(v) .trim() .toLowerCase() - .replace(/\s+/g,'-') - .replace(/[^a-z0-9_-]+/g,'-') - .replace(/-+/g,'-') - .replace(/^-|-$/g,''); + .replace(/\s+/g, '-') + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); } -async function openSnippetEditor(id){ - let resp = {}; - try { resp = await apiGet('snippets', id) || {}; } catch(e){} - const row = resp?.item || resp?.data || resp || {}; - const name = row?.name || ''; +async function fetchContentList(sectionId) { + const res = await apiAction('content.list', { method: 'GET', data: { section_id: sectionId } }); + return Array.isArray(res?.items) ? res.items : []; +} + +async function fetchContentItem(id, sectionId) { + return await apiAction('content.get', { method: 'GET', data: { id, section_id: sectionId } }); +} + +async function openContentEditor(item, section) { + const id = Number(item?.id || 0); + const name = item?.name || ''; + if (!id) return; + const detail = await fetchContentItem(id, section.id).catch(() => ({})); + const html = detail?.html ?? detail?.item?.html ?? detail?.content ?? ''; + + window.__currentItemId = id; + window.__currentEditorCtx = { id, mode: section.slug, section }; if (window.EditorUI && typeof window.EditorUI.open === 'function') { - window.EditorUI.open({ id: Number(id), name }, 'snippets'); - return; + window.EditorUI.open({ id, name, html, section }, 'content'); + } else if (window.__openEditor) { + window.__openEditor({ resource: 'content', id, name, html, section }); + } else { + toast('Editor ist nicht initialisiert.', false); } - if (window.__openEditor) { - window.__openEditor({ resource: 'snippets', id: Number(id), name }); - return; - } - console.warn('Kein Editor-Entry-Point gefunden (EditorUI.open / __openEditor).'); - toast('Editor ist nicht initialisiert.', false); } -async function openTemplateEditor(id){ +async function openTemplateEditor(item, section) { const dlg = document.getElementById('editTemplateDialog'); const form = document.getElementById('editTemplateForm'); const inpName = document.getElementById('edit_tpl_name'); @@ -59,9 +55,8 @@ async function openTemplateEditor(id){ 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 detail = await fetchContentItem(item.id, section.id).catch(() => ({})); + const row = detail?.item || detail?.data || detail || {}; const initialApi = row.api_name || ''; if (inpName) inpName.value = row.name || ''; @@ -77,48 +72,61 @@ async function openTemplateEditor(id){ } }; - function cleanup(){ + function cleanup() { form && form.removeEventListener('submit', onSubmit); btnCancel && (btnCancel.onclick = null); inpApiName && inpApiName.removeEventListener('input', onApiInput); } - async function onSubmit(ev){ + async function onSubmit(ev) { ev.preventDefault(); - try{ - const res = await apiUpdate('templates', id, { + try { + const res = await apiUpdate('content', item.id, { name: inpName ? inpName.value : '', - api_name: inpApiName ? inpApiName.value : '' + api_name: inpApiName ? inpApiName.value : '', + section_id: section.id, }); toast(res && res.ok ? 'Template gespeichert' : 'Speichern fehlgeschlagen', !!(res && res.ok)); dlg && dlg.close(); cleanup(); - loadList('templates'); - }catch(e){ + if (typeof window.loadList === 'function') window.loadList(section); + } catch { toast('Speichern fehlgeschlagen', false); } } inpApiName && inpApiName.addEventListener('input', onApiInput); - form && form.addEventListener('submit', onSubmit, { once:false }); + 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; +export async function loadList(section) { + const el = document.getElementById('view-content'); + if (typeof section === 'string') { + const sections = window.__sectionsConfig || []; + section = sections.find(s => String(s.slug || '').toLowerCase() === section.toLowerCase()) + || sections.find(s => String(s.name || '').toLowerCase() === section.toLowerCase()) + || null; + } else if (typeof section === 'number') { + const sections = window.__sectionsConfig || []; + section = sections.find(s => Number(s.id) === section) || null; + } + section = section || window.__activeSection || null; + if (!el || !section) return; - const label = resource.charAt(0).toUpperCase()+resource.slice(1); - el.innerHTML=` + const label = section.name || 'Section'; + const isTemplate = Number(section.is_template) === 1; + el.innerHTML = ` - ${label} + ${esc(label)} - - × + + × - + Erstelldatum (aufsteigend) Name A–Z Name Z–A @@ -126,16 +134,22 @@ export async function loadList(resource){ - Lade …`; + Lade …`; - const data=await apiList(resource); - const list=el.querySelector(`#list-${resource}`); - const filterInput=el.querySelector(`#filter-${resource}`); - const filterReset=el.querySelector(`#filter-${resource}-reset`); - const sortSelect=el.querySelector(`#sort-${resource}`); + let data = []; + try { + data = await fetchContentList(section.id); + } catch (err) { + list.innerHTML = `${esc(err.message || 'Laden fehlgeschlagen')}`; + return; + } + const list = el.querySelector('#list-section'); + const filterInput = el.querySelector('#filter-section'); + const filterReset = el.querySelector('#filter-section-reset'); + const sortSelect = el.querySelector('#sort-section'); - if(!Array.isArray(data)||data.length===0){ - list.innerHTML=`Keine Einträge`; + if (!Array.isArray(data) || data.length === 0) { + list.innerHTML = `Keine Einträge`; return; } @@ -159,65 +173,48 @@ export async function loadList(resource){ return listCopy.sort(compareByName); } if (key === 'name_desc') { - return listCopy.sort((a,b)=>compareByName(b,a)); + return listCopy.sort((a, b) => compareByName(b, a)); } if (key === 'updated_desc') { - return listCopy.sort((a,b)=>parseDate(b.updated_at) - parseDate(a.updated_at)); + return listCopy.sort((a, b) => parseDate(b.updated_at) - parseDate(a.updated_at)); } - return listCopy.sort((a,b)=>parseDate(a.created_at) - parseDate(b.created_at)); + return listCopy.sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)); } function matchesQuery(item, query) { if (!query) return true; const q = query.toLowerCase(); const name = String(item?.name || '').toLowerCase(); - const api = resource === 'templates' ? String(item?.api_name || '').toLowerCase() : ''; + const api = isTemplate ? String(item?.api_name || '').toLowerCase() : ''; return name.includes(q) || (api && api.includes(q)); } - function parentBadge(r,it){ - if(r==='sections'&&it.template_id) return ` Template #${it.template_id}${it.template_name ? ' · '+esc(it.template_name) : ''}`; - if(r==='blocks'&&it.section_id) return ` Section #${it.section_id}${it.section_name ? ' · '+esc(it.section_name) : ''}`; - if(r==='snippets'&&it.block_id) return ` Block #${it.block_id}${it.block_name ? ' · '+esc(it.block_name) : ''}`; - return ' frei'; + function render(items) { + list.innerHTML = items.map(item => { + const name = esc(item.name || ''); + const apiName = isTemplate ? esc(item.api_name || '') : ''; + const apiLine = (isTemplate && apiName) ? `API: ${apiName}` : ''; + const nameCell = ` + ${name || '(ohne Name)'} + ${apiLine} + `; + const openBtn = `Im Editor öffnen`; + const editTplBtn = isTemplate ? `Bearbeiten` : ''; + const testBtn = isTemplate ? `Testversand` : ''; + const prevBtn = `Vorschau`; + const delBtn = `Löschen`; + + return ` + ${nameCell} + #${item.id} + ${[openBtn, editTplBtn, testBtn, prevBtn, delBtn].filter(Boolean).join('')} + `; + }).join(''); + + bindListHandlers(list); } - function render(items){ - list.innerHTML=items.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)) - ? `Im E-Mail-Editor öffnen` : ''; - - const editBtn = (resource==='snippets') - ? `Im Editor` : ''; - - const editTplBtn = (resource==='templates') - ? `Bearbeiten` : ''; - - const testBtn = resource==='templates' - ? `Testversand` : ''; - - const prevBtn = `Vorschau`; - const delBtn = `Löschen`; - - return ` - ${nameCell} - #${item.id} - ${parentBadge(resource,item)} - ${[openBtn, editBtn, editTplBtn, testBtn, prevBtn, delBtn].filter(Boolean).join('')} - `; - }).join(''); - - bindListHandlers(list, resource); - } - - function applyFilter(){ + function applyFilter() { const query = (filterInput?.value || '').trim(); const sortKey = sortSelect?.value || sortDefault; const filtered = data.filter(item => matchesQuery(item, query)); @@ -225,16 +222,14 @@ export async function loadList(resource){ render(sorted); } - async function persistSort(nextValue){ + async function persistSort(nextValue) { window.__listSortDefault = nextValue; try { await apiAction('account.settings.update', { method: 'POST', data: { list_sort: nextValue } }); } catch {} } - if (filterInput) { - filterInput.addEventListener('input', applyFilter); - } + if (filterInput) filterInput.addEventListener('input', applyFilter); if (filterReset) { filterReset.addEventListener('click', () => { if (filterInput) { @@ -254,100 +249,81 @@ export async function loadList(resource){ applyFilter(); - const delDlg=document.getElementById('deleteDialog'), - delText=document.getElementById('deleteText'), - delForm=document.getElementById('deleteForm'), - delCancel=document.getElementById('deleteCancel'); + function bindListHandlers(scope) { + scope.querySelectorAll('[data-open]').forEach(btn => btn.addEventListener('click', () => { + const id = Number(btn.dataset.open || 0); + const item = data.find(it => Number(it.id) === id); + if (!item) return; + openContentEditor(item, section); + })); - let pending=null; - delCancel && (delCancel.onclick=()=>{pending=null;delDlg.close();}); + scope.querySelectorAll('[data-edit]').forEach(btn => btn.addEventListener('click', () => { + const id = Number(btn.dataset.edit || 0); + const item = data.find(it => Number(it.id) === id); + if (item) openTemplateEditor(item, section); + })); - // --- Editor öffnen (ANPASSUNG) ----------------------------------------- - function bindListHandlers(scope, resName){ - scope.querySelectorAll('[data-open]').forEach(b=>b.addEventListener('click', async ()=>{ - const [res,id]=b.dataset.open.split(':'); + const prevDlg = document.getElementById('previewDialog'); + const prevFrame = document.getElementById('previewFrame'); + scope.querySelectorAll('[data-preview]').forEach(btn => btn.addEventListener('click', async () => { + const id = Number(btn.dataset.preview || 0); + const obj = await fetchContentItem(id, section.id); + const html = (obj?.html || obj?.content || '(leer)'); + prevFrame.srcdoc = '' + html + ''; + prevDlg.showModal(); + })); - // 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 ?? '') : ''; + scope.querySelectorAll('[data-test]').forEach(btn => btn.addEventListener('click', () => { + const id = Number(btn.dataset.test || 0); + const nm = btn.dataset.name || ''; + if (!id) { + toast('Testversand: Ungültige ID', false); + return; + } + if (window.AdminTestSend && typeof window.AdminTestSend.open === 'function') { + window.AdminTestSend.open({ id, name: nm }); + } else { + toast('Testversand ist aktuell nicht verfügbar.', false); + } + })); - // Globale Kontexte (werden von Editor/anderen Modulen genutzt) - window.__currentItemId = Number(id); - window.__currentEditorCtx = { id:Number(id), mode:res }; + const delDlg = document.getElementById('deleteDialog'); + const delText = document.getElementById('deleteText'); + const delForm = document.getElementById('deleteForm'); + const delCancel = document.getElementById('deleteCancel'); + let pending = null; - // 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); - } - })); - // ----------------------------------------------------------------------- + delCancel && (delCancel.onclick = () => { pending = null; delDlg.close(); }); - // edit snippet - scope.querySelectorAll('[data-edit]').forEach(b=>b.addEventListener('click', async ()=>{ - const [res, id] = b.dataset.edit.split(':'); - if (res === 'snippets') await openSnippetEditor(id); - if (res === 'templates') await openTemplateEditor(id); - })); + scope.querySelectorAll('[data-del]').forEach(btn => btn.addEventListener('click', () => { + const id = Number(btn.dataset.del || 0); + const nm = btn.dataset.name || ''; + pending = { id, name: nm }; + if (delText) { + delText.innerHTML = `Soll ${nm || '(ohne Name)'} #${id} wirklich gelöscht werden?`; + } + delDlg.showModal(); + })); - // preview - const prevDlg=document.getElementById('previewDialog'), prevFrame=document.getElementById('previewFrame'); - scope.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||'(leer)'+html+''; - prevDlg.showModal(); - })); - - // test send (templates only) - scope.querySelectorAll('[data-test]').forEach(b=>b.addEventListener('click', ()=>{ - const id = Number(b.dataset.test || '0'); - const nm = b.dataset.name || ''; - if (!id) { - toast('Testversand: Ungültige ID', false); - return; - } - if (window.AdminTestSend && typeof window.AdminTestSend.open === 'function') { - window.AdminTestSend.open({ id, name: nm }); - } else { - toast('Testversand ist aktuell nicht verfügbar.', false); - } - })); - - // delete - scope.querySelectorAll('[data-del]').forEach(b=>b.addEventListener('click', async ()=>{ - const [res,id]=b.dataset.del.split(':'); const nm=b.dataset.name||''; - let usage = null; - try { - const detail = await apiGet(res, id); - usage = detail?.usage || null; - } catch {} - pending={res,id,nm,usage}; - const usageWarn = formatUsage(usage); - delText && (delText.innerHTML=`Soll ${nm || '(ohne Name)'} #${id} aus ${res} wirklich gelöscht werden?Achtung: Kinder-Elemente werden nicht automatisch mit gelöscht.${usageWarn}`); - delDlg.showModal(); - })); + delForm && (delForm.onsubmit = async (ev) => { + ev.preventDefault(); + if (!pending) return delDlg.close(); + const res = await apiAction('content.delete', { method: 'POST', data: { id: pending.id, section_id: section.id } }); + delDlg.close(); + toast(res && res.ok ? 'Gelöscht' : 'Löschen fehlgeschlagen', !!(res && res.ok), { duration: 3000 }); + loadList(section); + }); } - 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) +export function initLists() { + if (window.__sectionsReady && typeof window.__sectionsReady.then === 'function') { + window.__sectionsReady.then(() => { + if (window.__activeSection) loadList(window.__activeSection); + }); + } else if (window.__activeSection) { + loadList(window.__activeSection); + } window.__reloadList = loadList; - // Backwards compat (falls woanders genutzt) window.loadList = loadList; } diff --git a/public/assets/js/ui-tabs.js b/public/assets/js/ui-tabs.js index cb5dd46..839a85f 100644 --- a/public/assets/js/ui-tabs.js +++ b/public/assets/js/ui-tabs.js @@ -1,11 +1,60 @@ -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); - })); -} \ No newline at end of file +import { apiAction, toast } from './api.js'; + +function renderTabs(nav, sections, activeId) { + nav.innerHTML = ''; + sections.forEach((section, idx) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.dataset.sectionId = String(section.id); + btn.className = 'px-4 py-2 text-sm border-e'; + btn.textContent = section.name || `Section ${idx + 1}`; + if (section.id === activeId) { + btn.classList.add('bg-sky-50', 'text-sky-700'); + } + nav.appendChild(btn); + }); +} + +function pickDefaultSection(sections) { + if (!Array.isArray(sections) || !sections.length) return null; + const tpl = sections.find(s => Number(s.is_template) === 1); + return tpl || sections[0]; +} + +export async function initTabs() { + const nav = document.getElementById('sectionTabs'); + if (!nav) return; + const readyPromise = (async () => { + try { + const res = await apiAction('sections_config.list', { method: 'GET' }); + const sections = Array.isArray(res?.items) ? res.items : []; + if (!sections.length) { + nav.innerHTML = 'Keine Sections'; + return; + } + window.__sectionsConfig = sections; + const active = pickDefaultSection(sections); + window.__activeSection = active; + renderTabs(nav, sections, active?.id); + nav.querySelectorAll('button[data-section-id]').forEach(btn => { + btn.addEventListener('click', () => { + const id = Number(btn.dataset.sectionId || 0); + const next = sections.find(s => Number(s.id) === id); + if (!next) return; + window.__activeSection = next; + renderTabs(nav, sections, next.id); + if (typeof window.loadList === 'function') { + window.loadList(next); + } + }); + }); + if (typeof window.loadList === 'function' && active) { + window.loadList(active); + } + } catch (err) { + toast(err.message || 'Sections konnten nicht geladen werden', false); + } + })(); + window.__sectionsReady = readyPromise; + return readyPromise; +} diff --git a/public/assets/js/ui-user.js b/public/assets/js/ui-user.js index aa6481d..756058a 100644 --- a/public/assets/js/ui-user.js +++ b/public/assets/js/ui-user.js @@ -25,6 +25,15 @@ let teamTable; let userForm; let senderTable; let senderForm; +let sectionsList; +let sectionsCreateForm; +let sectionNameInput; +let sectionsDeleteDialog; +let sectionsDeleteForm; +let sectionsDeleteTarget; +let sectionsDeleteText; +let sectionsDeleteCancel; +let sectionDragId = null; let menuInitialized = false; let menuOpen = false; let debugButton; @@ -75,6 +84,14 @@ export function initAccountPage() { adminTablesAddBtn = document.getElementById('adminBridgeTablesAdd'); adminTablesRemoveBtn = document.getElementById('adminBridgeTablesRemove'); adminLoadBridgeBtn = document.getElementById('btn-admin-load-bridge'); + sectionsList = document.getElementById('sectionsList'); + sectionsCreateForm = document.getElementById('sectionsCreateForm'); + sectionNameInput = document.getElementById('sectionNameInput'); + sectionsDeleteDialog = document.getElementById('sectionsDeleteDialog'); + sectionsDeleteForm = document.getElementById('sectionsDeleteForm'); + sectionsDeleteTarget = document.getElementById('sectionsDeleteTarget'); + sectionsDeleteText = document.getElementById('sectionsDeleteText'); + sectionsDeleteCancel = document.getElementById('sectionsDeleteCancel'); document.getElementById('btn-user-add')?.addEventListener('click', () => openUserForm()); document.getElementById('userFormCancel')?.addEventListener('click', () => closeUserForm()); @@ -122,6 +139,8 @@ export function initAccountPage() { refreshBridgeTablesFromEndpoint(); }); + initSectionsManager(); + window.addEventListener('bridge-setup-updated', (ev) => { const setup = ev?.detail || {}; refreshAdminTables(setup.tables || [], state.settings.bridge_tables || []); @@ -132,6 +151,151 @@ export function initAccountPage() { updateRoleVisibility(); } +function initSectionsManager() { + if (!sectionsList || !sectionsCreateForm || !sectionNameInput) return; + + sectionsCreateForm.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const name = sectionNameInput.value.trim(); + if (!name) return; + try { + const res = await apiAction('sections_config.create', { method: 'POST', data: { name } }); + if (!res?.ok) throw new Error(res?.error || 'Erstellen fehlgeschlagen'); + sectionNameInput.value = ''; + await loadSectionsConfig(); + toast('Section erstellt', true); + } catch (err) { + toast(err.message || 'Erstellen fehlgeschlagen', false); + } + }); + + sectionsDeleteCancel && (sectionsDeleteCancel.onclick = () => { + sectionsDeleteDialog?.close(); + }); + + sectionsDeleteForm?.addEventListener('submit', async (ev) => { + ev.preventDefault(); + const id = Number(sectionsDeleteForm?.dataset?.sectionId || 0); + const target = Number(sectionsDeleteTarget?.value || 0); + if (!id || !target) return; + try { + const res = await apiAction('sections_config.delete', { method: 'POST', data: { id, move_to: target } }); + if (!res?.ok) throw new Error(res?.error || 'Löschen fehlgeschlagen'); + sectionsDeleteDialog?.close(); + await loadSectionsConfig(); + toast('Section gelöscht', true); + } catch (err) { + toast(err.message || 'Löschen fehlgeschlagen', false); + } + }); + + loadSectionsConfig(); +} + +async function loadSectionsConfig() { + try { + const res = await apiAction('sections_config.list', { method: 'GET' }); + const items = Array.isArray(res?.items) ? res.items : []; + renderSectionsList(items); + } catch (err) { + toast(err.message || 'Sections konnten nicht geladen werden', false); + } +} + +function renderSectionsList(items) { + if (!sectionsList) return; + const rows = items || []; + sectionsList.innerHTML = rows.map((item) => { + const isTemplate = Number(item.is_template) === 1; + const dragAttr = isTemplate ? '' : 'draggable="true"'; + const badge = isTemplate ? 'Fix' : ''; + const editBtn = isTemplate ? '' : `Umbenennen`; + const delBtn = isTemplate ? '' : `Löschen`; + return ` + ☰ + + ${escapeHtml(item.name || '')} + ${escapeHtml(item.slug || '')} + + ${badge} + ${editBtn}${delBtn} + `; + }).join(''); + + sectionsList.querySelectorAll('[data-edit]').forEach(btn => btn.addEventListener('click', async () => { + const id = Number(btn.dataset.edit || 0); + const current = rows.find(r => Number(r.id) === id); + if (!current) return; + const next = prompt('Neuer Name', current.name || ''); + if (!next || next.trim() === current.name) return; + try { + const res = await apiAction('sections_config.update', { method: 'POST', data: { id, name: next.trim() } }); + if (!res?.ok) throw new Error(res?.error || 'Speichern fehlgeschlagen'); + await loadSectionsConfig(); + toast('Section gespeichert', true); + } catch (err) { + toast(err.message || 'Speichern fehlgeschlagen', false); + } + })); + + sectionsList.querySelectorAll('[data-del]').forEach(btn => btn.addEventListener('click', () => { + const id = Number(btn.dataset.del || 0); + const current = rows.find(r => Number(r.id) === id); + if (!current) return; + const targets = rows.filter(r => Number(r.id) !== id); + if (!targets.length) { + toast('Keine Ziel-Section verfügbar', false); + return; + } + if (sectionsDeleteText) { + sectionsDeleteText.textContent = `Section "${current.name}" löschen?`; + } + if (sectionsDeleteTarget) { + sectionsDeleteTarget.innerHTML = targets + .map(r => `${escapeHtml(r.name || '')}`) + .join(''); + } + if (sectionsDeleteForm) { + sectionsDeleteForm.dataset.sectionId = String(id); + } + sectionsDeleteDialog?.showModal?.(); + })); + + sectionsList.querySelectorAll('[draggable="true"]').forEach(item => { + item.addEventListener('dragstart', (ev) => { + sectionDragId = item.dataset.id || null; + ev.dataTransfer?.setData('text/plain', sectionDragId || ''); + }); + item.addEventListener('dragend', () => { + sectionDragId = null; + }); + item.addEventListener('dragover', (ev) => { + ev.preventDefault(); + }); + item.addEventListener('drop', async (ev) => { + ev.preventDefault(); + const targetId = item.dataset.id || null; + if (!sectionDragId || !targetId || sectionDragId === targetId) return; + const ids = Array.from(sectionsList.querySelectorAll('[data-id]')).map(el => el.getAttribute('data-id')); + const fromIndex = ids.indexOf(sectionDragId); + const toIndex = ids.indexOf(targetId); + if (fromIndex === -1 || toIndex === -1) return; + ids.splice(fromIndex, 1); + ids.splice(toIndex, 0, sectionDragId); + try { + const res = await apiAction('sections_config.reorder', { method: 'POST', data: { order: ids } }); + if (!res?.ok) throw new Error(res?.error || 'Sortierung fehlgeschlagen'); + await loadSectionsConfig(); + toast('Sortierung gespeichert', true); + } catch (err) { + toast(err.message || 'Sortierung fehlgeschlagen', false); + } finally { + sectionDragId = null; + } + }); + }); +} + function isOwner() { return (window.__currentUser?.role || '').toLowerCase() === 'owner'; } diff --git a/public/editor/editor-core.php b/public/editor/editor-core.php index 22b9a22..c6661de 100644 --- a/public/editor/editor-core.php +++ b/public/editor/editor-core.php @@ -1,6 +1,7 @@ window.__editorMode = "=htmlspecialchars($mode, ENT_QUOTES)?>"; window.__editorId = = $id ?>; + window.__editorSectionId = = $sectionId ?>; window.BridgeParts = window.BridgeParts || {}; window.BridgeParts.ENABLE_EDITOR_EXTENSIONS = true; window.BridgeParts.ENABLE_EDITOR_BEHAVIOR = false; @@ -57,7 +59,9 @@ if ($fontSources) { window.BridgeParts.LOG_CONFIG = window.BridgeParts.LOG_CONFIG || {}; window.BridgeParts.LOG_CONFIG.INFO_ENABLED = false; window.BridgeParts.LOG_CONFIG.DATA_ENABLED = false; + window.BridgeParts.USE_DYNAMIC_SECTIONS = true; window.BridgeParts.CURRENT_ENTITY_ID = window.BridgeParts.CURRENT_ENTITY_ID || = $id ?>; + window.BridgeParts.CURRENT_SECTION_ID = window.BridgeParts.CURRENT_SECTION_ID || = $sectionId ?>; window.BridgeParts.API_KERNEL_URL = window.BridgeParts.API_KERNEL_URL || '/api.php'; window.BridgeParts.API_BASE = window.BridgeParts.API_BASE || window.BridgeParts.API_KERNEL_URL; window.BridgeParts.STORAGE_URL_BASE = window.BridgeParts.STORAGE_URL_BASE || window.BridgeParts.API_BASE; diff --git a/public/index.php b/public/index.php index e7d26e5..fe76778 100644 --- a/public/index.php +++ b/public/index.php @@ -7,12 +7,7 @@ $navLinks = []; ob_start(); ?> - - Templates - Sections - Blocks - Snippets - + Neu … Wähle eine Kategorie, um Templates, Sections, Blocks oder Snippets zu pflegen. - - - - + diff --git a/schema.sql b/schema.sql index 62c7817..6d48299 100644 --- a/schema.sql +++ b/schema.sql @@ -272,5 +272,47 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_template_usage` ( CONSTRAINT `fk_usage_template` FOREIGN KEY (`template_id`) REFERENCES `emailtemplate_templates` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +-- Tabelle: emailtemplate_content_sections +CREATE TABLE IF NOT EXISTS `emailtemplate_content_sections` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `customer_id` int(10) unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `slug` varchar(190) NOT NULL, + `position` int(11) NOT NULL DEFAULT 0, + `is_template` tinyint(1) NOT NULL DEFAULT 0, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uq_sections_customer_slug` (`customer_id`,`slug`), + UNIQUE KEY `uq_sections_customer_name` (`customer_id`,`name`), + KEY `idx_sections_customer` (`customer_id`), + KEY `idx_sections_sort` (`customer_id`,`position`,`id`), + CONSTRAINT `fk_content_sections_customer` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- Tabelle: emailtemplate_content_items +CREATE TABLE IF NOT EXISTS `emailtemplate_content_items` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `customer_id` int(10) unsigned NOT NULL, + `section_id` int(10) unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `api_name` varchar(190) DEFAULT NULL, + `category` varchar(100) DEFAULT NULL, + `json_content` mediumtext DEFAULT NULL, + `html` mediumtext DEFAULT NULL, + `editor_type` varchar(32) DEFAULT NULL, + `craft_json` mediumtext DEFAULT NULL, + `settings_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`settings_json`)), + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uq_items_api` (`customer_id`,`api_name`), + KEY `idx_items_customer` (`customer_id`), + KEY `idx_items_section` (`section_id`), + KEY `idx_items_name` (`name`), + CONSTRAINT `fk_content_items_section` FOREIGN KEY (`section_id`) REFERENCES `emailtemplate_content_sections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_content_items_customer` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + SET FOREIGN_KEY_CHECKS = 1; -- Ende des Schema-Dumps diff --git a/src/ApiKernel.php b/src/ApiKernel.php index ff32baa..c3aac04 100644 --- a/src/ApiKernel.php +++ b/src/ApiKernel.php @@ -172,7 +172,7 @@ class ApiKernel 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']; + $allowedResources = ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config']; 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; @@ -186,6 +186,8 @@ class ApiKernel 'sections' => $tables['sections'] ?? 'emailtemplate_sections', 'blocks' => $tables['blocks'] ?? 'emailtemplate_blocks', 'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets', + 'content_items' => $tables['content_items'] ?? 'emailtemplate_content_items', + 'content_sections' => $tables['content_sections'] ?? 'emailtemplate_content_sections', ]; } @@ -321,6 +323,137 @@ class ApiKernel return trim($value, '-'); } + private function normalizeSectionSlug(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('/\s+/', '-', $value); + $value = preg_replace('/[^a-z0-9_-]+/', '-', $value); + $value = preg_replace('/-+/', '-', $value); + return trim($value, '-'); + } + + private function contentItemsTable(): string + { + return $this->tableMap['content_items'] ?? $this->lookupTableName('content_items', 'emailtemplate_content_items'); + } + + private function contentSectionsTable(): string + { + return $this->tableMap['content_sections'] ?? $this->lookupTableName('content_sections', 'emailtemplate_content_sections'); + } + + private function useUnifiedContent(): bool + { + return $this->tableExists($this->contentItemsTable()) && $this->tableExists($this->contentSectionsTable()); + } + + private function isLegacyContentKind(string $kind): bool + { + return in_array($kind, ['templates', 'sections', 'blocks', 'snippets'], true); + } + + private function resolveLegacySectionDefaults(string $kind): array + { + if ($kind === 'templates') { + return ['name' => 'Emailtemplate', 'slug' => 'emailtemplate', 'is_template' => true]; + } + $name = ucfirst($kind); + return ['name' => $name, 'slug' => $this->normalizeSectionSlug($kind), 'is_template' => false]; + } + + private function resolveSectionSlugFromKind(string $kind): string + { + $legacy = $this->normalizeResourceKind($kind); + if ($legacy) { + return $legacy === 'templates' ? 'emailtemplate' : $legacy; + } + return $this->normalizeSectionSlug($kind); + } + + private function fetchContentSectionBySlug(int $customerId, string $slug): ?array + { + $table = $this->contentSectionsTable(); + if (!$this->tableExists($table)) return null; + $sql = "SELECT * FROM `$table` WHERE `customer_id` = :cid AND `slug` = :slug LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':cid' => $customerId, ':slug' => $slug]); + $row = $stmt->fetch(); + return $row ?: null; + } + + private function fetchContentSectionById(int $customerId, int $id): ?array + { + if ($id <= 0) return null; + $table = $this->contentSectionsTable(); + if (!$this->tableExists($table)) return null; + $sql = "SELECT * FROM `$table` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':cid' => $customerId, ':id' => $id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + private function ensureContentSection(int $customerId, string $name, string $slug, bool $isTemplate): array + { + $slug = $this->normalizeSectionSlug($slug); + $existing = $this->fetchContentSectionBySlug($customerId, $slug); + if ($existing) return $existing; + + $table = $this->contentSectionsTable(); + if (!$this->tableExists($table)) { + $this->fail('Sections table not available', null, 500); + } + + $position = 0; + if ($isTemplate) { + $stmt = $this->pdo->prepare("UPDATE `$table` SET `position` = `position` + 1 WHERE `customer_id` = :cid"); + $stmt->execute([':cid' => $customerId]); + $position = 0; + } else { + $stmt = $this->pdo->prepare("SELECT MAX(`position`) FROM `$table` WHERE `customer_id` = :cid"); + $stmt->execute([':cid' => $customerId]); + $position = (int)($stmt->fetchColumn() ?: 0) + 1; + } + + $stmt = $this->pdo->prepare( + "INSERT INTO `$table` (`customer_id`,`name`,`slug`,`position`,`is_template`) VALUES (:cid,:name,:slug,:pos,:tpl)" + ); + $stmt->execute([ + ':cid' => $customerId, + ':name' => $name, + ':slug' => $slug, + ':pos' => $position, + ':tpl' => $isTemplate ? 1 : 0, + ]); + $id = (int)$this->pdo->lastInsertId(); + return $this->fetchContentSectionById($customerId, $id) ?? [ + 'id' => $id, + 'customer_id' => $customerId, + 'name' => $name, + 'slug' => $slug, + 'position' => $position, + 'is_template' => $isTemplate ? 1 : 0, + ]; + } + + private function ensureEmailtemplateSection(int $customerId): array + { + return $this->ensureContentSection($customerId, 'Emailtemplate', 'emailtemplate', true); + } + + private function resolveSectionFromInput(int $customerId): ?array + { + $sectionId = (int)$this->val($this->in, ['section_id', 'section', 'sectionId'], 0); + if ($sectionId > 0) { + return $this->fetchContentSectionById($customerId, $sectionId); + } + $sectionSlug = trim((string)$this->val($this->in, ['section_slug', 'sectionSlug', 'section_code'], '')); + if ($sectionSlug !== '') { + return $this->fetchContentSectionBySlug($customerId, $this->normalizeSectionSlug($sectionSlug)); + } + return null; + } + private function assertTemplateApiNameUnique( string $table, string $apiCol, @@ -343,16 +476,633 @@ class ApiKernel } } + private function assertContentApiNameUnique( + 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 // ================================================================= + + private function handleContentList(?array $fixedSection = null): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + + $itemsTable = $this->contentItemsTable(); + $sectionsTable = $this->contentSectionsTable(); + if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) { + $this->fail('Content tables not available', null, 500); + } + + $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); + $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 i.`customer_id` = :cid "; + $params = [':cid' => $customerId]; + if ($section && !empty($section['id'])) { + $where .= " AND i.`section_id` = :sid "; + $params[':sid'] = (int)$section['id']; + } + if ($q !== '') { + $where .= " AND (i.`name` LIKE :q OR i.`category` LIKE :q) "; + $params[':q'] = '%' . $q . '%'; + } + + $sql = "SELECT i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template + FROM `$itemsTable` i + JOIN `$sectionsTable` s ON s.`id` = i.`section_id` + $where + ORDER BY i.`updated_at` DESC, i.`id` DESC + LIMIT :off,:lim"; + $stmt = $this->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['id'] ?? null, + 'name' => $r['name'] ?? null, + 'api_name' => $r['api_name'] ?? null, + 'category' => $r['category'] ?? null, + 'section_id' => $r['section_id'] ?? null, + 'section_name' => $r['section_name'] ?? null, + 'section_slug' => $r['section_slug'] ?? null, + 'section_position' => $r['section_position'] ?? null, + 'section_is_template' => (int)($r['section_is_template'] ?? 0), + 'updated_at' => $r['updated_at'] ?? null, + 'created_at' => $r['created_at'] ?? null, + ]; + if (array_key_exists('html', $r)) $item['html'] = (string)($r['html'] ?? ''); + if (array_key_exists('json_content', $r)) $item['content'] = $r['json_content']; + $out[] = $item; + } + + $this->respond([ + 'ok' => true, + 'kind' => 'content', + 'items' => $out, + 'data' => $out, + 'count' => count($out), + 'offset' => $offset, + 'limit' => $limit, + ]); + } + + private function handleContentGet(?array $fixedSection = null): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $id = $this->pullId($this->in); + if ($id === null || $id === '') $this->fail('id required', null, 422); + + $itemsTable = $this->contentItemsTable(); + $sectionsTable = $this->contentSectionsTable(); + if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) { + $this->fail('Content tables not available', null, 500); + } + + $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); + $params = [':cid' => $customerId, ':id' => $id]; + $where = " WHERE i.`customer_id` = :cid AND i.`id` = :id "; + if ($section && !empty($section['id'])) { + $where .= " AND i.`section_id` = :sid "; + $params[':sid'] = (int)$section['id']; + } + + $sql = "SELECT i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template + FROM `$itemsTable` i + JOIN `$sectionsTable` s ON s.`id` = i.`section_id` + $where + LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + foreach ($params as $k => $v) $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); + $stmt->execute(); + $row = $stmt->fetch(); + if (!$row) $this->fail('Not found', ['id' => $id], 404); + + $html = (string)($row['html'] ?? ''); + $json = $row['json_content'] ?? null; + $gjsComponents = []; + if ($json !== null) { + $decoded = json_decode((string)$json, true); + if (is_array($decoded)) $gjsComponents = $decoded; + } + if (!$gjsComponents && $html !== '') { + $gjsComponents = $this->parseHtmlToGjsComponents($html); + } + + $item = $row; + $item['content'] = $json; + $item['section_name'] = $row['section_name'] ?? null; + $item['section_slug'] = $row['section_slug'] ?? null; + $item['section_position'] = $row['section_position'] ?? null; + $item['section_is_template'] = (int)($row['section_is_template'] ?? 0); + + $this->respond([ + 'ok' => true, + 'kind' => 'content', + 'id' => $row['id'] ?? $id, + 'item' => $item, + 'data' => $item, + 'html' => $html, + 'content' => $json, + 'gjs_components' => $gjsComponents, + 'editor_type' => $row['editor_type'] ?? null, + 'craft_json' => $row['craft_json'] ?? null, + ]); + } + + private function handleContentCreate(?array $fixedSection = null): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + + $itemsTable = $this->contentItemsTable(); + if (!$this->tableExists($itemsTable)) { + $this->fail('Content table not available', null, 500); + } + + $name = trim((string)$this->val($this->in, ['name', 'title'], '')); + if ($name === '') $this->fail('name required', null, 422); + + $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); + if (!$section) $this->fail('section required', null, 422); + + $apiRaw = trim((string)$this->val($this->in, ['api_name', 'apiName', 'api'], '')); + $apiName = $apiRaw !== '' ? $this->normalizeApiName($apiRaw) : ''; + $isTemplate = !empty($section['is_template']); + if ($isTemplate && $apiName === '') { + $this->fail('api_name required', null, 422); + } + + if ($apiName !== '') { + $this->assertContentApiNameUnique($itemsTable, 'api_name', 'id', $customerId, $apiName, null); + } else { + $apiName = null; + } + + $html = $this->val($this->in, ['html', 'body', 'markup', 'content'], null); + $json = $this->val($this->in, ['content_json', 'json', 'structure_json'], null); + $category = $this->val($this->in, ['category', 'cat'], null); + $editorType = strtolower(trim((string)$this->val($this->in, ['editor_type', 'editor'], ''))); + $craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null); + $settings = $this->val($this->in, ['settings_json', 'settings'], null); + + $data = [ + 'customer_id' => $customerId, + 'section_id' => (int)$section['id'], + 'name' => $name, + 'api_name' => $apiName, + ]; + if ($category !== null) $data['category'] = (string)$category; + if ($editorType !== '') $data['editor_type'] = $editorType; + if ($craftJson !== null) $data['craft_json'] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson); + if ($settings !== null) $data['settings_json'] = is_string($settings) ? $settings : $this->encodeJson($settings); + + if ($json !== null) { + $components = is_string($json) ? json_decode($json, true) : $json; + if (is_array($components)) { + $components = $this->cleanReferenceComponents($components); + $data['json_content'] = $this->encodeJson($components); + } else { + $data['json_content'] = is_string($json) ? $json : ''; + } + if ($html !== null) $data['html'] = (string)$html; + } elseif ($html !== null) { + $data['html'] = (string)$html; + } + + $columns = array_keys($data); + $insertCols = implode(',', array_map(fn($c) => "`$c`", $columns)); + $placeholders = implode(',', array_map(fn($c) => ":$c", $columns)); + $stmt = $this->pdo->prepare("INSERT INTO `$itemsTable` ($insertCols) VALUES ($placeholders)"); + foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); + $stmt->execute(); + $newId = (int)$this->pdo->lastInsertId(); + $this->respond(['ok' => true, 'kind' => 'content', 'id' => $newId, 'item' => ['id' => $newId, 'name' => $name]]); + } + + private function handleContentUpdate(?array $fixedSection = null): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + + $itemsTable = $this->contentItemsTable(); + if (!$this->tableExists($itemsTable)) { + $this->fail('Content table not available', null, 500); + } + + $id = $this->pullId($this->in); + if ($id === null || $id === '') $this->fail('id required', null, 422); + + $stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1"); + $stmt->execute([':cid' => $customerId, ':id' => $id]); + $current = $stmt->fetch(); + if (!$current) $this->fail('Not found', ['id' => $id], 404); + + $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); + if (!$section) { + $section = $this->fetchContentSectionById($customerId, (int)($current['section_id'] ?? 0)); + } + if (!$section) $this->fail('section required', null, 422); + + $data = []; + $name = $this->val($this->in, ['name', 'title'], null); + if ($name !== null) $data['name'] = (string)$name; + + $category = $this->val($this->in, ['category', 'cat'], null); + if ($category !== null) $data['category'] = (string)$category; + + $apiRaw = $this->val($this->in, ['api_name', 'apiName', 'api'], null); + $apiName = $apiRaw !== null ? $this->normalizeApiName((string)$apiRaw) : null; + $isTemplate = !empty($section['is_template']); + if ($isTemplate && $apiRaw === null && empty($current['api_name'])) { + $this->fail('api_name required', null, 422); + } + if ($apiName !== null) { + if ($apiName === '' && $isTemplate) { + $this->fail('api_name required', null, 422); + } + if ($apiName !== '') { + $this->assertContentApiNameUnique($itemsTable, 'api_name', 'id', $customerId, $apiName, (int)$id); + $data['api_name'] = $apiName; + } else { + $data['api_name'] = null; + } + } + + $sectionId = $section['id'] ?? null; + if ($sectionId && (int)$sectionId !== (int)($current['section_id'] ?? 0)) { + $data['section_id'] = (int)$sectionId; + } + + $html = $this->val($this->in, ['html', 'body', 'markup', 'content'], null); + $json = $this->val($this->in, ['content_json', 'json', 'structure_json'], null); + if ($json !== null) { + $components = is_string($json) ? json_decode($json, true) : $json; + if (is_array($components)) { + $components = $this->cleanReferenceComponents($components); + $data['json_content'] = $this->encodeJson($components); + } else { + $data['json_content'] = is_string($json) ? $json : ''; + } + if ($html !== null) $data['html'] = (string)$html; + } elseif ($html !== null) { + $data['html'] = (string)$html; + } + + $editorType = $this->val($this->in, ['editor_type', 'editor'], null); + if ($editorType !== null) $data['editor_type'] = strtolower(trim((string)$editorType)); + $craftJson = $this->val($this->in, ['craft_json', 'craft_content', 'craft_data'], null); + if ($craftJson !== null) $data['craft_json'] = is_string($craftJson) ? $craftJson : $this->encodeJson($craftJson); + $settings = $this->val($this->in, ['settings_json', 'settings'], null); + if ($settings !== null) $data['settings_json'] = is_string($settings) ? $settings : $this->encodeJson($settings); + + if (!$data) { + $this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]); + return; + } + + $set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data))); + $data['id'] = $id; + $data['customer_id'] = $customerId; + $sql = "UPDATE `$itemsTable` SET $set WHERE `id` = :id AND `customer_id` = :customer_id LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); + $stmt->execute(); + $this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]); + } + + private function handleContentDelete(?array $fixedSection = null): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $id = $this->pullId($this->in); + if ($id === null || $id === '') $this->fail('id required', null, 422); + + $itemsTable = $this->contentItemsTable(); + if (!$this->tableExists($itemsTable)) { + $this->fail('Content table not available', null, 500); + } + + $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); + $params = [':cid' => $customerId, ':id' => $id]; + $where = "WHERE `customer_id` = :cid AND `id` = :id"; + if ($section && !empty($section['id'])) { + $where .= " AND `section_id` = :sid"; + $params[':sid'] = (int)$section['id']; + } + + $stmt = $this->pdo->prepare("DELETE FROM `$itemsTable` $where LIMIT 1"); + foreach ($params as $k => $v) $stmt->bindValue($k, $v); + $stmt->execute(); + $this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'deleted' => true]); + } + + private function handleSectionsConfigList(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + + $table = $this->contentSectionsTable(); + if (!$this->tableExists($table)) { + $this->fail('Sections table not available', null, 500); + } + + $this->ensureEmailtemplateSection($customerId); + $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `position` ASC, `id` ASC"); + $stmt->execute([':cid' => $customerId]); + $rows = $stmt->fetchAll() ?: []; + $items = array_map(static function ($row) { + return [ + 'id' => (int)($row['id'] ?? 0), + 'name' => $row['name'] ?? '', + 'slug' => $row['slug'] ?? '', + 'position' => (int)($row['position'] ?? 0), + 'is_template' => (int)($row['is_template'] ?? 0), + ]; + }, $rows); + $this->respond(['ok' => true, 'items' => $items, 'data' => $items]); + } + + private function handleSectionsConfigGet(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $id = $this->pullId($this->in); + if ($id === null || $id === '') $this->fail('id required', null, 422); + + $table = $this->contentSectionsTable(); + if (!$this->tableExists($table)) { + $this->fail('Sections table not available', null, 500); + } + + $row = $this->fetchContentSectionById($customerId, (int)$id); + if (!$row) $this->fail('Not found', ['id' => $id], 404); + $item = [ + 'id' => (int)($row['id'] ?? 0), + 'name' => $row['name'] ?? '', + 'slug' => $row['slug'] ?? '', + 'position' => (int)($row['position'] ?? 0), + 'is_template' => (int)($row['is_template'] ?? 0), + ]; + $this->respond(['ok' => true, 'item' => $item, 'data' => $item]); + } + + private function handleSectionsConfigCreate(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $table = $this->contentSectionsTable(); + if (!$this->tableExists($table)) { + $this->fail('Sections table not available', null, 500); + } + + $name = trim((string)$this->val($this->in, ['name', 'title'], '')); + if ($name === '') $this->fail('name required', null, 422); + $slug = $this->normalizeSectionSlug($name); + if ($slug === '') $this->fail('slug required', null, 422); + + $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid AND (LOWER(`name`) = LOWER(:name) OR LOWER(`slug`) = LOWER(:slug))"); + $stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug]); + if ((int)$stmt->fetchColumn() > 0) { + $this->fail('section name already exists', ['name' => $name], 409); + } + + $stmt = $this->pdo->prepare("SELECT MAX(`position`) FROM `$table` WHERE `customer_id` = :cid"); + $stmt->execute([':cid' => $customerId]); + $position = (int)($stmt->fetchColumn() ?: 0) + 1; + + $stmt = $this->pdo->prepare( + "INSERT INTO `$table` (`customer_id`,`name`,`slug`,`position`,`is_template`) VALUES (:cid,:name,:slug,:pos,0)" + ); + $stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':pos' => $position]); + $id = (int)$this->pdo->lastInsertId(); + $this->respond(['ok' => true, 'id' => $id, 'item' => ['id' => $id, 'name' => $name, 'slug' => $slug, 'position' => $position]]); + } + + private function handleSectionsConfigUpdate(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $id = $this->pullId($this->in); + if ($id === null || $id === '') $this->fail('id required', null, 422); + $table = $this->contentSectionsTable(); + if (!$this->tableExists($table)) { + $this->fail('Sections table not available', null, 500); + } + + $section = $this->fetchContentSectionById($customerId, (int)$id); + if (!$section) $this->fail('Not found', ['id' => $id], 404); + if (!empty($section['is_template'])) { + $this->fail('Emailtemplate section cannot be changed', null, 422); + } + + $name = $this->val($this->in, ['name', 'title'], null); + if ($name === null) { + $this->respond(['ok' => true, 'id' => (int)$id, 'updated' => true]); + return; + } + $name = trim((string)$name); + if ($name === '') $this->fail('name required', null, 422); + + $slug = $this->normalizeSectionSlug($name); + if ($slug === '') $this->fail('slug required', null, 422); + + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid AND (LOWER(`name`) = LOWER(:name) OR LOWER(`slug`) = LOWER(:slug)) AND `id` <> :id" + ); + $stmt->execute([':cid' => $customerId, ':name' => $name, ':slug' => $slug, ':id' => (int)$id]); + if ((int)$stmt->fetchColumn() > 0) { + $this->fail('section name already exists', ['name' => $name], 409); + } + + $stmt = $this->pdo->prepare("UPDATE `$table` SET `name` = :name, `slug` = :slug WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); + $stmt->execute([':name' => $name, ':slug' => $slug, ':id' => (int)$id, ':cid' => $customerId]); + $this->respond(['ok' => true, 'id' => (int)$id, 'updated' => true, 'item' => ['id' => (int)$id, 'name' => $name, 'slug' => $slug]]); + } + + private function handleSectionsConfigDelete(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $id = $this->pullId($this->in); + if ($id === null || $id === '') $this->fail('id required', null, 422); + $moveTo = (int)$this->val($this->in, ['move_to', 'move_to_id', 'target_section'], 0); + if ($moveTo <= 0) $this->fail('move_to required', null, 422); + + $sectionsTable = $this->contentSectionsTable(); + $itemsTable = $this->contentItemsTable(); + if (!$this->tableExists($sectionsTable) || !$this->tableExists($itemsTable)) { + $this->fail('Content tables not available', null, 500); + } + + $section = $this->fetchContentSectionById($customerId, (int)$id); + if (!$section) $this->fail('Not found', ['id' => $id], 404); + if (!empty($section['is_template'])) { + $this->fail('Emailtemplate section cannot be deleted', null, 422); + } + + $target = $this->fetchContentSectionById($customerId, $moveTo); + if (!$target) $this->fail('move_to section not found', null, 404); + if ((int)$target['id'] === (int)$id) $this->fail('move_to must differ', null, 422); + + $stmt = $this->pdo->prepare("UPDATE `$itemsTable` SET `section_id` = :target WHERE `customer_id` = :cid AND `section_id` = :sid"); + $stmt->execute([':target' => (int)$target['id'], ':cid' => $customerId, ':sid' => (int)$id]); + + $stmt = $this->pdo->prepare("DELETE FROM `$sectionsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); + $stmt->execute([':id' => (int)$id, ':cid' => $customerId]); + + $this->respond(['ok' => true, 'id' => (int)$id, 'deleted' => true]); + } + + private function handleSectionsConfigReorder(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $order = $this->val($this->in, ['order', 'items', 'ids'], []); + if (!is_array($order)) $this->fail('order must be array', null, 422); + + $table = $this->contentSectionsTable(); + if (!$this->tableExists($table)) { + $this->fail('Sections table not available', null, 500); + } + + $this->ensureEmailtemplateSection($customerId); + $stmt = $this->pdo->prepare("SELECT * FROM `$table` WHERE `customer_id` = :cid ORDER BY `position` ASC, `id` ASC"); + $stmt->execute([':cid' => $customerId]); + $rows = $stmt->fetchAll() ?: []; + $byId = []; + $emailtemplateId = null; + foreach ($rows as $row) { + $id = (int)($row['id'] ?? 0); + $byId[$id] = $row; + if (!empty($row['is_template'])) $emailtemplateId = $id; + } + + $newOrder = []; + if ($emailtemplateId) $newOrder[] = $emailtemplateId; + foreach ($order as $rawId) { + $id = (int)$rawId; + if ($id <= 0 || $id === $emailtemplateId) continue; + if (!isset($byId[$id])) continue; + $newOrder[] = $id; + } + foreach ($byId as $id => $_row) { + if ($id === $emailtemplateId) continue; + if (!in_array($id, $newOrder, true)) $newOrder[] = $id; + } + + $pos = 0; + $stmt = $this->pdo->prepare("UPDATE `$table` SET `position` = :pos WHERE `id` = :id AND `customer_id` = :cid"); + foreach ($newOrder as $id) { + $stmt->execute([':pos' => $pos, ':id' => (int)$id, ':cid' => $customerId]); + $pos++; + } + $this->respond(['ok' => true, 'updated' => true]); + } + + private function handleLegacyContentList(string $kind): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + $defaults = $this->resolveLegacySectionDefaults($kind); + $section = $defaults['is_template'] + ? $this->ensureEmailtemplateSection($customerId) + : $this->ensureContentSection($customerId, $defaults['name'], $defaults['slug'], false); + $this->handleContentList($section); + } + + private function handleLegacyContentGet(string $kind): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + $defaults = $this->resolveLegacySectionDefaults($kind); + $section = $defaults['is_template'] + ? $this->ensureEmailtemplateSection($customerId) + : $this->ensureContentSection($customerId, $defaults['name'], $defaults['slug'], false); + $this->handleContentGet($section); + } + + private function handleLegacyContentCreate(string $kind): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + $defaults = $this->resolveLegacySectionDefaults($kind); + $section = $defaults['is_template'] + ? $this->ensureEmailtemplateSection($customerId) + : $this->ensureContentSection($customerId, $defaults['name'], $defaults['slug'], false); + $this->handleContentCreate($section); + } + + private function handleLegacyContentUpdate(string $kind): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + $defaults = $this->resolveLegacySectionDefaults($kind); + $section = $defaults['is_template'] + ? $this->ensureEmailtemplateSection($customerId) + : $this->ensureContentSection($customerId, $defaults['name'], $defaults['slug'], false); + $this->handleContentUpdate($section); + } + + private function handleLegacyContentDelete(string $kind): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + $defaults = $this->resolveLegacySectionDefaults($kind); + $section = $defaults['is_template'] + ? $this->ensureEmailtemplateSection($customerId) + : $this->ensureContentSection($customerId, $defaults['name'], $defaults['slug'], false); + $this->handleContentDelete($section); + } /** * Allgemeine Methode zur Handhabung von LIST-Anfragen. */ private function handleList(string $kind): void { + if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { + $this->handleLegacyContentList($kind); + return; + } $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); @@ -434,6 +1184,10 @@ class ApiKernel */ private function handleGet(string $kind): void { + if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { + $this->handleLegacyContentGet($kind); + return; + } $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); @@ -528,6 +1282,10 @@ class ApiKernel */ private function handleCreate(string $kind): void { + if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { + $this->handleLegacyContentCreate($kind); + return; + } $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); @@ -668,6 +1426,10 @@ class ApiKernel */ private function handleUpdate(string $kind): void { + if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { + $this->handleLegacyContentUpdate($kind); + return; + } $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); @@ -786,6 +1548,10 @@ class ApiKernel */ private function handleDelete(string $kind): void { + if ($this->useUnifiedContent() && $this->isLegacyContentKind($kind)) { + $this->handleLegacyContentDelete($kind); + return; + } $auth = $this->requireAuth(); $t = $this->tableMap[$kind]; [$idCol, $allCols] = $this->resolveIdCol($kind); @@ -807,6 +1573,7 @@ class ApiKernel private function handleTemplateTestSend(): void { $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); $templateId = (int)$this->val($this->in, ['template_id', 'id', 'template'], 0); if ($templateId <= 0) { $this->fail('template_id required', null, 422); @@ -823,22 +1590,33 @@ class ApiKernel } $senderId = (int)$this->val($this->in, ['sender_id'], 0); - $t = $this->tableMap['templates']; - [$idCol, $allCols] = $this->resolveIdCol('templates'); - [$tw, $tp] = $this->tenantWhere($auth); - - $sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1"; - $stmt = $this->pdo->prepare($sql); - $stmt->bindValue(':id', $templateId); - foreach ($tp as $k => $v) $stmt->bindValue($k, $v); - $stmt->execute(); - $row = $stmt->fetch(); + $row = null; + $html = ''; + if ($this->useUnifiedContent()) { + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $section = $this->ensureEmailtemplateSection($customerId); + $itemsTable = $this->contentItemsTable(); + $sql = "SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid AND `id` = :id LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $templateId]); + $row = $stmt->fetch(); + $html = $row ? (string)($row['html'] ?? '') : ''; + } else { + $t = $this->tableMap['templates']; + [$idCol, $allCols] = $this->resolveIdCol('templates'); + [$tw, $tp] = $this->tenantWhere($auth); + $sql = "SELECT * FROM `$t` WHERE `$idCol` = :id" . $tw . " LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $stmt->bindValue(':id', $templateId); + foreach ($tp as $k => $v) $stmt->bindValue($k, $v); + $stmt->execute(); + $row = $stmt->fetch(); + $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); + $html = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : ''; + } if (!$row) { $this->fail('Template not found', ['id' => $templateId], 404); } - - $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); - $html = ($htmlCol && isset($row[$htmlCol])) ? (string)$row[$htmlCol] : ''; if ($html === '' && !empty($row['json_content'])) { $html = '(Dieses Template enthält noch keine HTML-Inhalte.)'; } @@ -870,7 +1648,6 @@ class ApiKernel $this->fail('Send failed', null, 500); } - $customerId = (int)($auth['customer_id'] ?? 0); if ($customerId > 0) { $this->incrementTemplateUsage($customerId, $templateId); } @@ -900,50 +1677,85 @@ class ApiKernel $this->fail('Invalid token', null, 403); } - $templatesTable = $this->tableMap['templates'] ?? null; - if (!$templatesTable || !$this->tableExists($templatesTable)) { - $this->fail('Templates table not available', null, 500); - } - - [$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, ['api_name', 'template', 'template_id', 'id', 'name'], ''); $templateId = is_numeric($templateKey) ? (int)$templateKey : null; + $tpl = null; + $html = ''; + $templateName = null; + $apiName = null; - $where = "WHERE `customer_id` = :cid "; - $params = [':cid' => $customerId]; - if ($templateId !== null && $templateId > 0) { - $where .= "AND `$idCol` = :id "; - $params[':id'] = $templateId; - } else { - $name = trim((string)$templateKey); - if ($name === '') { - $this->fail('template required', null, 422); - } - if ($apiCol) { - $where .= "AND `$apiCol` = :name "; - $params[':name'] = $name; + if ($this->useUnifiedContent()) { + $section = $this->ensureEmailtemplateSection($customerId); + $itemsTable = $this->contentItemsTable(); + $where = "WHERE `customer_id` = :cid AND `section_id` = :sid "; + $params = [':cid' => $customerId, ':sid' => (int)$section['id']]; + if ($templateId !== null && $templateId > 0) { + $where .= "AND `id` = :id "; + $params[':id'] = $templateId; } else { - $where .= "AND `$nameCol` = :name "; + $name = trim((string)$templateKey); + if ($name === '') { + $this->fail('template required', null, 422); + } + $where .= "AND `api_name` = :name "; $params[':name'] = $name; } - } + $sql = "SELECT * FROM `$itemsTable` $where LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + $tpl = $stmt->fetch(); + if ($tpl) { + $html = (string)($tpl['html'] ?? ''); + $templateName = $tpl['name'] ?? null; + $apiName = $tpl['api_name'] ?? null; + } + } else { + $templatesTable = $this->tableMap['templates'] ?? null; + if (!$templatesTable || !$this->tableExists($templatesTable)) { + $this->fail('Templates table not available', null, 500); + } - $sql = "SELECT * FROM `$templatesTable` $where LIMIT 1"; - $stmt = $this->pdo->prepare($sql); - foreach ($params as $k => $v) { - $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); + [$idCol, $allCols] = $this->resolveIdCol('templates'); + $nameCol = $this->conf['columns']['templates']['name'] ?? ($this->firstExisting($allCols, ['name']) ?: $idCol); + $apiCol = $this->firstExisting($allCols, ['api_name']); + + $where = "WHERE `customer_id` = :cid "; + $params = [':cid' => $customerId]; + if ($templateId !== null && $templateId > 0) { + $where .= "AND `$idCol` = :id "; + $params[':id'] = $templateId; + } else { + $name = trim((string)$templateKey); + 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); + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->execute(); + $tpl = $stmt->fetch(); + $htmlCol = $this->resolveHtmlColumn($allCols, 'templates'); + $html = ($htmlCol && isset($tpl[$htmlCol])) ? (string)$tpl[$htmlCol] : ''; + $templateName = $tpl[$nameCol] ?? null; + $apiName = $apiCol ? ($tpl[$apiCol] ?? null) : null; } - $stmt->execute(); - $tpl = $stmt->fetch(); if (!$tpl) { $this->fail('Template not found', ['template' => $templateKey], 404); } - - $htmlCol = $this->resolveHtmlColumn($allCols, 'templates'); - $html = ($htmlCol && isset($tpl[$htmlCol])) ? (string)$tpl[$htmlCol] : ''; if ($html === '' && !empty($tpl['json_content'])) { $html = '(Dieses Template enthält noch keine HTML-Inhalte.)'; } @@ -957,9 +1769,9 @@ class ApiKernel $this->respond([ 'ok' => true, - 'template_id' => (int)($tpl[$idCol] ?? 0), - 'name' => $tpl[$nameCol] ?? null, - 'api_name' => $apiCol ? ($tpl[$apiCol] ?? null) : null, + 'template_id' => (int)($tpl['id'] ?? 0), + 'name' => $templateName, + 'api_name' => $apiName, 'html' => $html, ]); } @@ -1181,25 +1993,38 @@ class ApiKernel case 'templates.test_send': $this->handleTemplateTestSend(); break; + case 'sections_config.reorder': + $this->handleSectionsConfigReorder(); + break; /* ---------- CRUD HANDLER ---------- */ default: - if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets'])) { + if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config'])) { switch ($operation) { case 'list': - $this->handleList($kind); + if ($kind === 'content') $this->handleContentList(); + elseif ($kind === 'sections_config') $this->handleSectionsConfigList(); + else $this->handleList($kind); break; case 'get': - $this->handleGet($kind); + if ($kind === 'content') $this->handleContentGet(); + elseif ($kind === 'sections_config') $this->handleSectionsConfigGet(); + else $this->handleGet($kind); break; case 'create': - $this->handleCreate($kind); + if ($kind === 'content') $this->handleContentCreate(); + elseif ($kind === 'sections_config') $this->handleSectionsConfigCreate(); + else $this->handleCreate($kind); break; case 'update': - $this->handleUpdate($kind); + if ($kind === 'content') $this->handleContentUpdate(); + elseif ($kind === 'sections_config') $this->handleSectionsConfigUpdate(); + else $this->handleUpdate($kind); break; case 'delete': - $this->handleDelete($kind); + if ($kind === 'content') $this->handleContentDelete(); + elseif ($kind === 'sections_config') $this->handleSectionsConfigDelete(); + else $this->handleDelete($kind); break; default: $this->fail('Unknown operation for resource: ' . $this->action, null, 404); @@ -1252,13 +2077,39 @@ class ApiKernel 'renders_total' => 0, ]; - $map = $this->tableMap ?? []; - foreach (['templates', 'sections', 'blocks', 'snippets'] as $kind) { - $table = $map[$kind] ?? null; - if (!$table || !$this->tableExists($table)) continue; - $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid"); + if ($this->useUnifiedContent()) { + $itemsTable = $this->contentItemsTable(); + $sectionsTable = $this->contentSectionsTable(); + $stmt = $this->pdo->prepare("SELECT `id`,`slug` FROM `$sectionsTable` WHERE `customer_id` = :cid"); $stmt->execute([':cid' => $customerId]); - $counts[$kind] = (int)($stmt->fetchColumn() ?: 0); + $sections = $stmt->fetchAll() ?: []; + $bySlug = []; + foreach ($sections as $row) { + $slug = $row['slug'] ?? ''; + if ($slug !== '') $bySlug[$slug] = (int)$row['id']; + } + $slugs = [ + 'templates' => 'emailtemplate', + 'sections' => 'sections', + 'blocks' => 'blocks', + 'snippets' => 'snippets', + ]; + foreach ($slugs as $key => $slug) { + $sid = $bySlug[$slug] ?? null; + if (!$sid) continue; + $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid"); + $stmt->execute([':cid' => $customerId, ':sid' => $sid]); + $counts[$key] = (int)($stmt->fetchColumn() ?: 0); + } + } else { + $map = $this->tableMap ?? []; + foreach (['templates', 'sections', 'blocks', 'snippets'] as $kind) { + $table = $map[$kind] ?? null; + if (!$table || !$this->tableExists($table)) continue; + $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM `$table` WHERE `customer_id` = :cid"); + $stmt->execute([':cid' => $customerId]); + $counts[$kind] = (int)($stmt->fetchColumn() ?: 0); + } } $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage'); @@ -1273,29 +2124,57 @@ class ApiKernel private function listTemplateUsage(int $customerId): array { - $table = $this->tableMap['templates'] ?? null; - if (!$table || !$this->tableExists($table)) { - return []; - } - $usageTable = $this->lookupTableName('template_usage', 'emailtemplate_template_usage'); - if ($this->tableExists($usageTable)) { - $sql = "SELECT t.id, t.name, t.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at - FROM `$table` t - LEFT JOIN `$usageTable` u ON u.template_id = t.id - WHERE t.customer_id = :cid - ORDER BY render_count DESC, t.updated_at DESC"; - $stmt = $this->pdo->prepare($sql); - $stmt->execute([':cid' => $customerId]); - $rows = $stmt->fetchAll() ?: []; + if ($this->useUnifiedContent()) { + $itemsTable = $this->contentItemsTable(); + $sectionsTable = $this->contentSectionsTable(); + if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) { + return []; + } + $section = $this->ensureEmailtemplateSection($customerId); + if ($this->tableExists($usageTable)) { + $sql = "SELECT i.id, i.name, i.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at + FROM `$itemsTable` i + LEFT JOIN `$usageTable` u ON u.template_id = i.id + WHERE i.customer_id = :cid AND i.section_id = :sid + ORDER BY render_count DESC, i.updated_at DESC"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id']]); + $rows = $stmt->fetchAll() ?: []; + } else { + $sql = "SELECT i.id, i.name, i.updated_at FROM `$itemsTable` i WHERE i.customer_id = :cid AND i.section_id = :sid ORDER BY i.updated_at DESC"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id']]); + $rows = $stmt->fetchAll() ?: []; + foreach ($rows as &$row) { + $row['render_count'] = 0; + $row['last_rendered_at'] = null; + } + } } else { - $sql = "SELECT t.id, t.name, t.updated_at FROM `$table` t WHERE t.customer_id = :cid ORDER BY t.updated_at DESC"; - $stmt = $this->pdo->prepare($sql); - $stmt->execute([':cid' => $customerId]); - $rows = $stmt->fetchAll() ?: []; - foreach ($rows as &$row) { - $row['render_count'] = 0; - $row['last_rendered_at'] = null; + $table = $this->tableMap['templates'] ?? null; + if (!$table || !$this->tableExists($table)) { + return []; + } + + if ($this->tableExists($usageTable)) { + $sql = "SELECT t.id, t.name, t.updated_at, COALESCE(u.render_count, 0) AS render_count, u.last_rendered_at + FROM `$table` t + LEFT JOIN `$usageTable` u ON u.template_id = t.id + WHERE t.customer_id = :cid + ORDER BY render_count DESC, t.updated_at DESC"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':cid' => $customerId]); + $rows = $stmt->fetchAll() ?: []; + } else { + $sql = "SELECT t.id, t.name, t.updated_at FROM `$table` t WHERE t.customer_id = :cid ORDER BY t.updated_at DESC"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':cid' => $customerId]); + $rows = $stmt->fetchAll() ?: []; + foreach ($rows as &$row) { + $row['render_count'] = 0; + $row['last_rendered_at'] = null; + } } } @@ -1353,6 +2232,9 @@ class ApiKernel private function calculateUsage(string $kind, int $id, array $auth): array { if ($id <= 0) return ['total' => 0]; + if ($this->useUnifiedContent()) { + return ['total' => 0]; + } $summary = []; $templateItemsTable = $this->lookupTableName('template_items', 'emailtemplate_template_items'); @@ -1417,32 +2299,51 @@ class ApiKernel private function fetchResourceHtml(string $kind, int $id, array $auth, array &$cache, array &$stack): ?string { + if ($id <= 0) return null; $kindKey = $this->normalizeResourceKind($kind); - if (!$kindKey || $id <= 0) return null; - - $cacheKey = $kindKey . ':' . $id; + $cacheKey = ($kindKey ?: $kind) . ':' . $id; if (array_key_exists($cacheKey, $cache)) return $cache[$cacheKey]; if (!empty($stack[$cacheKey])) return null; + if ($this->useUnifiedContent()) { + $customerId = (int)($auth['customer_id'] ?? 0); + $sectionSlug = $this->resolveSectionSlugFromKind($kind); + $section = $this->fetchContentSectionBySlug($customerId, $sectionSlug); + if (!$section) { + $cache[$cacheKey] = null; + return null; + } + $itemsTable = $this->contentItemsTable(); + $sql = "SELECT `html`,`json_content` FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid AND `id` = :id LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $id]); + $row = $stmt->fetch(); + if (!$row) { + $cache[$cacheKey] = null; + return null; + } + $html = (string)($row['html'] ?? ''); + } else { + if (!$kindKey) return null; + $table = $this->tableMap[$kindKey] ?? null; + if (!$table) return null; + [$idCol, $allCols] = $this->resolveIdCol($kindKey); + [$tw, $tp] = $this->tenantWhere($auth); - $table = $this->tableMap[$kindKey] ?? null; - if (!$table) return null; - [$idCol, $allCols] = $this->resolveIdCol($kindKey); - [$tw, $tp] = $this->tenantWhere($auth); + $sql = "SELECT * FROM `$table` 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) { + $cache[$cacheKey] = null; + return null; + } - $sql = "SELECT * FROM `$table` 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) { - $cache[$cacheKey] = null; - return null; + $htmlCol = $this->resolveHtmlColumn($allCols, $kindKey); + $html = $htmlCol && isset($row[$htmlCol]) ? (string)$row[$htmlCol] : ''; } - $htmlCol = $this->resolveHtmlColumn($allCols, $kindKey); - $html = $htmlCol && isset($row[$htmlCol]) ? (string)$row[$htmlCol] : ''; - $stack[$cacheKey] = true; $html = $this->renderHtmlWithReferences($html, $auth, $cache, $stack); unset($stack[$cacheKey]); @@ -1473,11 +2374,11 @@ class ApiKernel if (!$loaded) return $html; $xpath = new DOMXPath($doc); - $nodes = $xpath->query('//*[@data-lib-kind and @data-lib-id]'); + $nodes = $xpath->query('//*[@data-lib-id and (@data-lib-kind or @data-lib-section)]'); if ($nodes !== false) { foreach ($nodes as $node) { /** @var \DOMElement $node */ - $kind = $node->getAttribute('data-lib-kind'); + $kind = $node->getAttribute('data-lib-section') ?: $node->getAttribute('data-lib-kind'); $refId = (int)$node->getAttribute('data-lib-id'); if (!$kind || $refId <= 0) continue; $replacement = $this->fetchResourceHtml($kind, $refId, $auth, $cache, $stack);
Die Sortierung steuert, welche Inhalte in anderen Sections eingebunden werden dürfen.
(Dieses Template enthält noch keine HTML-Inhalte.)