This commit is contained in:
2025-12-04 22:33:05 +01:00
parent 316175e158
commit 9dee06cdd6
145 changed files with 16865 additions and 88 deletions

View File

@@ -0,0 +1,438 @@
/* /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';
export function initEditor() {
  // ... (Alle Konstanten bleiben unverändert) ...
  const dlg          = document.getElementById('editorDialog');
  const iframe       = document.getElementById('editorFrame');
  const btnSave      = document.getElementById('btn-save');
  const btnPreview   = document.getElementById('btn-preview');
  const btnTest      = document.getElementById('btn-test');
  const btnClose     = document.getElementById('btn-close');
  const btnClear     = document.getElementById('btn-clear-main');
  const prevDlg      = document.getElementById('previewDialog');
  const sendDlg      = document.getElementById('sendTestDialog');
  const sendForm     = document.getElementById('sendTestForm');
  const sendTo       = document.getElementById('send_to');
  const sendSubject  = document.getElementById('send_subject');
  const btnCancelSend= document.getElementById('btn-cancel-send');
  const btnSendNow   = document.getElementById('btn-send-now');
  const prevFrame    = document.getElementById('previewFrame');
  const btnPrevClose = document.getElementById('btn-close-preview');
  let current = null;   // { resource, id, name }
  let bridgeListener = null;
  let reqToken = 0;     // steigender Token pro Öffnen -> ignoriert verspätete Events
  const ok  = (m) => toast(m, true);
  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 writeHtmlToFrame(html) {
    iframe.srcdoc = `<!doctype html><html>
      <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
      <body id="gjs">${html || ''}</body>
    </html>`;
  }
  async function readEditedHtml() {
    const win = iframe?.contentWindow;
    const doc = iframe?.contentDocument;
    if (!win || !doc) return '';
    const ed = win.__gjs || (win.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
    if (ed && typeof ed.getHtml === 'function') {
      const html = ed.getHtml();
      const css  = (typeof ed.getCss === 'function') ? ed.getCss() : '';
      return css ? `<style>${css}</style>\n${html}` : html;
    }
    const root = doc.querySelector('#gjs') || doc.body || doc.documentElement;
    return root ? root.innerHTML : '';
  }
  function waitForEditor(maxMs = 8000) {
    return new Promise((resolve, reject) => {
      const start = Date.now();
      (function poll() {
        const win = iframe?.contentWindow;
        const ed  = win?.__gjs || (win?.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
        if (ed) return resolve(ed);
        if (Date.now() - start > maxMs) return reject(new Error('Editor not ready'));
        setTimeout(poll, 120);
      })();
    });
  }
  
  // 🚨 NEUE FUNKTION: Delegiert das Kommando an den Editor im iFrame
  async function delegateCommand(commandName) {
    try {
      const editor = await waitForEditor(3000);
      if (editor.Commands.has(commandName)) {
        // Führt den Command im iFrame aus (z.B. 'save-data')
        editor.runCommand(commandName);
        return true;
      } else {
        err(`Delegieren fehlgeschlagen: Command '${commandName}' nicht gefunden.`);
        return false;
      }
    } catch (e) {
      err(`Delegieren fehlgeschlagen: Editor nicht bereit (${commandName}).`);
      console.error(e);
      return false;
    }
  }
  // ... (hideReadyBadge bleibt unverändert) ...
  function hideReadyBadge(doc) {
    if (!doc) return;
    const kill = () => {
      const el = doc.getElementById('badge');
      if (el) el.style.display = 'none';
    };
    kill();
    const style = doc.createElement('style');
    style.textContent = `
      #badge { display:none !important; }
      .gjs-pn-status { display:none !important; }
      .ready-badge,
      .status-badge.ready,
      [data-status="ready"],
      [data-badge="ready"],
      .gjs-ready,
      .gjs-badge-ready { display:none !important; }
    `;
    doc.head.appendChild(style);
    const mo = new MutationObserver(() => { kill(); /* hideByText(doc); */ });
    mo.observe(doc.documentElement, { childList: true, subtree: true });
    setTimeout(() => { kill(); /* hideByText(doc); */ }, 150);
    setTimeout(() => { kill(); /* hideByText(doc); */ }, 500);
    setTimeout(() => { kill(); /* hideByText(doc); */ }, 1200);
  }
  
  // ... (Lade-Overlay bleibt unverändert) ...
  let veilEl = null;
  function ensureVeil() {
    if (veilEl) return veilEl;
    veilEl = document.createElement('div');
    Object.assign(veilEl.style, {
      position:'absolute', inset:'0', background:'rgba(248,250,252,.85)',
      display:'flex', alignItems:'center', justifyContent:'center',
      zIndex:'2147483000', fontFamily:'system-ui, -apple-system, Segoe UI, Roboto, Arial',
      fontSize:'14px', color:'#0f172a'
    });
    veilEl.innerHTML = `
      <div style="display:flex;flex-direction:column;gap:.6rem;align-items:center;">
        <div class="spinner" style="width:28px;height:28px;border-radius:999px;border:3px solid #cbd5e1;border-top-color:#0ea5e9;animation:spin .8s linear infinite"></div>
        <div style="font-weight:500;">Lade Editor …</div>
      </div>
      <style>@keyframes spin{to{transform:rotate(360deg)}}</style>
    `;
    const host = dlg?.querySelector('.h-full, .flex, .flex-col') || dlg;
    (host || document.body).appendChild(veilEl);
    return veilEl;
  }
  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 || '' }));
  }
  // ---------- Initialen HTML-Inhalt in Editor pushen (mit Token/Race-Schutz) ----------
  async function pushInitialHtmlToEditor({ mode, html, snippets, ref, token }) {
    if (token !== reqToken) return; // veraltete Anfrage ignorieren
    const win = iframe?.contentWindow;
    const doc = iframe?.contentDocument;
    // NEU: HTML wird NUR über postMessage gesendet. Die Bridge im iFrame ist verantwortlich
    // dafür, das HTML in GrapesJS zu setzen, NACHDEM ihre Plugins fertig sind.
    try {
      win?.postMessage({ source:'admin', type:'init', mode, html: html || '', snippets: snippets || [], ref: ref || {} }, '*');
    } catch {}
    try {
      // Warten auf Editor ist noch sinnvoll, um das Lade-Badge zu unterdrücken,
      // aber wir manipulieren den Editor NICHT MEHR direkt von hier aus.
      await waitForEditor(6000); 
      if (token !== reqToken) return;
      
      // ... (Gelöschte Logik: ed.setComponents(html) ist nun in der Bridge-Logik) ...
    } catch {
      /* Falls GJS noch nicht bereit ist, arbeiten wir nur via postMessage. */
    }
    try { hideReadyBadge(doc); } catch {}
    if (token === reqToken) hideVeil();
  }
  // ---------- Öffnen ----------
  async function open(item, resource) {
    current = {
      resource: String(resource || activeMode() || 'templates').toLowerCase(),
      id: Number(item?.id || 0),
      name: item?.name || ''
    };
    if (!current.id) return err('Ungültige ID');
    // globaler Kontext
    window.__currentItemId    = current.id;
    window.__currentEditorCtx = { id: current.id, mode: current.resource };
    // Neuen Token erzeugen & alten Listener entfernen
    reqToken++;
    const myToken = reqToken;
    if (bridgeListener) window.removeEventListener('message', bridgeListener);
    bridgeListener = null;
    // Overlay zeigen
    showVeil();
    // Daten parallel laden (fresh HTML + kontextgefilterte Snippets + Referenzen)
    let fresh = '';
    let snippets = [];
    let refLib = { sections: [], blocks: [] };
    await Promise.all([
      (async() => {
        try {
          const row = await apiGet(current.resource, current.id);
          // API liefert jetzt top-level html/content; fallback auf item.*
          fresh = row?.html ?? row?.content ?? row?.item?.html ?? row?.item?.content ?? '';
        } catch {}
      })(),
      (async() => { snippets = await buildSnippetsForContext(current); })(),
      (async() => { refLib   = await buildRefLibForContext(current); })()
    ]);
    // iFrame-Load -> Bridge-Ready abhören
    iframe.onload = function () {
      if (myToken !== reqToken) return;
      try { hideReadyBadge(iframe.contentDocument); } catch {}
      bridgeListener = (ev) => {
        const d = ev?.data || {};
        if (!d) return;
        // wir erwarten Nachrichten aus der Bridge/Editor
        if (d.source !== 'bridge' && d.source !== 'editor') return;
        if (myToken !== reqToken) return;
        // NEU: Wenn der Editor meldet, dass er *gespeichert* hat,
        // aktualisieren wir die Liste im Elternfenster
        if (d.type === 'save:success') {
          ok('Gespeichert');
          try {
            if (typeof window.reloadActiveList === 'function') window.reloadActiveList();
            else if (typeof window.__reloadList === 'function') window.__reloadList(current.resource);
          } catch {}
          return;
        }
        
        // 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,
            html: fresh,
            snippets,
            ref: {
              sections: (refLib.sections || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' })),
              blocks:   (refLib.blocks   || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' }))
            },
            token: myToken
          });
        }
      };
      window.addEventListener('message', bridgeListener);
      // Fallback, falls kein Ready ankommt
      setTimeout(() => {
        pushInitialHtmlToEditor({
          mode: current.resource,
          html: fresh,
          snippets,
          ref: {
            sections: (refLib.sections || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' })),
            blocks:   (refLib.blocks   || []).map(r => ({ id:r.id, name:r.name, html:r.html || '' }))
          },
          token: myToken
        });
      }, 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()}`;
    dlg?.showModal?.();
  }
  // ---------- Speichern (DELEGIERT) ----------
  // 🚨 KORRIGIERT: Delegiert Speichern an den iFrame, der die JSON-Daten holt!
  async function save() {
    if (!current?.id) return err('Keine aktive ID');
    const mode = activeMode();
    if (mode !== 'snippets') { // Nur Templates/Blocks/Sections delegieren, Snippets behalten die alte Logik (NUR HTML)
      return delegateCommand('save-data');
    }
    
    // Alte Snippet-Logik beibehalten (falls der Snippet-Editor nicht GrapesJS ist und nur HTML erwartet)
    const liveHtml = await readEditedHtml();
    const payload = { id: current.id, content: liveHtml };
    const res = await apiUpdate(mode, current.id, payload);
    if (!res?.ok) { err('Speichern fehlgeschlagen'); return; }
    ok('Gespeichert');
    try {
      if (typeof window.reloadActiveList === 'function') await window.reloadActiveList();
      else if (typeof window.__reloadList === 'function') window.__reloadList(mode);
    } catch {}
  }
  // ... (Der Rest der Funktionen bleibt unverändert) ...
  async function clearEditor() {
    const win = iframe?.contentWindow;
    const ed  = win?.__gjs || (win?.grapesjs && win.grapesjs.editors && win.grapesjs.editors[0]) || null;
    if (ed) {
      ed.setComponents('');
      ed.setStyle('');
    } else {
      writeHtmlToFrame('');
    }
  }
  async function openPreview() {
    const html = await readEditedHtml();
    prevFrame.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${html || '<em>(leer)</em>'}</body></html>`;
    prevDlg?.showModal?.();
  }
  
  async function openSend() {
    sendSubject.value = 'Testversand';
    sendTo.value = '';
    sendDlg?.showModal?.();
  }
  function closeSend(){ sendDlg?.close?.(); }
  async function doSend(ev){
    ev?.preventDefault?.();
    const to = sendTo.value.trim();
    if(!to){ toast("Bitte Empfänger angeben", false); return; }
    const win = iframe?.contentWindow;
    const ctx = (win && win.__currentEditorCtx) || {};
    const id  = (window.__currentItemId || ctx?.id || 0);
    if(!id){ toast("Kein Template geladen", false); return; }
    // Hier wird der gespeicherte HTML-Code verwendet, nicht der Live-HTML, da apiAction
    // keine Live-Daten erwartet. Es geht um template_id.
    const r = await apiAction('templates.test_send', { method:'POST', data:{ template_id: id, to, subject: sendSubject.value || 'Testversand' } });
    if(r?.ok){ toast("Testversand ausgelöst"); closeSend(); } else { toast("Senden fehlgeschlagen", false); }
  }
  function closePreview(){ prevDlg?.close?.(); }
  function close() {
    // nächstes Öffnen invalidiert laufende asyncs
    reqToken++;
    try { iframe.contentWindow?.postMessage({source:'admin',type:'reset'}, '*'); } catch {}
    if (bridgeListener) window.removeEventListener('message', bridgeListener);
    bridgeListener = null;
    hideVeil();
    dlg?.close?.();
    // iFrame zurück auf blank
    iframe.src = 'about:blank#' + Date.now();
    // Kontext leeren
    current = null;
    window.__currentItemId = undefined;
    window.__currentEditorCtx = undefined;
  }
  // Buttons
  btnSave      && (btnSave.onclick      = save);
  btnClear     && (btnClear.onclick     = clearEditor);
  btnClose     && (btnClose.onclick     = close);
  btnPrevClose && (btnPrevClose.onclick = closePreview);
  btnPreview   && (btnPreview.onclick   = openPreview);
  btnTest      && (btnTest.onclick      = openSend);
  btnCancelSend&& (btnCancelSend.onclick= closeSend);
  sendForm     && (sendForm.onsubmit    = doSend);
  // Public API
  window.EditorUI = { open, save, close, clear: clearEditor, preview: openPreview };
}
// Default-Export + globaler Fallback
export default initEditor;
window.initEditor = initEditor;