refactor(cms): split oversized modules below the 400-line limit
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
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
- content-editor.js: extract the /save handler to scripts/cms-save.js - cms-logo-page.js: inline the ~280-line canvas editor script to scripts/cms-logo-client.js (page is now the HTML/CSS shell only) - cms-editor-client.js: move the keyboard-shortcut section to scripts/cms-editor-shortcuts.js, inlined after the main client All modules now under the hard limit; full pre-deploy suite green. Closes MITHOME-80
This commit is contained in:
+9
-58
@@ -17,6 +17,7 @@ const { validateContent } = require('./proto/src/content/schema');
|
|||||||
const { renderMarkdown } = require('./scripts/markdown-render');
|
const { renderMarkdown } = require('./scripts/markdown-render');
|
||||||
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
|
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
|
||||||
const { handleVersionRoutes, listVersions } = require('./scripts/cms-versions');
|
const { handleVersionRoutes, listVersions } = require('./scripts/cms-versions');
|
||||||
|
const { handleSaveRoute } = require('./scripts/cms-save');
|
||||||
const { LOGO_TARGETS, handleLogoRoutes } = require('./scripts/cms-logo');
|
const { LOGO_TARGETS, handleLogoRoutes } = require('./scripts/cms-logo');
|
||||||
|
|
||||||
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
|
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');
|
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.
|
// 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 ───────────────────────────────────────────────────────────────────
|
// ── Server ───────────────────────────────────────────────────────────────────
|
||||||
@@ -230,63 +232,12 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /save — JSON body
|
// POST /save — handled in scripts/cms-save.js (optimistic lock + validation + backup).
|
||||||
if (req.method === 'POST' && u.pathname === '/save') {
|
if (handleSaveRoute({
|
||||||
let body = '';
|
req, res, u, activeFile, files: FILES, maxBodyBytes: MAX_REQUEST_BODY_BYTES,
|
||||||
let bodyTooLarge = false;
|
validate: validateContent, writeAudit, backupAndWrite: backupAndWriteAtomically,
|
||||||
let bodySize = 0;
|
backupDir: BACKUP_DIR, user: CMS_USER, clientAddress, cmsDirname: __dirname,
|
||||||
req.on('data', c => {
|
})) return;
|
||||||
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 /publish — Git Commit, Pull Rebase & Push
|
// POST /publish — Git Commit, Pull Rebase & Push
|
||||||
if (req.method === 'POST' && u.pathname === '/publish') {
|
if (req.method === 'POST' && u.pathname === '/publish') {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
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')
|
||||||
|
|
||||||
const makeData = () => ({
|
const makeData = () => ({
|
||||||
hero: { title: 'T', subtitle: 'S', description: 'D' },
|
hero: { title: 'T', subtitle: 'S', description: 'D' },
|
||||||
|
|||||||
@@ -343,58 +343,3 @@ render(DATA, document.getElementById('editor'));
|
|||||||
const toast = document.querySelector('.toast');
|
const toast = document.querySelector('.toast');
|
||||||
if (toast) setTimeout(() => toast.remove(), 3500);
|
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 = `
|
|
||||||
<div style="background:#1a2035;border:1px solid #2d3748;border-radius:14px;padding:28px 32px;max-width:420px;width:100%;font-size:14px;line-height:2;color:#e2e8f0;">
|
|
||||||
<h2 style="font-size:16px;color:#93c5fd;margin-bottom:12px;">⌨️ Gyorsbillentyűk</h2>
|
|
||||||
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + S</kbd> — Mentés</div>
|
|
||||||
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + P</kbd> — Publikálás</div>
|
|
||||||
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + Shift + V</kbd> — Verziók</div>
|
|
||||||
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">?</kbd> — ez a súgó (Esc: bezárás)</div>
|
|
||||||
</div>`;
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div style="background:#1a2035;border:1px solid #2d3748;border-radius:14px;padding:28px 32px;max-width:420px;width:100%;font-size:14px;line-height:2;color:#e2e8f0;">
|
||||||
|
<h2 style="font-size:16px;color:#93c5fd;margin-bottom:12px;">⌨️ Gyorsbillentyűk</h2>
|
||||||
|
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + S</kbd> — Mentés</div>
|
||||||
|
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + P</kbd> — Publikálás</div>
|
||||||
|
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + Shift + V</kbd> — Verziók</div>
|
||||||
|
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">?</kbd> — ez a súgó (Esc: bezárás)</div>
|
||||||
|
</div>`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
+6
-280
@@ -1,6 +1,11 @@
|
|||||||
// Branding page for the Content Editor: upload/replace and edit logos with
|
// Branding page for the Content Editor: upload/replace and edit logos with
|
||||||
// interactive Canvas editor (crop, zoom/pan, rotate/flip, padding, filters).
|
// 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 { LOGO_TARGETS } = require('./cms-logo');
|
||||||
|
const logoClientJs = fs.readFileSync(path.join(__dirname, 'cms-logo-client.js'), 'utf8');
|
||||||
|
|
||||||
|
|
||||||
const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
|
const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
|
||||||
<html lang="hu">
|
<html lang="hu">
|
||||||
@@ -174,286 +179,7 @@ const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
const CSRF_TOKEN = "${csrfToken}";
|
const CSRF_TOKEN = "${csrfToken}";
|
||||||
function setMsg(target, text, ok) {
|
${logoClientJs}
|
||||||
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');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// POST /save handler for the Content Editor — extracted to keep content-editor.js
|
||||||
|
// under the 400-line hard limit. Returns true when the request was handled.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function handleSaveRoute({
|
||||||
|
req, res, u, activeFile, files, maxBodyBytes, validate,
|
||||||
|
writeAudit, backupAndWrite, backupDir, user, clientAddress, cmsDirname,
|
||||||
|
}) {
|
||||||
|
if (req.method !== 'POST' || u.pathname !== '/save') return false;
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
let bodyTooLarge = false;
|
||||||
|
let bodySize = 0;
|
||||||
|
req.on('data', c => {
|
||||||
|
bodySize += c.length;
|
||||||
|
if (bodySize > maxBodyBytes) { bodyTooLarge = true; return; }
|
||||||
|
body += c;
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
if (bodyTooLarge) {
|
||||||
|
writeAudit('content_saved', { clientAddress, 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 ${maxBodyBytes} 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, 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 = validate(activeFile, data);
|
||||||
|
if (!validation.ok) {
|
||||||
|
writeAudit('content_saved', { clientAddress, 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 = backupAndWrite(files[activeFile], data, backupDir);
|
||||||
|
// 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 lock.
|
||||||
|
const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex');
|
||||||
|
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'ok' });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true, backup: path.relative(cmsDirname, backupFile), contentHash: newHash }));
|
||||||
|
} catch (e) {
|
||||||
|
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'error' });
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: false, error: e.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleSaveRoute };
|
||||||
Reference in New Issue
Block a user