feat(cms): add interactive canvas logo editor modal
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Has been cancelled
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Has been cancelled
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Has been cancelled
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Has been cancelled
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Has been cancelled
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Has been cancelled
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Has been cancelled
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Has been cancelled

Closes MITHOME-76:
- Interactive Canvas-based modal for logo editing in CMS
- Crop with presets (1:1, 3:1, 4:1, 16:9, free)
- Drag-to-pan positioning and wheel/slider zoom
- 90 deg rotation and horizontal/vertical flip
- Configurable padding/margins around logo
- Brightness, contrast, and color invert filters
- Direct high-DPI (2x retina) PNG export and atomic save
This commit is contained in:
Do Siki
2026-08-20 11:53:23 +02:00
parent c5d5198fbf
commit 4c2913e131
2 changed files with 378 additions and 23 deletions
+377 -22
View File
@@ -1,5 +1,5 @@
// Branding page for the Content Editor: upload/replace the two logos with
// client-side preview. Kept separate from cms-pages.js (file-size limits).
// Branding page for the Content Editor: upload/replace and edit logos with
// interactive Canvas editor (crop, zoom/pan, rotate/flip, padding, filters).
const { LOGO_TARGETS } = require('./cms-logo');
const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
@@ -24,11 +24,38 @@ const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
.preview img { max-width: 100%; max-height: 72px; }
input[type=file] { color: #94a3b8; font-size: 14px; margin-bottom: 12px; width: 100%; }
.meta { font-size: 13px; color: #94a3b8; min-height: 20px; margin-bottom: 12px; }
button { background: linear-gradient(135deg,#3b82f6,#8b5cf6); color: #fff; border: none; border-radius: 8px; padding: 10px 22px; font-size: 14px; font-weight: 700; cursor: pointer; }
.actions-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.btn-save { background: linear-gradient(135deg,#059669,#10b981); color: #fff; border: none; border-radius: 8px; padding: 9px 18px; font-size: 14px; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.btn-edit { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; border-radius: 8px; padding: 9px 16px; font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.btn-secondary { background: #334155; color: #e2e8f0; border: 1px solid #475569; border-radius: 8px; padding: 9px 16px; font-size: 14px; font-weight: 600; cursor: pointer; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .5; cursor: wait; }
.msg { font-size: 14px; margin-top: 12px; min-height: 20px; }
.ok { color: #6ee7b7; } .err { color: #fca5a5; }
/* Modal Styles */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85); backdrop-filter: blur(4px); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 14px; }
.modal-backdrop.open { display: flex; }
.modal-box { background: #1a2035; border: 1px solid #334155; border-radius: 14px; width: 100%; max-width: 860px; max-height: 92vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 40px rgba(0,0,0,0.6); }
.modal-header { padding: 12px 20px; border-bottom: 1px solid #2d3748; display: flex; align-items: center; justify-content: space-between; background: #141824; }
.modal-header h3 { font-size: 16px; color: #93c5fd; }
.btn-close { background: transparent; border: none; color: #94a3b8; font-size: 18px; cursor: pointer; padding: 4px 8px; }
.modal-body { display: grid; grid-template-columns: 1fr 280px; gap: 16px; padding: 16px; overflow-y: auto; max-height: calc(92vh - 120px); }
@media (max-width: 720px) { .modal-body { grid-template-columns: 1fr; } }
.canvas-container { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 20px 20px; border: 1px solid #334155; border-radius: 10px; display: flex; align-items: center; justify-content: center; min-height: 320px; position: relative; overflow: hidden; cursor: grab; user-select: none; }
.canvas-container.grabbing { cursor: grabbing; }
canvas { max-width: 100%; max-height: 100%; display: block; }
.editor-controls { display: flex; flex-direction: column; gap: 14px; font-size: 13px; color: #cbd5e1; }
.ctrl-group { background: #141824; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; }
.ctrl-group h4 { font-size: 12px; text-transform: uppercase; color: #94a3b8; margin-bottom: 8px; letter-spacing: 0.5px; }
.btn-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.btn-tool { background: #1e293b; border: 1px solid #475569; color: #cbd5e1; border-radius: 6px; padding: 5px 9px; font-size: 12px; cursor: pointer; }
.btn-tool.active { background: #2563eb; color: #fff; border-color: #3b82f6; }
.slider-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.slider-row label { width: 68px; font-size: 12px; color: #94a3b8; }
.slider-row input[type=range] { flex: 1; accent-color: #3b82f6; }
.slider-row span { width: 38px; font-size: 11px; text-align: right; color: #cbd5e1; }
.modal-footer { padding: 12px 20px; border-top: 1px solid #2d3748; background: #141824; display: flex; justify-content: flex-end; gap: 10px; align-items: center; }
</style>
</head>
<body>
@@ -39,7 +66,7 @@ const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
</header>
<main>
<p class="note">Csak <strong>PNG</strong> fájl, max. 1 MB. A régi logó mentésre kerül (a 🕘 Verziókhoz hasonlóan visszavonható). A CMS-belei változás azonnal, a <strong>weboldalon a Publikálás (deploy) után</strong> jelenik meg. Ajánlott átlátszó háttérű PNG a sötét fejléchez.</p>
<p class="note">Csak <strong>PNG</strong> fájl tölthető fel (max. 1 MB). A régi logóról automatikus biztonsági mentés készül. A szerkesztővel közvetlenül vágatod, méretezheted, forgathatod és korrigálhatod a logókat a mentés előtt. A változás a <strong>weboldalon a Publikálás (deploy) után</strong> jelenik meg.</p>
<div class="card">
<h2>Weboldal fejléc logója (szöveges)</h2>
@@ -47,7 +74,10 @@ const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
<div class="preview"><img id="prev-header" src="/logo.png?variant=header&t=${Date.now()}" alt="fejléc logó előnézet"></div>
<input type="file" id="file-header" accept="image/png">
<div class="meta" id="meta-header"></div>
<button onclick="upload('header')">⬆ Fejléc logó cseréje</button>
<div class="actions-row">
<button class="btn-save" id="btn-header" onclick="upload('header')">💾 Fejléc logó mentése</button>
<button class="btn-edit" onclick="openEditor('header')">✏️ Logó szerkesztése</button>
</div>
<p class="msg" id="msg-header"></p>
</div>
@@ -57,11 +87,91 @@ const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
<div class="preview"><img id="prev-icon" src="/logo.png?t=${Date.now()}" alt="ikon logó előnézet"></div>
<input type="file" id="file-icon" accept="image/png">
<div class="meta" id="meta-icon"></div>
<button onclick="upload('icon')">⬆ Ikon logó cseréje</button>
<div class="actions-row">
<button class="btn-save" id="btn-icon" onclick="upload('icon')">💾 Ikon logó mentése</button>
<button class="btn-edit" onclick="openEditor('icon')">✏️ Logó szerkesztése</button>
</div>
<p class="msg" id="msg-icon"></p>
</div>
</main>
<!-- Editor Modal -->
<div class="modal-backdrop" id="editor-modal">
<div class="modal-box">
<div class="modal-header">
<h3 id="modal-title">🎨 Logó szerkesztése</h3>
<button class="btn-close" onclick="closeEditor()">✕</button>
</div>
<div class="modal-body">
<div class="canvas-container" id="canvas-wrap">
<canvas id="edit-canvas" width="500" height="350"></canvas>
</div>
<div class="editor-controls">
<div class="ctrl-group">
<h4>Képarány / Vágás</h4>
<div class="btn-row" id="aspect-btns">
<button class="btn-tool" data-aspect="0" onclick="setAspect(0)">Szabad</button>
<button class="btn-tool active" data-aspect="1" onclick="setAspect(1)">1:1</button>
<button class="btn-tool" data-aspect="3" onclick="setAspect(3)">3:1</button>
<button class="btn-tool" data-aspect="4" onclick="setAspect(4)">4:1</button>
<button class="btn-tool" data-aspect="1.777" onclick="setAspect(1.777)">16:9</button>
</div>
</div>
<div class="ctrl-group">
<h4>Méret és Pozíció</h4>
<div class="slider-row">
<label>Nagyítás:</label>
<input type="range" id="zoom-range" min="0.3" max="3" step="0.05" value="1" oninput="setZoom(this.value)">
<span id="zoom-val">100%</span>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn-tool" onclick="resetPan()">✛ Középre</button>
<button class="btn-tool" onclick="fitToCrop()">⊡ Keretbe illesztés</button>
</div>
</div>
<div class="ctrl-group">
<h4>Forgatás & Tükrözés</h4>
<div class="btn-row">
<button class="btn-tool" onclick="rotate(-90)">↺ Balra 90°</button>
<button class="btn-tool" onclick="rotate(90)">↻ Jobbra 90°</button>
<button class="btn-tool" onclick="toggleFlip('h')">⇋ Vízszintes</button>
<button class="btn-tool" onclick="toggleFlip('v')">⇅ Függőleges</button>
</div>
</div>
<div class="ctrl-group">
<h4>Margó / Padding</h4>
<div class="slider-row">
<label>Margó:</label>
<input type="range" id="pad-range" min="0" max="60" step="2" value="0" oninput="setPadding(this.value)">
<span id="pad-val">0px</span>
</div>
</div>
<div class="ctrl-group">
<h4>Képkorrekció</h4>
<div class="slider-row">
<label>Fényerő:</label>
<input type="range" id="bright-range" min="50" max="200" value="100" oninput="setFilter('brightness', this.value)">
<span id="bright-val">100%</span>
</div>
<div class="slider-row">
<label>Kontraszt:</label>
<input type="range" id="contrast-range" min="50" max="200" value="100" oninput="setFilter('contrast', this.value)">
<span id="contrast-val">100%</span>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn-tool" id="btn-invert" onclick="toggleInvert()">🌓 Invertálás</button>
<button class="btn-tool" onclick="resetFilters()">↺ Alaphelyzet</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeEditor()">Mégse</button>
<button class="btn-save" id="modal-save-btn" onclick="saveEditedLogo()">💾 Szerkesztett logó mentése</button>
</div>
</div>
</div>
<script>
const CSRF_TOKEN = "${csrfToken}";
function setMsg(target, text, ok) {
@@ -69,36 +179,281 @@ function setMsg(target, text, ok) {
el.textContent = text;
el.className = 'msg ' + (ok ? 'ok' : 'err');
}
async function upload(target) {
const file = document.getElementById('file-' + target).files[0];
const btn = document.getElementById('btn-' + target);
const origText = btn.textContent;
const msg = t => setMsg(target, t, false);
if (!file) { msg('Először válassz PNG fájlt.'); return; }
if (!file) { msg('Először válassz egy új PNG fájlt a mentéshez.'); return; }
if (file.type !== 'image/png') { msg('Csak PNG fájl tölthető fel.'); return; }
if (file.size > 1024 * 1024) { msg('A fájl nagyobb, mint 1 MB.'); return; }
const btn = event.target; btn.disabled = true;
btn.disabled = true;
btn.textContent = '⏳ Mentés folyamatban...';
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const res = await fetch('/logo?target=' + target, {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
body: bytes
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
setMsg(target, '✅ Cserélve. (A weboldalon a Publikálás után jelenik meg.)', true);
document.getElementById('prev-' + target).src = '/logo.png?t=' + Date.now();
} else msg('❌ ' + json.error);
} catch (e) { msg('❌ Hálózati hiba'); }
await sendLogoBinary(target, bytes);
} catch (e) { msg('❌ Hálózati hiba mentés közben'); }
btn.disabled = false;
btn.textContent = origText;
}
async function sendLogoBinary(target, bytes) {
const res = await fetch('/logo?target=' + target, {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
body: bytes
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
setMsg(target, '✅ Logó sikeresen elmentve! (A weboldalon a Publikálás után jelenik meg.)', true);
const variantParam = target === 'header' ? 'variant=header&' : '';
document.getElementById('prev-' + target).src = '/logo.png?' + variantParam + 't=' + Date.now();
const meta = document.getElementById('meta-' + target);
if (meta) meta.textContent = 'Módosítva (' + (bytes.length / 1024).toFixed(1) + ' KB) — elmentve';
} else setMsg(target, '❌ ' + json.error, false);
}
document.querySelectorAll('input[type=file]').forEach(inp => {
inp.addEventListener('change', () => {
const target = inp.id.replace('file-', '');
const f = inp.files[0];
const meta = document.getElementById('meta-' + inp.id.replace('file-', ''));
if (f) meta.textContent = f.name + ' — ' + (f.size / 1024).toFixed(1) + ' KB';
const meta = document.getElementById('meta-' + target);
const prev = document.getElementById('prev-' + target);
if (!f) return;
if (f.type !== 'image/png') {
setMsg(target, 'Csak PNG formátumú kép választható ki.', false);
if (meta) meta.textContent = '';
return;
}
if (f.size > 1024 * 1024) {
setMsg(target, 'A fájl nagyobb 1 MB-nál.', false);
if (meta) meta.textContent = '';
return;
}
setMsg(target, 'Új fájl kiválasztva. Kattints a Mentés vagy a ✏️ Szerkesztés gombra.', true);
if (meta) meta.textContent = f.name + ' — ' + (f.size / 1024).toFixed(1) + ' KB (még nincs mentve)';
prev.src = URL.createObjectURL(f);
});
});
/* ── Interactive Canvas Editor Logic ────────────────────────────── */
let currentEditTarget = 'header';
let editImg = new Image();
let editState = {
zoom: 1, panX: 0, panY: 0, rotation: 0,
flipH: 1, flipV: 1, padding: 0, aspect: 0,
brightness: 100, contrast: 100, invert: false
};
const canvas = document.getElementById('edit-canvas');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('canvas-wrap');
let isDragging = false, startX = 0, startY = 0;
function openEditor(target) {
currentEditTarget = target;
document.getElementById('modal-title').textContent = '🎨 Logó szerkesztése — ' + (target === 'header' ? 'Weboldal fejléc' : 'CMS ikon');
editState.aspect = (target === 'icon' ? 1 : 0);
updateAspectBtns();
resetFilters();
resetPan();
const fileInput = document.getElementById('file-' + target);
if (fileInput.files && fileInput.files[0]) {
const reader = new FileReader();
reader.onload = e => { loadImg(e.target.result); };
reader.readAsDataURL(fileInput.files[0]);
} else {
const previewSrc = document.getElementById('prev-' + target).src;
loadImg(previewSrc);
}
}
function loadImg(src) {
editImg = new Image();
editImg.crossOrigin = 'anonymous';
editImg.onload = () => {
document.getElementById('editor-modal').classList.add('open');
fitToCrop();
render();
};
editImg.src = src;
}
function closeEditor() {
document.getElementById('editor-modal').classList.remove('open');
}
function setAspect(ratio) {
editState.aspect = ratio;
updateAspectBtns();
render();
}
function updateAspectBtns() {
document.querySelectorAll('#aspect-btns button').forEach(b => {
const a = parseFloat(b.dataset.aspect);
b.classList.toggle('active', (editState.aspect === 0 && a === 0) || (Math.abs(editState.aspect - a) < 0.01));
});
}
function setZoom(val) {
editState.zoom = parseFloat(val);
document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%';
render();
}
function setPadding(val) {
editState.padding = parseInt(val, 10);
document.getElementById('pad-val').textContent = editState.padding + 'px';
render();
}
function setFilter(name, val) {
editState[name] = parseInt(val, 10);
document.getElementById(name.slice(0, 6) + '-val').textContent = val + '%';
render();
}
function toggleInvert() {
editState.invert = !editState.invert;
document.getElementById('btn-invert').classList.toggle('active', editState.invert);
render();
}
function resetFilters() {
editState.brightness = 100; editState.contrast = 100; editState.invert = false; editState.padding = 0;
document.getElementById('bright-range').value = 100; document.getElementById('bright-val').textContent = '100%';
document.getElementById('contrast-range').value = 100; document.getElementById('contrast-val').textContent = '100%';
document.getElementById('pad-range').value = 0; document.getElementById('pad-val').textContent = '0px';
document.getElementById('btn-invert').classList.remove('active');
render();
}
function rotate(deg) {
editState.rotation = (editState.rotation + deg) % 360;
render();
}
function toggleFlip(dir) {
if (dir === 'h') editState.flipH *= -1;
if (dir === 'v') editState.flipV *= -1;
render();
}
function resetPan() {
editState.panX = 0; editState.panY = 0;
render();
}
function getCropRect() {
const cw = canvas.width, ch = canvas.height;
let rw = cw * 0.85, rh = ch * 0.85;
if (editState.aspect > 0) {
if (rw / rh > editState.aspect) rw = rh * editState.aspect;
else rh = rw / editState.aspect;
}
return { x: (cw - rw) / 2, y: (ch - rh) / 2, w: rw, h: rh };
}
function fitToCrop() {
if (!editImg.width || !editImg.height) return;
const crop = getCropRect();
const isRotated = Math.abs(editState.rotation) === 90 || Math.abs(editState.rotation) === 270;
const iw = isRotated ? editImg.height : editImg.width;
const ih = isRotated ? editImg.width : editImg.height;
const scale = Math.min(crop.w / iw, crop.h / ih);
editState.zoom = Math.max(0.3, Math.min(3, scale));
document.getElementById('zoom-range').value = editState.zoom;
document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%';
editState.panX = 0; editState.panY = 0;
render();
}
function render() {
if (!editImg.width) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const crop = getCropRect();
// Draw image
ctx.save();
ctx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : '');
ctx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY);
ctx.rotate((editState.rotation * Math.PI) / 180);
ctx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV);
const pad = editState.padding / (editState.zoom || 1);
const dw = Math.max(10, editImg.width - pad * 2);
const dh = Math.max(10, editImg.height - pad * 2);
ctx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
// Dark overlay outside crop rect
ctx.save();
ctx.fillStyle = 'rgba(15, 17, 23, 0.75)';
ctx.fillRect(0, 0, canvas.width, crop.y);
ctx.fillRect(0, crop.y + crop.h, canvas.width, canvas.height - (crop.y + crop.h));
ctx.fillRect(0, crop.y, crop.x, crop.h);
ctx.fillRect(crop.x + crop.w, crop.y, canvas.width - (crop.x + crop.w), crop.h);
// Crop border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(crop.x, crop.y, crop.w, crop.h);
ctx.restore();
}
// Drag & Pan handlers
wrap.addEventListener('mousedown', e => { isDragging = true; startX = e.clientX - editState.panX; startY = e.clientY - editState.panY; wrap.classList.add('grabbing'); });
window.addEventListener('mousemove', e => { if (!isDragging) return; editState.panX = e.clientX - startX; editState.panY = e.clientY - startY; render(); });
window.addEventListener('mouseup', () => { isDragging = false; wrap.classList.remove('grabbing'); });
wrap.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.05 : -0.05;
setZoom(Math.max(0.3, Math.min(3, editState.zoom + delta)));
document.getElementById('zoom-range').value = editState.zoom;
}, { passive: false });
async function saveEditedLogo() {
const crop = getCropRect();
const outCanvas = document.createElement('canvas');
outCanvas.width = Math.round(crop.w * 2); // 2x for retina sharpness
outCanvas.height = Math.round(crop.h * 2);
const octx = outCanvas.getContext('2d');
octx.save();
octx.scale(2, 2);
octx.translate(-crop.x, -crop.y);
octx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : '');
octx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY);
octx.rotate((editState.rotation * Math.PI) / 180);
octx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV);
const pad = editState.padding / (editState.zoom || 1);
const dw = Math.max(10, editImg.width - pad * 2);
const dh = Math.max(10, editImg.height - pad * 2);
octx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh);
octx.restore();
const saveBtn = document.getElementById('modal-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = '⏳ Mentés folyamatban...';
outCanvas.toBlob(async blob => {
if (!blob) { alert('Hiba a kép exportálásakor'); saveBtn.disabled = false; return; }
try {
const bytes = new Uint8Array(await blob.arrayBuffer());
await sendLogoBinary(currentEditTarget, bytes);
closeEditor();
} catch (e) {
alert('Hiba történt a mentés során.');
}
saveBtn.disabled = false;
saveBtn.textContent = '💾 Szerkesztett logó mentése';
}, 'image/png');
}
</script>
</body>
</html>`;