diff --git a/content-editor.js b/content-editor.js
index d675bfb..416e982 100644
--- a/content-editor.js
+++ b/content-editor.js
@@ -17,6 +17,7 @@ const { validateContent } = require('./proto/src/content/schema');
const { renderMarkdown } = require('./scripts/markdown-render');
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
const { handleVersionRoutes, listVersions } = require('./scripts/cms-versions');
+const { handleSaveRoute } = require('./scripts/cms-save');
const { LOGO_TARGETS, handleLogoRoutes } = require('./scripts/cms-logo');
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
@@ -57,7 +58,8 @@ const { LOGO_PAGE } = require('./scripts/cms-logo-page');
const { validateLogin, createSessionCookie, clearSessionCookie, hasValidSession, deleteSession } = require('./scripts/cms-session');
// Browser script is kept in its own file and inlined into the HTML template at render time.
-const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-client.js'), 'utf8');
+const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-client.js'), 'utf8')
+ + '\n' + fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-shortcuts.js'), 'utf8');
// ── Server ───────────────────────────────────────────────────────────────────
@@ -230,63 +232,12 @@ const server = http.createServer(async (req, res) => {
return;
}
- // POST /save — JSON body
- if (req.method === 'POST' && u.pathname === '/save') {
- let body = '';
- let bodyTooLarge = false;
- let bodySize = 0;
- req.on('data', c => {
- bodySize += c.length;
- if (bodySize > MAX_REQUEST_BODY_BYTES) {
- bodyTooLarge = true;
- return;
- }
- body += c;
- });
- req.on('end', () => {
- try {
- if (bodyTooLarge) {
- writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'request_too_large' });
- res.writeHead(413, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: false, error: `A kérés túl nagy (maximum ${MAX_REQUEST_BODY_BYTES} byte)` }));
- return;
- }
- const data = JSON.parse(body);
- // Optimistic locking: the editor echoes the fingerprint of the content it
- // loaded. If the file changed since (deploy, another tab, git), a blind
- // save would silently overwrite those changes — reject with 409 instead.
- const clientHash = req.headers['x-content-hash'];
- const currentOnDisk = fs.readFileSync(FILES[activeFile], 'utf8').trim();
- const currentHash = crypto.createHash('sha256').update(currentOnDisk).digest('hex');
- if (typeof clientHash !== 'string' || clientHash !== currentHash) {
- writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'conflict' });
- res.writeHead(409, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: false, error: 'A tartalom megváltozott, mióta ezt a lapot megnyitottad (pl. deploy vagy másik fül mentett). Frissítsd az oldalt, és végezd el újra a módosításokat.' }));
- return;
- }
- const validation = validateContent(activeFile, data);
- if (!validation.ok) {
- writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'validation_failed' });
- res.writeHead(422, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors }));
- return;
- }
- const backupFile = backupAndWriteAtomically(FILES[activeFile], data, BACKUP_DIR);
- // Return the hash of the written content so the editor tab can refresh
- // its fingerprint — otherwise the user's OWN next save would trip the
- // optimistic-lock 409 (MITHOME-68).
- const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex');
- writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'ok' });
- res.writeHead(200, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: true, backup: path.relative(__dirname, backupFile), contentHash: newHash }));
- } catch (e) {
- writeAudit('content_saved', { clientAddress, user: CMS_USER, file: activeFile, result: 'error' });
- res.writeHead(500, { 'Content-Type': 'application/json' });
- res.end(JSON.stringify({ ok: false, error: e.message }));
- }
- });
- return;
- }
+ // POST /save — handled in scripts/cms-save.js (optimistic lock + validation + backup).
+ if (handleSaveRoute({
+ req, res, u, activeFile, files: FILES, maxBodyBytes: MAX_REQUEST_BODY_BYTES,
+ validate: validateContent, writeAudit, backupAndWrite: backupAndWriteAtomically,
+ backupDir: BACKUP_DIR, user: CMS_USER, clientAddress, cmsDirname: __dirname,
+ })) return;
// POST /publish — Git Commit, Pull Rebase & Push
if (req.method === 'POST' && u.pathname === '/publish') {
diff --git a/proto/src/__tests__/cms-editor-shortcuts.test.ts b/proto/src/__tests__/cms-editor-shortcuts.test.ts
index e036e94..fb9ba26 100644
--- a/proto/src/__tests__/cms-editor-shortcuts.test.ts
+++ b/proto/src/__tests__/cms-editor-shortcuts.test.ts
@@ -13,6 +13,7 @@ import fs from 'fs'
import path from 'path'
const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8')
+ + '\n' + fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-shortcuts.js'), 'utf8')
const makeData = () => ({
hero: { title: 'T', subtitle: 'S', description: 'D' },
diff --git a/scripts/cms-editor-client.js b/scripts/cms-editor-client.js
index d7f651e..a2e6c27 100644
--- a/scripts/cms-editor-client.js
+++ b/scripts/cms-editor-client.js
@@ -343,58 +343,3 @@ render(DATA, document.getElementById('editor'));
const toast = document.querySelector('.toast');
if (toast) setTimeout(() => toast.remove(), 3500);
-// ── Keyboard shortcuts ───────────────────────────────────────────────────────
-// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help
-// Plain typing in inputs never triggers actions — the handler requires the
-// modifier key (or, for '?', a non-editing target).
-
-function showShortcutsOverlay() {
- const existing = document.getElementById('shortcuts-overlay');
- if (existing) { existing.remove(); return; }
- const overlay = document.createElement('div');
- overlay.id = 'shortcuts-overlay';
- overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;';
- overlay.innerHTML = `
-
-
⌨️ Gyorsbillentyűk
-
Ctrl/Cmd + S — Mentés
-
Ctrl/Cmd + P — Publikálás
-
Ctrl/Cmd + Shift + V — Verziók
-
? — ez a súgó (Esc: bezárás)
-
`;
- overlay.addEventListener('click', () => overlay.remove());
- document.body.appendChild(overlay);
-}
-
-document.addEventListener('keydown', e => {
- // Esc closes the shortcut overlay if open
- if (e.key === 'Escape') {
- const overlay = document.getElementById('shortcuts-overlay');
- if (overlay) { overlay.remove(); e.preventDefault(); }
- return;
- }
- const mod = e.ctrlKey || e.metaKey;
- if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) {
- e.preventDefault();
- save();
- return;
- }
- if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) {
- e.preventDefault();
- publish();
- return;
- }
- if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) {
- e.preventDefault();
- window.open('/versions?file=' + encodeURIComponent(FILE), '_blank');
- return;
- }
- if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') {
- const target = e.target;
- const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
- if (!isEditing) {
- e.preventDefault();
- showShortcutsOverlay();
- }
- }
-});
diff --git a/scripts/cms-editor-shortcuts.js b/scripts/cms-editor-shortcuts.js
new file mode 100644
index 0000000..a7cfc91
--- /dev/null
+++ b/scripts/cms-editor-shortcuts.js
@@ -0,0 +1,56 @@
+// Keyboard shortcuts for the Content Editor editor page. Inlined after the
+// main client script; all referenced functions are global at that point.
+// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help
+// Plain typing in inputs never triggers actions — the handler requires the
+// modifier key (or, for '?', a non-editing target).
+
+function showShortcutsOverlay() {
+ const existing = document.getElementById('shortcuts-overlay');
+ if (existing) { existing.remove(); return; }
+ const overlay = document.createElement('div');
+ overlay.id = 'shortcuts-overlay';
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;';
+ overlay.innerHTML = `
+
+
⌨️ Gyorsbillentyűk
+
Ctrl/Cmd + S — Mentés
+
Ctrl/Cmd + P — Publikálás
+
Ctrl/Cmd + Shift + V — Verziók
+
? — ez a súgó (Esc: bezárás)
+
`;
+ overlay.addEventListener('click', () => overlay.remove());
+ document.body.appendChild(overlay);
+}
+
+document.addEventListener('keydown', e => {
+ // Esc closes the shortcut overlay if open
+ if (e.key === 'Escape') {
+ const overlay = document.getElementById('shortcuts-overlay');
+ if (overlay) { overlay.remove(); e.preventDefault(); }
+ return;
+ }
+ const mod = e.ctrlKey || e.metaKey;
+ if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) {
+ e.preventDefault();
+ save();
+ return;
+ }
+ if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) {
+ e.preventDefault();
+ publish();
+ return;
+ }
+ if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) {
+ e.preventDefault();
+ window.open('/versions?file=' + encodeURIComponent(FILE), '_blank');
+ return;
+ }
+ if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') {
+ const target = e.target;
+ const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
+ if (!isEditing) {
+ e.preventDefault();
+ showShortcutsOverlay();
+ }
+ }
+});
diff --git a/scripts/cms-logo-client.js b/scripts/cms-logo-client.js
new file mode 100644
index 0000000..29fcf76
--- /dev/null
+++ b/scripts/cms-logo-client.js
@@ -0,0 +1,280 @@
+function setMsg(target, text, ok) {
+ const el = document.getElementById('msg-' + target);
+ 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 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; }
+ btn.disabled = true;
+ btn.textContent = '⏳ Mentés folyamatban...';
+ try {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ 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-' + 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');
+}
diff --git a/scripts/cms-logo-page.js b/scripts/cms-logo-page.js
index bba78f8..f6a1b94 100644
--- a/scripts/cms-logo-page.js
+++ b/scripts/cms-logo-page.js
@@ -1,6 +1,11 @@
// Branding page for the Content Editor: upload/replace and edit logos with
// interactive Canvas editor (crop, zoom/pan, rotate/flip, padding, filters).
+// The browser-side editor script is inlined from cms-logo-client.js.
+const fs = require('fs');
+const path = require('path');
const { LOGO_TARGETS } = require('./cms-logo');
+const logoClientJs = fs.readFileSync(path.join(__dirname, 'cms-logo-client.js'), 'utf8');
+
const LOGO_PAGE = (csrfToken) => `
@@ -174,286 +179,7 @@ const LOGO_PAGE = (csrfToken) => `