Files
websitedev/scripts/cms-editor-client.js
T
Do Siki 88383049d5
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
fix(cms): stable bottom bar and self-save no longer trips the 409 lock
MITHOME-68: the optimistic-lock fingerprint was frozen at page load, so the
user's own second save 409'd. /save now returns the hash of the written
content and the client refreshes CONTENT_HASH on success — 409 only fires
for genuine external changes (deploy, other tab, restore). Also: successful
logins no longer consume the auth failure budget (only failed attempts do).

MITHOME-69: bottom bar items no longer shift while saving/publishing — the
status message occupies a constant flex slot (visibility instead of
display), the publish button locks its width while running and restores
its env-specific label, auto margins removed. Layout guard test added.

Test markers are now run-unique so a crashed run can never poison the
next one's expectations.
2026-08-19 14:04:49 +02:00

332 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Browser-side script of the Content Editor editor page.
// Inlined into the HTML template at render time by content-editor.js.
// Test coverage: scripts/test-content-editor-serializer.js runs this exact code.
// ── Render ──────────────────────────────────────────────────────────────────
function render(obj, container) {
container.innerHTML = '';
renderObject(obj, container, '');
}
function renderObject(obj, container, prefix) {
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? prefix + '.' + key : key;
if (Array.isArray(val)) {
renderArray(key, val, container, path);
} else if (typeof val === 'object' && val !== null) {
renderObject(val, container, path);
} else {
renderPrimitive(path, val, container);
}
}
}
function renderPrimitive(path, val, container) {
const isLong = String(val).length > 80 || String(val).includes('<');
const div = document.createElement('div');
div.className = 'field';
const type = val === null ? 'null' : typeof val;
let control;
if (type === 'boolean') {
control = `<input type="checkbox" data-path="${path}" data-type="boolean" ${val ? 'checked' : ''}>`;
} else if (type === 'number') {
control = `<input type="number" data-path="${path}" data-type="number" value="${esc(val)}">`;
} else {
control = isLong
? `<textarea data-path="${path}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
: `<input type="text" data-path="${path}" data-type="${type}" value="${esc(val ?? '')}">`;
}
div.innerHTML = `
<label>${path}</label>
${control}
`;
container.appendChild(div);
}
function renderArray(key, arr, container, path) {
const section = document.createElement('div');
section.className = 'array-section';
section.dataset.arrayPath = path;
const label = document.createElement('div');
label.className = 'array-label';
label.textContent = path;
section.appendChild(label);
const items = document.createElement('div');
items.className = 'array-items';
items.dataset.arrayItems = path;
section.appendChild(items);
arr.forEach((item, i) => {
if (typeof item === 'object' && item !== null) {
items.appendChild(makeObjCard(item, i, path));
} else {
items.appendChild(makeStrItem(item, i, path));
}
});
// Template for adding new items
const sample = arr.length > 0 ? arr[arr.length - 1] : '';
const isObj = typeof sample === 'object' && sample !== null;
const addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.textContent = ' Új elem hozzáadása';
addBtn.onclick = () => {
const idx = items.children.length;
if (isObj) {
const blank = blankLike(sample);
items.appendChild(makeObjCard(blank, idx, path));
} else {
items.appendChild(makeStrItem('', idx, path));
}
reindexItems(items);
};
section.appendChild(addBtn);
container.appendChild(section);
}
function blankLike(value) {
if (Array.isArray(value)) return [];
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, blankLike(child)]));
}
if (typeof value === 'boolean') return false;
if (typeof value === 'number') return 0;
return '';
}
function makeStrItem(val, idx, path) {
const wrap = document.createElement('div');
wrap.className = 'str-item';
const type = val === null ? 'null' : typeof val;
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
ta.dataset.path = path + '[' + idx + ']';
ta.dataset.type = type;
if (type === 'boolean') {
ta.type = 'checkbox';
ta.checked = val;
} else {
ta.value = val ?? '';
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val ?? '').length / 80)));
}
const del = document.createElement('button');
del.className = 'btn-del';
del.textContent = '❌';
del.title = 'Törlés';
del.onclick = () => {
// WHY: capture the container BEFORE removing — a detached node has no
// ancestors, so closest() would return null and reindexing would silently
// not run (sparse arrays → schema errors on save).
const container = wrap.closest('.array-items');
wrap.remove();
reindexItems(container);
};
wrap.appendChild(ta);
wrap.appendChild(del);
return wrap;
}
function makeObjCard(obj, idx, path) {
const card = document.createElement('div');
card.className = 'obj-card';
const hdr = document.createElement('div');
hdr.className = 'card-header';
hdr.textContent = path + '[' + idx + ']';
card.appendChild(hdr);
renderObject(obj, card, path + '[' + idx + ']');
const del = document.createElement('button');
del.className = 'btn-del-card';
del.textContent = '❌ Törlés';
del.onclick = () => {
// Same as above: capture before detaching, or reindexing is skipped.
const container = card.closest('.array-items');
card.remove();
reindexItems(container);
};
card.appendChild(del);
return card;
}
function reindexItems(itemsEl) {
if (!itemsEl) return;
const path = itemsEl.dataset.arrayItems;
// WHY: rewrite only the index that directly follows THIS array's own path prefix.
// A generic "replace first [n]" rule corrupts nested arrays (e.g. deleting from
// services[1].specs.items rewrites the OUTER services index and scatters paths
// across services[0..n], producing sparse arrays and schema errors).
const prefix = path + '[';
Array.from(itemsEl.children).forEach((child, i) => {
child.querySelectorAll('[data-path]').forEach(el => {
const old = el.dataset.path;
if (typeof old !== 'string' || !old.startsWith(prefix)) return;
const rest = old.slice(prefix.length);
const bracketEnd = rest.indexOf(']');
const suffix = bracketEnd === -1 ? '' : rest.slice(bracketEnd);
el.dataset.path = prefix + i + suffix;
});
// Update card header
const hdr = child.querySelector('.card-header');
if (hdr) hdr.textContent = path + '[' + i + ']';
});
}
// ── Collect & Save ───────────────────────────────────────────────────────────
function collect() {
const result = JSON.parse(JSON.stringify(DATA)); // deep clone as base
// Wipe all arrays so we rebuild them from DOM
clearArrays(result);
document.querySelectorAll('[data-path]').forEach(el => {
setPath(result, el.dataset.path, readValue(el));
});
return result;
}
function readValue(el) {
switch (el.dataset.type) {
case 'boolean': return el.checked;
case 'number': return Number(el.value);
case 'null': return el.value === '' ? null : el.value;
default: return el.value;
}
}
function clearArrays(obj) {
for (const k of Object.keys(obj)) {
if (Array.isArray(obj[k])) obj[k] = [];
else if (typeof obj[k] === 'object' && obj[k] !== null) clearArrays(obj[k]);
}
}
function setPath(obj, path, value) {
const parts = parsePath(path);
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (cur[part] === undefined || cur[part] === null) {
cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
}
cur = cur[part];
}
cur[parts[parts.length - 1]] = value;
}
function parsePath(path) {
const parts = [];
let token = '';
let inIndex = false;
for (const char of path) {
if (char === '.') {
if (!inIndex && token) parts.push(token);
token = '';
} else if (char === '[') {
if (token) parts.push(token);
token = '';
inIndex = true;
} else if (char === ']') {
parts.push(Number(token));
token = '';
inIndex = false;
} else {
token += char;
}
}
if (token) parts.push(token);
return parts;
}
async function save() {
const data = collect();
const res = await fetch('/save?file=' + FILE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH },
body: JSON.stringify(data, null, 2)
});
if (res.status === 401) { location.href = '/login'; return; }
if (res.status === 409) {
if (confirm('A tartalom megváltozott, mióta ez a lap megnyílt (pl. deploy vagy másik fül mentett).\n\nOK = lap frissítése az új tartalommal (a szerkesztésed elvész)\nMégse = maradsz ezen a lapon, a mentés nem történt meg.')) {
location.reload();
}
return;
}
const json = await res.json();
const status = document.getElementById('saveStatus');
if (json.ok) {
// Refresh the optimistic-lock fingerprint with the server-computed hash of
// the saved content, so the user's own subsequent saves don't trip 409.
if (json.contentHash) CONTENT_HASH = json.contentHash;
status.textContent = '✅ Mentve!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba: ' + json.error;
status.style.color = '#f87171';
}
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 3000);
}
async function publish() {
const btn = document.getElementById('publishBtn');
const status = document.getElementById('saveStatus');
// Save first
await save();
// WHY: lock the button width and remember the label so the running state
// neither resizes the bottom bar nor permanently swaps the env-specific label.
const originalLabel = btn.textContent;
btn.style.minWidth = btn.offsetWidth + 'px';
btn.textContent = '⏳ Élesítés folyamatban...';
btn.disabled = true;
try {
const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
status.textContent = '🚀 Sikeresen elküldve a szerverre!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba az élesítésnél: ' + json.error;
status.style.color = '#f87171';
}
} catch (e) {
status.textContent = '❌ Hálózati hiba';
status.style.color = '#f87171';
}
btn.textContent = originalLabel;
btn.style.minWidth = '';
btn.disabled = false;
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
}
async function logout() {
if (!confirm('Biztosan ki szeretnél lépni?')) return;
try {
// Invalidates the server-side session cookie (Basic Auth cache is not
// affected — the login page is public, no 401-overwrite is needed).
await fetch('/logout', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
} catch (e) { /* network error — continue to the login page */ }
location.href = '/login';
}
function esc(v) {
return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Boot
render(DATA, document.getElementById('editor'));
// Auto-dismiss toast
const toast = document.querySelector('.toast');
if (toast) setTimeout(() => toast.remove(), 3500);