From a09a07bcfcdef5d469f2dedd0c4dd2e430c66a36 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Wed, 21 Jan 2026 22:44:32 +0100 Subject: [PATCH] adsad --- public/assets/js/bridge/blocks-api.js | 6 ++ public/assets/js/bridge/library-api.js | 2 +- public/assets/js/ui-editor.js | 131 ++++++++++++++++++++--- public/assets/js/ui-list.js | 63 ++++++++++- public/index.php | 13 +++ schema.sql | 2 + src/ApiKernel.php | 139 ++++++++++++++++++++++--- 7 files changed, 324 insertions(+), 32 deletions(-) diff --git a/public/assets/js/bridge/blocks-api.js b/public/assets/js/bridge/blocks-api.js index 41c6bf6..6c66495 100644 --- a/public/assets/js/bridge/blocks-api.js +++ b/public/assets/js/bridge/blocks-api.js @@ -502,6 +502,12 @@ // 🚨 KRITISCH: Server erwartet das Feld 'json' json: jsonProjectDataRaw, }; + const activateNext = B.NEXT_ACTIVATE_VERSION || window.NEXT_ACTIVATE_VERSION; + if (activateNext) { + dataToSend.activate_version = 1; + B.NEXT_ACTIVATE_VERSION = 0; + window.NEXT_ACTIVATE_VERSION = 0; + } if (SECTION_ID) { dataToSend.section_id = SECTION_ID; } diff --git a/public/assets/js/bridge/library-api.js b/public/assets/js/bridge/library-api.js index 4353fca..345b2b0 100644 --- a/public/assets/js/bridge/library-api.js +++ b/public/assets/js/bridge/library-api.js @@ -191,7 +191,7 @@ return []; } const promises = sections.map(section => - fetchData('content', 'list', { section_id: section.id }) + fetchData('content', 'list', { section_id: section.id, active_only: 1 }) .then(items => (Array.isArray(items) ? items : []).map(i => ({ ...i, kind: String(section.slug || '').toLowerCase(), diff --git a/public/assets/js/ui-editor.js b/public/assets/js/ui-editor.js index 7650fa9..fe4ba2b 100644 --- a/public/assets/js/ui-editor.js +++ b/public/assets/js/ui-editor.js @@ -33,6 +33,11 @@ export function initEditor() { const btnUnsavedCancel = document.getElementById('btn-unsaved-cancel'); const btnUnsavedDiscard = document.getElementById('btn-unsaved-discard'); const btnUnsavedSave = document.getElementById('btn-unsaved-save'); + const btnDeactivateVersion = document.getElementById('btn-deactivate-version'); + const activateDialog = document.getElementById('activateVersionDialog'); + const btnActivateCancel = document.getElementById('btn-activate-cancel'); + const btnActivateNo = document.getElementById('btn-activate-no'); + const btnActivateYes = document.getElementById('btn-activate-yes'); let current = null; // { resource, id, name, section } let bridgeListener = null; @@ -49,6 +54,9 @@ export function initEditor() { let suppressDirty = false; let suppressTimer = null; let baselineReady = false; + let versionMap = new Map(); + let currentVersionId = 0; + let currentVersionMeta = null;   const ok  = (m) => toast(m, true);   const err = (m) => toast(m, false); @@ -94,6 +102,7 @@ export function initEditor() { function setVersionUiVisible(show) { if (versionSelect) versionSelect.classList.toggle('hidden', !show); // restore button removed + if (btnDeactivateVersion) btnDeactivateVersion.classList.toggle('hidden', !show); } setVersionUiVisible(false); @@ -262,11 +271,49 @@ export function initEditor() { return choice; } + function showActivateDialog() { + return new Promise((resolve) => { + if (!activateDialog || typeof activateDialog.showModal !== 'function') { + resolve('no'); + return; + } + const cleanup = () => { + btnActivateCancel && btnActivateCancel.removeEventListener('click', onCancel); + btnActivateNo && btnActivateNo.removeEventListener('click', onNo); + btnActivateYes && btnActivateYes.removeEventListener('click', onYes); + }; + const closeWith = (choice) => { + cleanup(); + activateDialog.close(); + resolve(choice); + }; + const onCancel = () => closeWith('cancel'); + const onNo = () => closeWith('no'); + const onYes = () => closeWith('yes'); + btnActivateCancel && btnActivateCancel.addEventListener('click', onCancel); + btnActivateNo && btnActivateNo.addEventListener('click', onNo); + btnActivateYes && btnActivateYes.addEventListener('click', onYes); + activateDialog.showModal(); + }); + } + + function updateVersionMeta(id) { + const key = id ? String(id) : ''; + currentVersionId = id ? Number(id) : 0; + currentVersionMeta = key && versionMap.has(key) ? versionMap.get(key) : null; + if (btnDeactivateVersion) { + const isActive = !!(currentVersionMeta && Number(currentVersionMeta.is_active) === 1); + btnDeactivateVersion.classList.toggle('hidden', !isActive); + } + } + function renderVersionOptions(items) { versionItems = items || []; - if (!versionSelect) return; + versionMap = new Map(); + if (!versionSelect) return ''; const rows = Array.isArray(versionItems) ? versionItems : []; versionSelect.innerHTML = ''; + lastVersionSelection = ''; if (!rows.length) { const opt = document.createElement('option'); opt.value = ''; @@ -274,33 +321,41 @@ export function initEditor() { opt.disabled = true; versionSelect.appendChild(opt); versionSelect.disabled = true; - return; + updateVersionMeta(0); + return ''; } versionSelect.disabled = false; - rows.forEach((item, idx) => { + let activeId = ''; + rows.forEach((item) => { const opt = document.createElement('option'); - const label = `#${item.version_no} – ${formatVersionDate(item.created_at)}`; + const label = `#${item.version_no} – ${formatVersionDate(item.created_at)}` + (Number(item.is_active) === 1 ? ' (aktiv)' : ''); opt.value = String(item.id); opt.textContent = label; versionSelect.appendChild(opt); - if (!lastVersionSelection && idx === 0) { - lastVersionSelection = String(item.id); - } + versionMap.set(String(item.id), item); + if (Number(item.is_active) === 1 && !activeId) activeId = String(item.id); }); - if (lastVersionSelection) versionSelect.value = lastVersionSelection; + const fallbackId = activeId || (rows[0] ? String(rows[0].id) : ''); + if (fallbackId) { + lastVersionSelection = fallbackId; + versionSelect.value = fallbackId; + updateVersionMeta(Number(fallbackId)); + } + return lastVersionSelection; } async function loadVersionsForCurrent() { if (!current?.id) { renderVersionOptions([]); - return; + return ''; } try { const res = await apiAction('content_versions.list', { method: 'GET', data: { content_id: current.id, id: current.id } }); if (!res?.ok) throw new Error(res?.error || 'Versionen konnten nicht geladen werden'); - renderVersionOptions(Array.isArray(res?.items) ? res.items : []); + return renderVersionOptions(Array.isArray(res?.items) ? res.items : []); } catch { renderVersionOptions([]); + return ''; } } @@ -594,9 +649,11 @@ export function initEditor() {     // Overlay zeigen     showVeil(); -    // Daten parallel laden (fresh HTML + kontextgefilterte Snippets + Referenzen) -    let fresh = ''; -    let snippets = []; + const requestedVersionId = Number(item?.version_id || item?.versionId || 0); + + // Daten parallel laden (fresh HTML + kontextgefilterte Snippets + Referenzen) + let fresh = ''; + let snippets = []; let refLib = { sections: [], blocks: [] }; let hasJson = false; let jsonState = ''; @@ -604,6 +661,7 @@ export function initEditor() { let editorType = 'grapesjs'; let craftJson = ''; + let defaultVersionId = '';     await Promise.all([       (async() => { try { @@ -624,9 +682,25 @@ export function initEditor() { })(),       (async() => { snippets = await buildSnippetsForContext(current); })(),       (async() => { refLib   = await buildRefLibForContext(current); })(), - (async() => { await loadVersionsForCurrent(); })() + (async() => { defaultVersionId = await loadVersionsForCurrent(); })()     ]); + const effectiveVersionId = requestedVersionId ? String(requestedVersionId) : defaultVersionId; + if (effectiveVersionId) { + try { + const res = await apiAction('content_versions.get', { method: 'GET', data: { id: effectiveVersionId, content_id: current.id } }); + if (res?.ok && res?.item) { + const fields = extractContentFields(res.item); + fresh = fields.html || ''; + jsonState = fields.json || ''; + editorType = fields.editorType || editorType; + craftJson = fields.craftJson || ''; + updateVersionMeta(Number(effectiveVersionId)); + lastVersionSelection = String(effectiveVersionId); + } + } catch {} + } + editorType = editorType === 'craftjs' ? 'craftjs' : 'grapesjs'; setSavedSnapshotFromData({ html: fresh, content: jsonState, editor_type: editorType, craft_json: craftJson }); setEditorType(editorType); @@ -744,12 +818,20 @@ export function initEditor() { async function save() { if (!current?.id) return err('Keine aktive ID'); + let activateNext = false; + if (currentVersionMeta && (Number(currentVersionMeta.is_active) === 1 || Number(currentVersionMeta.was_active) === 1)) { + const decision = await showActivateDialog(); + if (decision === 'cancel') return false; + activateNext = decision === 'yes'; + } + if (currentEditorType === 'craftjs') { const html = craftEditor ? craftEditor.getContent() : ''; const craftJson = craftEditor && craftEditor.getCraftJson ? craftEditor.getCraftJson() : JSON.stringify({ html }); const payload = { html, craft_json: craftJson, editor_type: 'craftjs', section_id: current.section.id }; + if (activateNext) payload.activate_version = 1; const res = await apiUpdate('content', current.id, payload); if (res?.ok) ok('Gespeichert'); else err(res?.error || 'Speichern fehlgeschlagen'); @@ -757,6 +839,14 @@ export function initEditor() { return res?.ok; } + if (activateNext) { + const win = iframe?.contentWindow; + if (win && win.BridgeParts) { + win.BridgeParts.NEXT_ACTIVATE_VERSION = 1; + } else if (win) { + win.NEXT_ACTIVATE_VERSION = 1; + } + } const okSave = await delegateCommand('save-data'); if (okSave) { setTimeout(async () => { @@ -921,6 +1011,18 @@ export function initEditor() { btnCancelSend&& (btnCancelSend.onclick= closeSend); sendForm && (sendForm.onsubmit = doSend); editorSelect && (editorSelect.onchange = () => switchEditor(editorSelect.value)); + btnDeactivateVersion && (btnDeactivateVersion.onclick = async () => { + if (!current?.id) return; + if (!currentVersionMeta || Number(currentVersionMeta.is_active) !== 1) return; + try { + const res = await apiAction('content_versions.deactivate', { method: 'POST', data: { content_id: current.id } }); + if (!res?.ok) throw new Error(res?.error || 'Deaktivieren fehlgeschlagen'); + await loadVersionsForCurrent(); + toast('Aktive Version deaktiviert', true); + } catch (e) { + err(e.message || 'Deaktivieren fehlgeschlagen'); + } + }); versionSelect && (versionSelect.onchange = async () => { if (!current?.id) return; const previousSelection = lastVersionSelection; @@ -939,6 +1041,7 @@ export function initEditor() { if (!res?.ok) throw new Error(res?.error || 'Version konnte nicht geladen werden'); await applyVersionPayload(res?.item || res); lastVersionSelection = String(versionId); + updateVersionMeta(versionId); } catch (e) { err(e.message || 'Version konnte nicht geladen werden'); versionSelect.value = previousSelection; diff --git a/public/assets/js/ui-list.js b/public/assets/js/ui-list.js index aac5673..547ed3d 100644 --- a/public/assets/js/ui-list.js +++ b/public/assets/js/ui-list.js @@ -29,6 +29,7 @@ async function fetchContentItem(id, sectionId) { } async function openContentEditor(item, section) { + const versionId = Number(item?.version_id || 0); const id = Number(item?.id || 0); const name = item?.name || ''; if (!id) return; @@ -39,9 +40,9 @@ async function openContentEditor(item, section) { window.__currentEditorCtx = { id, mode: section.slug, section }; if (window.EditorUI && typeof window.EditorUI.open === 'function') { - window.EditorUI.open({ id, name, html, section }, 'content'); + window.EditorUI.open({ id, name, html, section, version_id: versionId }, 'content'); } else if (window.__openEditor) { - window.__openEditor({ resource: 'content', id, name, html, section }); + window.__openEditor({ resource: 'content', id, name, html, section, version_id: versionId }); } else { toast('Editor ist nicht initialisiert.', false); } @@ -189,11 +190,56 @@ export async function loadList(section) { return name.includes(q) || (api && api.includes(q)); } + const versionCache = new Map(); + + async function loadVersionOptions(selectEl, itemId) { + if (!selectEl || !itemId) return; + if (versionCache.has(itemId)) { + const cached = versionCache.get(itemId); + renderVersionSelect(selectEl, cached.items, cached.activeId); + return; + } + try { + const res = await apiAction('content_versions.list', { method: 'GET', data: { content_id: itemId } }); + const items = Array.isArray(res?.items) ? res.items : []; + const active = items.find(v => Number(v.is_active) === 1); + const activeId = active ? String(active.id) : (items[0] ? String(items[0].id) : ''); + versionCache.set(itemId, { items, activeId }); + renderVersionSelect(selectEl, items, activeId); + } catch { + renderVersionSelect(selectEl, [], ''); + } + } + + function renderVersionSelect(selectEl, items, activeId) { + selectEl.innerHTML = ''; + if (!items.length) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'Keine Versionen'; + opt.disabled = true; + selectEl.appendChild(opt); + selectEl.disabled = true; + return; + } + selectEl.disabled = false; + items.forEach(item => { + const opt = document.createElement('option'); + opt.value = String(item.id); + opt.textContent = `#${item.version_no}` + (Number(item.is_active) === 1 ? ' (aktiv)' : ''); + selectEl.appendChild(opt); + }); + if (activeId) selectEl.value = activeId; + } + 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 versionSelect = ``; const nameCell = `
${name || '(ohne Name)'}
${apiLine} @@ -207,6 +253,7 @@ export async function loadList(section) { return `
${nameCell}
#${item.id}
+ ${versionSelect}
${[openBtn, editTplBtn, testBtn, prevBtn, delBtn].filter(Boolean).join('')}
`; }).join(''); @@ -250,6 +297,18 @@ export async function loadList(section) { applyFilter(); function bindListHandlers(scope) { + scope.querySelectorAll('[data-version-select]').forEach(sel => { + const id = Number(sel.getAttribute('data-version-select') || 0); + sel.addEventListener('focus', () => loadVersionOptions(sel, id)); + sel.addEventListener('click', () => loadVersionOptions(sel, id)); + sel.addEventListener('change', () => { + const versionId = Number(sel.value || 0); + if (!versionId) return; + const item = data.find(it => Number(it.id) === id); + if (!item) return; + openContentEditor({ ...item, version_id: versionId }, section); + }); + }); 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); diff --git a/public/index.php b/public/index.php index 83cb2d0..7d1b0ee 100644 --- a/public/index.php +++ b/public/index.php @@ -62,6 +62,7 @@ require __DIR__ . '/../partials/structure/layout_start.php'; + @@ -125,6 +126,18 @@ require __DIR__ . '/../partials/structure/layout_start.php';
+ +
+

Neue Version aktivieren

+

Soll die neu gespeicherte Version als aktiv gesetzt werden?

+
+ + + +
+
+
+
diff --git a/schema.sql b/schema.sql index b22cbd1..f54d64d 100644 --- a/schema.sql +++ b/schema.sql @@ -321,6 +321,8 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_content_versions` ( `content_id` int(10) unsigned NOT NULL, `section_id` int(10) unsigned NOT NULL, `version_no` int(10) unsigned NOT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 0, + `was_active` tinyint(1) NOT NULL DEFAULT 0, `editor_type` varchar(32) DEFAULT NULL, `json_content` mediumtext DEFAULT NULL, `html` mediumtext DEFAULT NULL, diff --git a/src/ApiKernel.php b/src/ApiKernel.php index 4285201..e1e48d5 100644 --- a/src/ApiKernel.php +++ b/src/ApiKernel.php @@ -372,6 +372,8 @@ class ApiKernel 'editor' => $this->firstExisting($cols, ['editor_type', 'editor']), 'craft' => $this->firstExisting($cols, ['craft_json', 'craft_content', 'craft_data']), 'settings' => $this->firstExisting($cols, ['settings_json', 'settings']), + 'is_active' => $this->firstExisting($cols, ['is_active']), + 'was_active' => $this->firstExisting($cols, ['was_active']), ]; } @@ -380,12 +382,12 @@ class ApiKernel return $this->tableExists($this->contentItemsTable()) && $this->tableExists($this->contentSectionsTable()); } - private function createContentVersion(array $current, array $itemCols, int $customerId, int $sectionId): void + private function createContentVersion(array $current, array $itemCols, int $customerId, int $sectionId): ?int { $table = $this->contentVersionsTable(); - if (!$this->tableExists($table)) return; + if (!$this->tableExists($table)) return null; $contentId = (int)($current['id'] ?? 0); - if ($contentId <= 0) return; + if ($contentId <= 0) return null; $jsonCol = $itemCols['json'] ?? null; $htmlCol = $itemCols['html'] ?? null; @@ -426,6 +428,7 @@ class ApiKernel $stmt = $this->pdo->prepare("INSERT INTO `$table` ($insertCols) VALUES ($placeholders)"); foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); + $newId = (int)$this->pdo->lastInsertId(); $cleanup = $this->pdo->prepare( "DELETE FROM `$table` WHERE `id` IN ( @@ -435,11 +438,32 @@ class ApiKernel )" ); $cleanup->execute([':cid' => $contentId]); + return $newId; } catch (Throwable $e) { // Versioning darf nicht das Speichern blockieren. + return null; } } + private function activateContentVersion(int $customerId, int $contentId, int $versionId): void + { + $table = $this->contentVersionsTable(); + if (!$this->tableExists($table)) return; + $this->pdo->prepare("UPDATE `$table` SET `is_active` = 0 WHERE `customer_id` = :cid AND `content_id` = :content") + ->execute([':cid' => $customerId, ':content' => $contentId]); + $this->pdo->prepare( + "UPDATE `$table` SET `is_active` = 1, `was_active` = 1 WHERE `customer_id` = :cid AND `content_id` = :content AND `id` = :id LIMIT 1" + )->execute([':cid' => $customerId, ':content' => $contentId, ':id' => $versionId]); + } + + private function deactivateContentVersion(int $customerId, int $contentId): void + { + $table = $this->contentVersionsTable(); + if (!$this->tableExists($table)) return; + $this->pdo->prepare("UPDATE `$table` SET `is_active` = 0 WHERE `customer_id` = :cid AND `content_id` = :content") + ->execute([':cid' => $customerId, ':content' => $contentId]); + } + private function isLegacyContentKind(string $kind): bool { return in_array($kind, ['templates', 'sections', 'blocks', 'snippets'], true); @@ -635,6 +659,7 @@ class ApiKernel $itemsTable = $this->contentItemsTable(); $sectionsTable = $this->contentSectionsTable(); + $versionsTable = $this->contentVersionsTable(); if (!$this->tableExists($itemsTable) || !$this->tableExists($sectionsTable)) { $this->fail('Content tables not available', null, 500); } @@ -642,6 +667,10 @@ class ApiKernel $catCol = $itemCols['category']; $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; + $onlyActive = (int)$this->val($this->in, ['active_only', 'only_active', 'active'], 0) === 1; + $versionCols = $onlyActive && $this->tableExists($versionsTable) + ? $this->resolveContentVersionColumns($versionsTable) + : null; $section = $fixedSection ?: $this->resolveSectionFromInput($customerId); $q = trim((string)$this->val($this->in, 'q', '')); @@ -663,9 +692,20 @@ class ApiKernel $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 + $join = ''; + $select = "i.*, s.`name` AS section_name, s.`slug` AS section_slug, s.`position` AS section_position, s.`is_template` AS section_is_template"; + if ($onlyActive && $versionCols) { + $join = " JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1"; + $select .= ", v.`id` AS active_version_id, v.`version_no` AS active_version_no, v.`is_active` AS version_is_active, v.`was_active` AS version_was_active"; + if (!empty($versionCols['html'])) $select .= ", v.`{$versionCols['html']}` AS version_html"; + if (!empty($versionCols['json'])) $select .= ", v.`{$versionCols['json']}` AS version_json"; + if (!empty($versionCols['craft'])) $select .= ", v.`{$versionCols['craft']}` AS version_craft"; + if (!empty($versionCols['editor'])) $select .= ", v.`{$versionCols['editor']}` AS version_editor"; + } + $sql = "SELECT $select FROM `$itemsTable` i JOIN `$sectionsTable` s ON s.`id` = i.`section_id` + $join $where ORDER BY i.`updated_at` DESC, i.`id` DESC LIMIT :off,:lim"; @@ -691,8 +731,16 @@ class ApiKernel 'updated_at' => $r['updated_at'] ?? null, 'created_at' => $r['created_at'] ?? null, ]; - if ($htmlCol && array_key_exists($htmlCol, $r)) $item['html'] = (string)($r[$htmlCol] ?? ''); - if ($jsonCol && array_key_exists($jsonCol, $r)) $item['content'] = $r[$jsonCol]; + if ($onlyActive && $versionCols) { + if (!empty($r['active_version_id'])) $item['active_version_id'] = (int)$r['active_version_id']; + if (array_key_exists('version_html', $r)) $item['html'] = (string)($r['version_html'] ?? ''); + if (array_key_exists('version_json', $r)) $item['content'] = $r['version_json']; + if (array_key_exists('version_craft', $r)) $item['craft_json'] = $r['version_craft']; + if (array_key_exists('version_editor', $r)) $item['editor_type'] = $r['version_editor']; + } else { + if ($htmlCol && array_key_exists($htmlCol, $r)) $item['html'] = (string)($r[$htmlCol] ?? ''); + if ($jsonCol && array_key_exists($jsonCol, $r)) $item['content'] = $r[$jsonCol]; + } $out[] = $item; } @@ -857,12 +905,16 @@ class ApiKernel foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); $newId = (int)$this->pdo->lastInsertId(); + $activateVersion = (int)$this->val($this->in, ['activate_version', 'activate', 'set_active'], 0) === 1; try { $stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); $stmt->execute([':id' => $newId, ':cid' => $customerId]); $row = $stmt->fetch(); if ($row) { - $this->createContentVersion($row, $itemCols, $customerId, (int)$section['id']); + $vid = $this->createContentVersion($row, $itemCols, $customerId, (int)$section['id']); + if ($activateVersion && $vid) { + $this->activateContentVersion($customerId, (int)$row['id'], $vid); + } } } catch (Throwable $e) { // ignore versioning failures on create @@ -964,6 +1016,7 @@ class ApiKernel return; } + $activateVersion = (int)$this->val($this->in, ['activate_version', 'activate', 'set_active'], 0) === 1; $versionCols = array_filter([$jsonCol, $htmlCol, $craftCol, $settingsCol, $editorCol]); $shouldSnapshot = false; foreach ($versionCols as $col) { @@ -972,9 +1025,6 @@ class ApiKernel break; } } - if ($shouldSnapshot) { - $this->createContentVersion($current, $itemCols, $customerId, (int)($section['id'] ?? 0)); - } $set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data))); $data['id'] = $id; @@ -983,6 +1033,21 @@ class ApiKernel $stmt = $this->pdo->prepare($sql); foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); $stmt->execute(); + if ($shouldSnapshot) { + try { + $stmt = $this->pdo->prepare("SELECT * FROM `$itemsTable` WHERE `customer_id` = :cid AND `id` = :id LIMIT 1"); + $stmt->execute([':cid' => $customerId, ':id' => $id]); + $row = $stmt->fetch(); + if ($row) { + $vid = $this->createContentVersion($row, $itemCols, $customerId, (int)($section['id'] ?? 0)); + if ($activateVersion && $vid) { + $this->activateContentVersion($customerId, (int)$row['id'], $vid); + } + } + } catch (Throwable $e) { + // ignore versioning failures + } + } $this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'updated' => true]); } @@ -1049,6 +1114,8 @@ class ApiKernel 'version_no' => (int)($row['version_no'] ?? 0), 'editor_type' => $row['editor_type'] ?? null, 'created_at' => $row['created_at'] ?? null, + 'is_active' => (int)($row['is_active'] ?? 0), + 'was_active' => (int)($row['was_active'] ?? 0), ]; }, $rows); $this->respond(['ok' => true, 'items' => $items, 'data' => $items]); @@ -1130,6 +1197,38 @@ class ApiKernel $this->respond(['ok' => true, 'restored' => true, 'content_id' => (int)($version['content_id'] ?? 0)]); } + private function handleContentVersionsActivate(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0); + if ($versionId <= 0) $this->fail('version id required', null, 422); + + $table = $this->contentVersionsTable(); + if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500); + + $stmt = $this->pdo->prepare("SELECT `id`,`content_id` FROM `$table` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); + $stmt->execute([':id' => $versionId, ':cid' => $customerId]); + $row = $stmt->fetch(); + if (!$row) $this->fail('Not found', ['id' => $versionId], 404); + + $this->activateContentVersion($customerId, (int)$row['content_id'], $versionId); + $this->respond(['ok' => true, 'activated' => true, 'id' => $versionId]); + } + + private function handleContentVersionsDeactivate(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $contentId = (int)$this->val($this->in, ['content_id', 'content'], 0); + if ($contentId <= 0) $this->fail('content_id required', null, 422); + + $this->deactivateContentVersion($customerId, $contentId); + $this->respond(['ok' => true, 'deactivated' => true, 'content_id' => $contentId]); + } + private function handleSectionsConfigList(): void { $auth = $this->requireAuth(); @@ -2325,6 +2424,12 @@ class ApiKernel case 'content_versions.restore': $this->handleContentVersionsRestore(); break; + case 'content_versions.activate': + $this->handleContentVersionsActivate(); + break; + case 'content_versions.deactivate': + $this->handleContentVersionsDeactivate(); + break; /* ---------- CRUD HANDLER ---------- */ default: @@ -2645,16 +2750,20 @@ class ApiKernel } $itemsTable = $this->contentItemsTable(); $itemCols = $this->resolveContentItemColumns($itemsTable); + $versionsTable = $this->contentVersionsTable(); + $versionCols = $this->tableExists($versionsTable) ? $this->resolveContentVersionColumns($versionsTable) : null; $htmlCol = $itemCols['html']; $jsonCol = $itemCols['json']; - if (!$htmlCol && !$jsonCol) { + if (!$htmlCol && !$jsonCol && (!$versionCols || !$versionCols['html'])) { $cache[$cacheKey] = null; return null; } $selectCols = []; - if ($htmlCol) $selectCols[] = "`$htmlCol`"; - if ($jsonCol) $selectCols[] = "`$jsonCol`"; - $sql = "SELECT " . implode(',', $selectCols) . " FROM `$itemsTable` WHERE `customer_id` = :cid AND `section_id` = :sid AND `id` = :id LIMIT 1"; + if ($htmlCol) $selectCols[] = "i.`$htmlCol` AS item_html"; + if ($jsonCol) $selectCols[] = "i.`$jsonCol` AS item_json"; + if ($versionCols && $versionCols['html']) $selectCols[] = "v.`{$versionCols['html']}` AS version_html"; + $join = $versionCols ? "LEFT JOIN `$versionsTable` v ON v.`content_id` = i.`id` AND v.`is_active` = 1" : ""; + $sql = "SELECT " . implode(',', $selectCols) . " FROM `$itemsTable` i $join WHERE i.`customer_id` = :cid AND i.`section_id` = :sid AND i.`id` = :id LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':cid' => $customerId, ':sid' => (int)$section['id'], ':id' => $id]); $row = $stmt->fetch(); @@ -2662,7 +2771,7 @@ class ApiKernel $cache[$cacheKey] = null; return null; } - $html = $htmlCol ? (string)($row[$htmlCol] ?? '') : ''; + $html = (string)($row['version_html'] ?? $row['item_html'] ?? ''); } else { if (!$kindKey) return null; $table = $this->tableMap[$kindKey] ?? null;