6869 lines
297 KiB
JavaScript
6869 lines
297 KiB
JavaScript
/**
|
|
* Inline Editor - raiju CMS
|
|
* Edit content directly on the page when logged in as admin.
|
|
*
|
|
* Features:
|
|
* - WYSIWYG Text Editor with HTML mode
|
|
* - SoundCloud & YouTube Embeds
|
|
* - Drag & Drop Sorting
|
|
* - Add/Delete Sections
|
|
* - Event Management
|
|
* - Image & Audio Library
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Intercept fetch — redirect to login on session expiry (guard against double-wrap)
|
|
if (!window._raijuFetchWrapped) {
|
|
const _origFetch = window.fetch;
|
|
window.fetch = async function(...args) {
|
|
const response = await _origFetch.apply(this, args);
|
|
if (response.status === 401) {
|
|
try {
|
|
const clone = response.clone();
|
|
const data = await clone.json();
|
|
if (data.session_expired) {
|
|
window.location.href = '/admin/index.php?timeout=' + Math.floor(Date.now() / 1000);
|
|
return response;
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
return response;
|
|
};
|
|
window._raijuFetchWrapped = true;
|
|
}
|
|
|
|
// ============================================================
|
|
// HELPERS
|
|
// ============================================================
|
|
|
|
/**
|
|
* Dark single-colour logos (like the default Raiju icon) turn white on the dark admin bar
|
|
* (.admin-bar-logo-icon--default); multi-coloured site icons keep their colours. Decided from
|
|
* the rendered pixels: sites often replace the default file at assets/images/favicon.svg, so
|
|
* the path alone says nothing. Without pixel access (cross-origin logo) the path is used.
|
|
*/
|
|
function markAdminBarLogo(img, src) {
|
|
const decide = () => {
|
|
let mono = /(^|\/)assets\/images\/favicon\.svg(?:[?#].*)?$/.test(src);
|
|
try {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = canvas.height = 24;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(img, 0, 0, 24, 24);
|
|
const data = context.getImageData(0, 0, 24, 24).data;
|
|
const min = [255, 255, 255];
|
|
const max = [0, 0, 0];
|
|
let opaque = 0;
|
|
let luminance = 0;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (data[i + 3] < 200) continue;
|
|
opaque++;
|
|
for (let c = 0; c < 3; c++) {
|
|
min[c] = Math.min(min[c], data[i + c]);
|
|
max[c] = Math.max(max[c], data[i + c]);
|
|
}
|
|
luminance += (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255;
|
|
}
|
|
const singleColour = opaque > 0 && max.every((value, c) => value - min[c] <= 24);
|
|
mono = singleColour && luminance / opaque < 0.5;
|
|
} catch (e) {
|
|
// Pixels not readable: keep the path-based guess
|
|
}
|
|
img.classList.toggle('admin-bar-logo-icon--default', mono);
|
|
};
|
|
if (img.complete && img.naturalWidth) decide();
|
|
else img.addEventListener('load', decide, { once: true });
|
|
}
|
|
|
|
/** Escape a string for safe insertion into HTML via innerHTML / template literals. */
|
|
function escHtml(str) {
|
|
const d = document.createElement('div');
|
|
d.textContent = str;
|
|
return d.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
function tFallback(key, fallback, params) {
|
|
const value = t(key, params);
|
|
return value && value !== key ? value : fallback;
|
|
}
|
|
|
|
const A11Y_FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
let activeDialog = null;
|
|
let activeDialogPreviousFocus = null;
|
|
|
|
function setupDialogA11y(modal, titleId, initialSelector) {
|
|
const dialog = modal.querySelector('.editor-modal-content');
|
|
if (!dialog) return;
|
|
dialog.setAttribute('role', 'dialog');
|
|
dialog.setAttribute('aria-modal', 'true');
|
|
if (titleId) dialog.setAttribute('aria-labelledby', titleId);
|
|
modal.dataset.initialFocus = initialSelector || '.editor-close-btn';
|
|
if (modal.dataset.a11yReady === 'true') return;
|
|
modal.dataset.a11yReady = 'true';
|
|
modal.addEventListener('keydown', function(e) {
|
|
if (e.key !== 'Tab' || !modal.classList.contains('active')) return;
|
|
const focusable = Array.from(modal.querySelectorAll(A11Y_FOCUSABLE)).filter(el => el.offsetParent !== null);
|
|
if (!focusable.length) return;
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
function openDialogA11y(modal) {
|
|
activeDialog = modal;
|
|
activeDialogPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
setTimeout(() => {
|
|
const target = modal.querySelector(modal.dataset.initialFocus || A11Y_FOCUSABLE) || modal.querySelector(A11Y_FOCUSABLE);
|
|
if (target) target.focus();
|
|
}, 0);
|
|
}
|
|
|
|
function closeDialogA11y(modal) {
|
|
if (activeDialog === modal && activeDialogPreviousFocus && document.contains(activeDialogPreviousFocus)) {
|
|
activeDialogPreviousFocus.focus();
|
|
}
|
|
if (activeDialog === modal) {
|
|
activeDialog = null;
|
|
activeDialogPreviousFocus = null;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// CONFIGURATION
|
|
// ============================================================
|
|
|
|
const EditorConfig = {
|
|
apiUrl: '/admin/api.php',
|
|
csrfToken: null,
|
|
currentPage: null,
|
|
currentSection: null,
|
|
currentSectionIndex: null,
|
|
currentContentPage: null,
|
|
isHtmlMode: false,
|
|
contentData: {},
|
|
contentRevisions: {},
|
|
saving: false,
|
|
loadedPages: [],
|
|
currentEvent: null,
|
|
currentGroup: null,
|
|
eventsData: null,
|
|
draggedElement: null,
|
|
draggedIndex: null,
|
|
draggedContentPage: null,
|
|
// Language config (populated from meta tags during init)
|
|
languages: {},
|
|
defaultLang: 'en',
|
|
aiFeaturesEnabled: window.RJ_AI_FEATURES_ENABLED !== false,
|
|
// News post editing
|
|
isNewsPost: false,
|
|
newsPostId: null,
|
|
newsPostData: null,
|
|
// Edit-Mode State
|
|
editMode: false,
|
|
originalPageData: {},
|
|
dirtyPages: new Set(),
|
|
undoStack: [],
|
|
redoStack: []
|
|
};
|
|
|
|
const SESSION_KEEPALIVE_INTERVAL_MS = 5 * 60 * 1000;
|
|
const sessionKeepaliveEvents = ['pointerdown', 'keydown', 'input', 'focusin'];
|
|
let lastSessionKeepaliveAt = 0;
|
|
|
|
function requestSessionKeepalive(force = false) {
|
|
if (!EditorConfig.csrfToken || !EditorConfig.apiUrl) return;
|
|
const now = Date.now();
|
|
if (!force && now - lastSessionKeepaliveAt < SESSION_KEEPALIVE_INTERVAL_MS) return;
|
|
lastSessionKeepaliveAt = now;
|
|
|
|
const url = `${EditorConfig.apiUrl}?action=keepalive&csrf_token=${encodeURIComponent(EditorConfig.csrfToken)}&_=${now}`;
|
|
fetch(url, { credentials: 'same-origin', cache: 'no-store' })
|
|
.catch(error => {
|
|
console.warn('Session keepalive failed:', error);
|
|
});
|
|
}
|
|
|
|
function handleSessionActivity() {
|
|
if (!EditorConfig.editMode) return;
|
|
requestSessionKeepalive(false);
|
|
}
|
|
|
|
function startSessionKeepalive() {
|
|
requestSessionKeepalive(true);
|
|
sessionKeepaliveEvents.forEach(eventName => {
|
|
document.addEventListener(eventName, handleSessionActivity, true);
|
|
});
|
|
}
|
|
|
|
function stopSessionKeepalive() {
|
|
sessionKeepaliveEvents.forEach(eventName => {
|
|
document.removeEventListener(eventName, handleSessionActivity, true);
|
|
});
|
|
}
|
|
|
|
// Block type registry (injected from PHP via window.BlockTypeRegistry)
|
|
const BlockTypes = window.BlockTypeRegistry || {};
|
|
|
|
// Category display names
|
|
const CategoryLabels = {
|
|
content: t('cat.content'),
|
|
media: t('cat.media'),
|
|
cards: t('cat.cards'),
|
|
interactive: t('cat.interactive'),
|
|
layout: t('cat.layout'),
|
|
embed: t('cat.embed')
|
|
};
|
|
|
|
// Legacy compat alias
|
|
const SectionTypes = {};
|
|
for (const [type, def] of Object.entries(BlockTypes)) {
|
|
SectionTypes[type] = { label: def.label };
|
|
}
|
|
SectionTypes.project = { label: 'Card' };
|
|
|
|
// SVG Icons (monochrome)
|
|
const Icons = {
|
|
edit: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>',
|
|
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>',
|
|
drag: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>',
|
|
audio: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>',
|
|
video: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>',
|
|
text: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z"/></svg>',
|
|
plus: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>',
|
|
warning: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>',
|
|
eraser: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.008 4.008 0 0 1-5.66 0L2.81 17c-.78-.79-.78-2.05 0-2.84l10.6-10.6c.79-.78 2.05-.78 2.83 0M4.22 15.58l3.54 3.53c.78.79 2.04.79 2.83 0l3.53-3.53-4.95-4.95-4.95 4.95z"/></svg>',
|
|
undo: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>',
|
|
redo: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.06-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/></svg>',
|
|
save: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>',
|
|
history: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>',
|
|
eyeOpen: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>',
|
|
eyeClosed: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>',
|
|
image: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>',
|
|
link: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>',
|
|
card: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>',
|
|
upload: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>',
|
|
folder: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>',
|
|
grid: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z"/></svg>',
|
|
list: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>',
|
|
eye: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>',
|
|
replace: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>',
|
|
check: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>',
|
|
heading: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 4v3h5.5v12h3V7H19V4H5z"/></svg>',
|
|
quote: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>',
|
|
spacer: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 18H3v2h18v-2zM21 4H3v2h18V4zm-9 8.5l3.5 3.5-1.41 1.41L12 15.33l-2.09 2.08L8.5 16l3.5-3.5zM12 7.5L8.5 11l1.41 1.41L12 10.33l2.09 2.08L15.5 11 12 7.5z"/></svg>',
|
|
divider: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 11h18v2H3z"/></svg>',
|
|
// Offizielle Brand Icons
|
|
soundcloud: '<svg viewBox="0 0 24 24"><defs><style>.cls-1{fill:currentColor;stroke:currentColor;stroke-miterlimit:10;stroke-width:1.91px;}</style></defs><path class="cls-1" d="M22.5,13.93a2.87,2.87,0,0,1-2.86,2.87H13V7.47a4.82,4.82,0,0,1,1.44-.22,4.07,4.07,0,0,1,4.29,3.82h1A2.86,2.86,0,0,1,22.5,13.93Z"/><line class="cls-1" x1="10.09" y1="8.2" x2="10.09" y2="17.75"/><line class="cls-1" x1="7.23" y1="9.16" x2="7.23" y2="17.75"/><line class="cls-1" x1="4.36" y1="9.16" x2="4.36" y2="17.75"/><line class="cls-1" x1="1.5" y1="11.07" x2="1.5" y2="16.8"/></svg>',
|
|
youtube: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>'
|
|
};
|
|
|
|
// ============================================================
|
|
// UNDO HISTORY (localStorage-basiert)
|
|
// ============================================================
|
|
|
|
const UndoHistory = {
|
|
maxEntries: 10,
|
|
storageKeyPrefix: 'editor_history_',
|
|
|
|
// Save backup before a change
|
|
saveBackup(contentPage, data, actionDescription = 'Change') {
|
|
try {
|
|
const key = this.storageKeyPrefix + contentPage;
|
|
let history = this.getBackups(contentPage);
|
|
|
|
// Neuen Eintrag erstellen
|
|
const entry = {
|
|
timestamp: Date.now(),
|
|
date: new Date().toLocaleString('de-AT'),
|
|
action: actionDescription,
|
|
data: JSON.parse(JSON.stringify(data)) // Deep copy
|
|
};
|
|
|
|
// Insert at beginning (newest first)
|
|
history.unshift(entry);
|
|
|
|
// Auf maxEntries begrenzen
|
|
if (history.length > this.maxEntries) {
|
|
history = history.slice(0, this.maxEntries);
|
|
}
|
|
|
|
localStorage.setItem(key, JSON.stringify(history));
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error saving backup:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
// Get all backups for a page
|
|
getBackups(contentPage) {
|
|
try {
|
|
const key = this.storageKeyPrefix + contentPage;
|
|
const stored = localStorage.getItem(key);
|
|
return stored ? JSON.parse(stored) : [];
|
|
} catch (error) {
|
|
console.error('Error loading backups:', error);
|
|
return [];
|
|
}
|
|
},
|
|
|
|
// Get all pages with backups
|
|
getAllPages() {
|
|
const pages = [];
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i);
|
|
if (key && key.startsWith(this.storageKeyPrefix)) {
|
|
pages.push(key.replace(this.storageKeyPrefix, ''));
|
|
}
|
|
}
|
|
return pages;
|
|
},
|
|
|
|
// Delete history for a page
|
|
clearHistory(contentPage) {
|
|
const key = this.storageKeyPrefix + contentPage;
|
|
localStorage.removeItem(key);
|
|
},
|
|
|
|
// Delete entire history
|
|
clearAllHistory() {
|
|
const pages = this.getAllPages();
|
|
pages.forEach(page => this.clearHistory(page));
|
|
}
|
|
};
|
|
|
|
// Variable for last state (for instant undo in toast)
|
|
let lastSavedState = null;
|
|
let lastSavedPage = null;
|
|
let lastSavedType = null; // 'content' or 'event'
|
|
|
|
// ============================================================
|
|
// RESIZABLE MODAL FUNCTIONALITY
|
|
// ============================================================
|
|
|
|
const ModalResize = {
|
|
isResizing: false,
|
|
currentModal: null,
|
|
startX: 0,
|
|
startY: 0,
|
|
startWidth: 0,
|
|
startHeight: 0,
|
|
startLeft: 0,
|
|
startTop: 0,
|
|
|
|
// Initialize resize for a modal
|
|
init(modalContent) {
|
|
if (!modalContent || modalContent.querySelector('.modal-resize-handle')) return;
|
|
|
|
// Resize-Handle erstellen
|
|
const handle = document.createElement('div');
|
|
handle.className = 'modal-resize-handle';
|
|
handle.title = 'Resize';
|
|
modalContent.appendChild(handle);
|
|
|
|
// Event-Listener
|
|
handle.addEventListener('mousedown', (e) => this.startResize(e, modalContent));
|
|
},
|
|
|
|
startResize(e, modalContent) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
this.isResizing = true;
|
|
this.currentModal = modalContent;
|
|
this.startX = e.clientX;
|
|
this.startY = e.clientY;
|
|
|
|
const rect = modalContent.getBoundingClientRect();
|
|
this.startWidth = rect.width;
|
|
this.startHeight = rect.height;
|
|
this.startLeft = rect.left;
|
|
this.startTop = rect.top;
|
|
|
|
// If still centered, switch to absolute positioning
|
|
if (!modalContent.classList.contains('custom-size')) {
|
|
modalContent.style.width = this.startWidth + 'px';
|
|
modalContent.style.height = this.startHeight + 'px';
|
|
modalContent.style.left = this.startLeft + 'px';
|
|
modalContent.style.top = this.startTop + 'px';
|
|
modalContent.classList.add('custom-size');
|
|
}
|
|
|
|
modalContent.classList.add('resizing');
|
|
document.addEventListener('mousemove', this.handleMouseMove);
|
|
document.addEventListener('mouseup', this.handleMouseUp);
|
|
},
|
|
|
|
handleMouseMove: function(e) {
|
|
if (!ModalResize.isResizing || !ModalResize.currentModal) return;
|
|
|
|
const deltaX = e.clientX - ModalResize.startX;
|
|
const deltaY = e.clientY - ModalResize.startY;
|
|
|
|
const newWidth = Math.max(320, ModalResize.startWidth + deltaX);
|
|
const newHeight = Math.max(200, ModalResize.startHeight + deltaY);
|
|
|
|
// Limit max size
|
|
const maxWidth = window.innerWidth - 40;
|
|
const maxHeight = window.innerHeight - 40;
|
|
|
|
ModalResize.currentModal.style.width = Math.min(newWidth, maxWidth) + 'px';
|
|
ModalResize.currentModal.style.height = Math.min(newHeight, maxHeight) + 'px';
|
|
},
|
|
|
|
handleMouseUp: function() {
|
|
if (!ModalResize.currentModal) return;
|
|
|
|
ModalResize.currentModal.classList.remove('resizing');
|
|
ModalResize.isResizing = false;
|
|
ModalResize.currentModal = null;
|
|
|
|
document.removeEventListener('mousemove', ModalResize.handleMouseMove);
|
|
document.removeEventListener('mouseup', ModalResize.handleMouseUp);
|
|
},
|
|
|
|
// Reset modal to default size (on close)
|
|
reset(modalContent) {
|
|
if (!modalContent) return;
|
|
modalContent.classList.remove('custom-size', 'resizing');
|
|
modalContent.style.width = '';
|
|
modalContent.style.height = '';
|
|
modalContent.style.left = '';
|
|
modalContent.style.top = '';
|
|
}
|
|
};
|
|
|
|
// ============================================================
|
|
// CONFIRM DIALOG (replaces window.confirm)
|
|
// ============================================================
|
|
|
|
let confirmCallback = null;
|
|
|
|
function createConfirmDialog() {
|
|
if (document.getElementById('confirm-dialog-modal')) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'confirm-dialog-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop" aria-hidden="true"></div>
|
|
<div class="editor-modal-content editor-modal-small">
|
|
<div class="editor-modal-header">
|
|
<h3 id="confirm-dialog-title">Confirm</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closeConfirmDialog(false)" aria-label="${escHtml(tFallback('close', 'Close'))}">×</button>
|
|
</div>
|
|
<div class="editor-modal-body">
|
|
<div class="confirm-dialog-content">
|
|
<div class="confirm-dialog-icon">${Icons.warning}</div>
|
|
<p class="confirm-dialog-message" id="confirm-dialog-message"></p>
|
|
<p class="confirm-dialog-hint" id="confirm-dialog-hint"></p>
|
|
</div>
|
|
</div>
|
|
<div class="confirm-dialog-footer">
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.closeConfirmDialog(false)">${t('cancel')}</button>
|
|
<button type="button" class="editor-btn editor-btn-danger" id="confirm-dialog-action-btn" onclick="InlineEditor.closeConfirmDialog(true)">${t('delete')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
setupDialogA11y(modal, 'confirm-dialog-title', '#confirm-dialog-action-btn');
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', () => closeConfirmDialog(false));
|
|
|
|
// Resizable Modal
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
}
|
|
|
|
function showConfirmDialog(title, message, hint, callback, buttonText = t('delete'), buttonClass = 'editor-btn-danger') {
|
|
createConfirmDialog();
|
|
confirmCallback = callback;
|
|
|
|
document.getElementById('confirm-dialog-title').textContent = title;
|
|
document.getElementById('confirm-dialog-message').textContent = message;
|
|
document.getElementById('confirm-dialog-hint').textContent = hint || '';
|
|
document.getElementById('confirm-dialog-hint').style.display = hint ? 'block' : 'none';
|
|
document.getElementById('confirm-dialog-action-btn').textContent = buttonText;
|
|
document.getElementById('confirm-dialog-action-btn').className = 'editor-btn ' + buttonClass;
|
|
|
|
const modal = document.getElementById('confirm-dialog-modal');
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
openDialogA11y(modal);
|
|
openDialogA11y(modal);
|
|
}
|
|
|
|
function closeConfirmDialog(confirmed) {
|
|
const modal = document.getElementById('confirm-dialog-modal');
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
closeDialogA11y(modal);
|
|
|
|
if (confirmCallback) {
|
|
const cb = confirmCallback;
|
|
confirmCallback = null;
|
|
if (confirmed) cb();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// PROMPT-DIALOG (ersetzt window.prompt)
|
|
// ============================================================
|
|
|
|
let promptCallback = null;
|
|
|
|
function createPromptDialog() {
|
|
if (document.getElementById('prompt-dialog-modal')) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'prompt-dialog-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop" aria-hidden="true"></div>
|
|
<div class="editor-modal-content editor-modal-small">
|
|
<div class="editor-modal-header">
|
|
<h3 id="prompt-dialog-title">Eingabe</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closePromptDialog(null)" aria-label="${escHtml(tFallback('close', 'Close'))}">×</button>
|
|
</div>
|
|
<div class="editor-modal-body">
|
|
<div class="prompt-dialog-content">
|
|
<label class="prompt-dialog-label" id="prompt-dialog-label"></label>
|
|
<input type="text" class="prompt-dialog-input" id="prompt-dialog-input">
|
|
</div>
|
|
</div>
|
|
<div class="editor-modal-footer">
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.closePromptDialog(null)">Cancel</button>
|
|
<button type="button" class="editor-btn editor-btn-primary" onclick="InlineEditor.submitPromptDialog()">OK</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
setupDialogA11y(modal, 'prompt-dialog-title', '#prompt-dialog-input');
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', () => closePromptDialog(null));
|
|
|
|
// Enter key to confirm
|
|
document.getElementById('prompt-dialog-input').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
submitPromptDialog();
|
|
}
|
|
});
|
|
|
|
// Resizable Modal
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
}
|
|
|
|
function showPromptDialog(title, label, defaultValue, callback) {
|
|
createPromptDialog();
|
|
promptCallback = callback;
|
|
|
|
document.getElementById('prompt-dialog-title').textContent = title;
|
|
document.getElementById('prompt-dialog-label').textContent = label;
|
|
document.getElementById('prompt-dialog-input').value = defaultValue || '';
|
|
|
|
const modal = document.getElementById('prompt-dialog-modal');
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
openDialogA11y(modal);
|
|
|
|
// Focus on input
|
|
setTimeout(() => {
|
|
document.getElementById('prompt-dialog-input').focus();
|
|
document.getElementById('prompt-dialog-input').select();
|
|
}, 100);
|
|
}
|
|
|
|
function closePromptDialog(value) {
|
|
const modal = document.getElementById('prompt-dialog-modal');
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
closeDialogA11y(modal);
|
|
|
|
if (promptCallback) {
|
|
const cb = promptCallback;
|
|
promptCallback = null;
|
|
cb(value);
|
|
}
|
|
}
|
|
|
|
function submitPromptDialog() {
|
|
const value = document.getElementById('prompt-dialog-input').value;
|
|
closePromptDialog(value);
|
|
}
|
|
|
|
// ============================================================
|
|
// INITIALISIERUNG
|
|
// ============================================================
|
|
|
|
function initEditor() {
|
|
const tokenMeta = document.querySelector('meta[name="csrf-token"]');
|
|
if (!tokenMeta) return;
|
|
|
|
EditorConfig.csrfToken = tokenMeta.content;
|
|
|
|
const pageMeta = document.querySelector('meta[name="content-page"]');
|
|
if (pageMeta) {
|
|
EditorConfig.currentPage = pageMeta.content;
|
|
}
|
|
|
|
const langMeta = document.querySelector('meta[name="site-languages"]');
|
|
if (langMeta) {
|
|
try { EditorConfig.languages = JSON.parse(langMeta.content); } catch(e) {}
|
|
}
|
|
const defaultLangMeta = document.querySelector('meta[name="site-lang-default"]');
|
|
if (defaultLangMeta) {
|
|
EditorConfig.defaultLang = defaultLangMeta.content;
|
|
}
|
|
|
|
// Detect news post page
|
|
const newsPostEl = document.querySelector('[data-news-post]');
|
|
if (newsPostEl && window.__cmsNewsPost) {
|
|
EditorConfig.isNewsPost = true;
|
|
EditorConfig.newsPostId = newsPostEl.dataset.newsPost;
|
|
EditorConfig.newsPostData = window.__cmsNewsPost;
|
|
}
|
|
|
|
// Apply admin theme preference to frontend for editor modals
|
|
try {
|
|
const adminTheme = localStorage.getItem('site-admin-theme');
|
|
if (adminTheme) {
|
|
let themeValue = adminTheme;
|
|
if (themeValue === 'system') {
|
|
themeValue = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
}
|
|
document.documentElement.setAttribute('data-site-theme', themeValue);
|
|
}
|
|
} catch(e) {}
|
|
|
|
// Alle Content-Pages sammeln
|
|
const contentPages = new Set();
|
|
if (EditorConfig.currentPage) {
|
|
contentPages.add(EditorConfig.currentPage);
|
|
}
|
|
|
|
document.querySelectorAll('[data-content-page]').forEach(el => {
|
|
contentPages.add(el.dataset.contentPage);
|
|
});
|
|
// Custom layouts may reference shared content only through individual
|
|
// helpers, without a content-area wrapper or editable group. Load the
|
|
// complete document and its revision before any of those fields can save.
|
|
document.querySelectorAll('.editable-field[data-page], [data-editable-group][data-page], [data-editable-link][data-page], [data-editable-image][data-page], [data-editable-icon][data-page], [data-editable-list][data-list-page]').forEach(el => {
|
|
const page = el.dataset.page || el.dataset.listPage;
|
|
// Event image helpers use the separate events API, not page JSON.
|
|
if (page && page !== 'events') contentPages.add(page);
|
|
});
|
|
|
|
// Alle Content-Daten laden
|
|
const loadPromises = Array.from(contentPages).map(page => loadContent(page));
|
|
loadPromises.push(loadEvents());
|
|
|
|
Promise.all(loadPromises).then(async () => {
|
|
attachEditorChromeHoverStates();
|
|
createEditorUI();
|
|
createGroupEditorUI();
|
|
createEventEditorUI();
|
|
createAddSectionUI();
|
|
attachEditHandlers();
|
|
attachEventEditHandlers();
|
|
attachFooterEditHandlers();
|
|
attachGroupEditHandlers();
|
|
attachFieldEditHandlers();
|
|
attachLinkEditHandlers();
|
|
attachImageEditHandlers();
|
|
attachListEditHandlers();
|
|
attachComparisonRowHandlers();
|
|
attachComparisonCellToggles();
|
|
// Await the admin bar — showAdminBar() awaits a settings fetch
|
|
// and only then injects the bar markup. Without await, the
|
|
// auto-restore branch below ran enterEditMode() before the
|
|
// bar existed, leaving the toolbar stuck on the "start edit"
|
|
// state even though edit mode was active internally.
|
|
await showAdminBar();
|
|
|
|
// Notify admin about auto-generated content fields
|
|
if (window.RJ_AUTO_GENERATED && window.RJ_AUTO_GENERATED.length > 0) {
|
|
const count = window.RJ_AUTO_GENERATED.length;
|
|
const pages = [...new Set(window.RJ_AUTO_GENERATED.map(f => f.page))];
|
|
const msg = count === 1
|
|
? `Auto-wrote 1 missing field to ${pages[0]}.json`
|
|
: `Auto-wrote ${count} missing fields to ${pages.join(', ')}.json`;
|
|
setTimeout(() => showToast(msg, 'info'), 500);
|
|
}
|
|
|
|
// Auto-restore edit mode after structural-change reload
|
|
if (sessionStorage.getItem('site-edit-mode') === 'true') {
|
|
sessionStorage.removeItem('site-edit-mode');
|
|
enterEditMode();
|
|
}
|
|
attachEditModeHint();
|
|
});
|
|
}
|
|
|
|
// A reminder for authenticated visitors who try to edit before enabling the editor.
|
|
let editModeHintReady = false;
|
|
|
|
function attachEditModeHint() {
|
|
if (editModeHintReady || !EditorConfig.csrfToken || !document.getElementById('admin-btn-edit')) return;
|
|
editModeHintReady = true;
|
|
let clicks = 0;
|
|
const interactive = [
|
|
'a', 'button', 'input', 'textarea', 'select', 'label', 'form', 'summary',
|
|
'audio', 'video', 'iframe', '[onclick]', '[tabindex]:not([tabindex="-1"])',
|
|
'[contenteditable]:not([contenteditable="false"])',
|
|
'[role="button"]', '[role="link"]', '[role="tab"]', '[role="menuitem"]',
|
|
'[role="checkbox"]', '[role="radio"]', '[role="switch"]', '[role="slider"]',
|
|
'[role="combobox"]', '[role="option"]', '[aria-controls]', '[aria-haspopup]',
|
|
'#admin-bar', '#adminAccess', '.editor-modal', '.rj-imgmgr-modal'
|
|
].join(', ');
|
|
|
|
function onPageClick(event) {
|
|
if (EditorConfig.editMode) {
|
|
clicks = 0;
|
|
return;
|
|
}
|
|
if (!event.isTrusted || event.defaultPrevented || event.button !== 0 || event.detail === 0 ||
|
|
event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
if (!target || target.closest(interactive)) return;
|
|
// Never cover another dialog, including dialogs supplied by the website.
|
|
if (Array.from(document.querySelectorAll('dialog[open], [role="dialog"], [aria-modal="true"], .editor-modal.active'))
|
|
.some(dialog => dialog.getClientRects().length > 0)) return;
|
|
if (++clicks < 2) return;
|
|
document.removeEventListener('click', onPageClick);
|
|
showEditModeHint();
|
|
}
|
|
|
|
document.addEventListener('click', onPageClick);
|
|
}
|
|
|
|
function showEditModeHint() {
|
|
const dialog = document.createElement('dialog');
|
|
dialog.className = 'editor-mode-hint';
|
|
dialog.setAttribute('aria-labelledby', 'editor-mode-hint-title');
|
|
dialog.setAttribute('aria-describedby', 'editor-mode-hint-message');
|
|
dialog.innerHTML = `
|
|
<h2 id="editor-mode-hint-title">${escHtml(tFallback('edit_reminder.title', 'Edit this page?'))}</h2>
|
|
<p id="editor-mode-hint-message">${escHtml(tFallback('edit_reminder.message', 'You are logged in. Activate the visual editor to edit text and images directly on this page.'))}</p>
|
|
<div class="editor-mode-hint__actions">
|
|
<button type="button" class="editor-btn editor-btn-secondary" data-hint-dismiss>${escHtml(tFallback('edit_reminder.dismiss', 'Keep browsing'))}</button>
|
|
<button type="button" class="editor-btn editor-btn-primary" data-hint-activate autofocus>${escHtml(tFallback('edit_reminder.activate', 'Activate visual editor'))}</button>
|
|
</div>`;
|
|
dialog.querySelector('[data-hint-dismiss]').addEventListener('click', () => dialog.close());
|
|
dialog.querySelector('[data-hint-activate]').addEventListener('click', () => {
|
|
dialog.close();
|
|
if (!EditorConfig.editMode) enterEditMode();
|
|
document.getElementById('admin-btn-cancel')?.focus();
|
|
});
|
|
dialog.addEventListener('close', () => dialog.remove(), { once: true });
|
|
document.body.appendChild(dialog);
|
|
dialog.showModal();
|
|
}
|
|
|
|
function attachEditorChromeHoverStates() {
|
|
if (document.documentElement.dataset.editorHoverReady === 'true') return;
|
|
document.documentElement.dataset.editorHoverReady = 'true';
|
|
|
|
const selector = [
|
|
'#admin-bar .admin-bar-btn',
|
|
'.section-add-btn-toggle',
|
|
'.raiju-editor-control-btn',
|
|
'.raiju-editor-drag-handle'
|
|
].join(',');
|
|
|
|
document.addEventListener('pointerover', (e) => {
|
|
const el = e.target.closest(selector);
|
|
if (!el || el.disabled) return;
|
|
el.classList.add('is-hover');
|
|
}, true);
|
|
|
|
document.addEventListener('pointerout', (e) => {
|
|
const el = e.target.closest(selector);
|
|
if (!el) return;
|
|
if (e.relatedTarget && el.contains(e.relatedTarget)) return;
|
|
el.classList.remove('is-hover');
|
|
}, true);
|
|
}
|
|
|
|
// ============================================================
|
|
// DATEN LADEN
|
|
// ============================================================
|
|
|
|
async function loadEvents() {
|
|
try {
|
|
const response = await fetch(`${EditorConfig.apiUrl}?action=load-events`);
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
EditorConfig.eventsData = result.data;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading events:', error);
|
|
}
|
|
}
|
|
|
|
async function loadContent(page) {
|
|
try {
|
|
const response = await fetch(`${EditorConfig.apiUrl}?action=load&page=${page}`);
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
EditorConfig.contentData[page] = result.data;
|
|
EditorConfig.contentRevisions[page] = result.revision;
|
|
EditorConfig.loadedPages.push(page);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading ' + page + ':', error);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ADMIN BAR
|
|
// ============================================================
|
|
|
|
// Persisted page backups (separate from the current session's undo history).
|
|
const PageBackups = {
|
|
items: [], loading: false, restoring: false, loadFailed: false, reloadRequired: false,
|
|
update() {
|
|
const group = document.getElementById('admin-page-backups');
|
|
const select = document.getElementById('admin-backup-select');
|
|
const button = document.getElementById('admin-btn-restore-backup');
|
|
if (!group || !select || !button) return;
|
|
group.hidden = !this.items.length && !this.loadFailed;
|
|
document.getElementById('admin-bar').classList.toggle('has-page-backups', !group.hidden);
|
|
const busy = this.loading || this.restoring || EditorConfig.saving;
|
|
select.disabled = busy || this.loadFailed || this.reloadRequired;
|
|
button.disabled = busy || this.reloadRequired || (!this.loadFailed && !select.value);
|
|
button.textContent = t(this.restoring ? 'page_backups.restoring' : this.loadFailed ? 'page_backups.retry' : 'page_backups.apply');
|
|
button.title = t(this.reloadRequired ? 'page_backups.reload' : 'page_backups.restore_title');
|
|
group.setAttribute('aria-busy', String(!!busy));
|
|
},
|
|
async load() {
|
|
if (!EditorConfig.currentPage || EditorConfig.isNewsPost || this.loading || this.restoring) return;
|
|
const select = document.getElementById('admin-backup-select');
|
|
if (!select) return;
|
|
this.loading = true;
|
|
this.update();
|
|
try {
|
|
const response = await fetch(`${EditorConfig.apiUrl}?action=backups&page=${encodeURIComponent(EditorConfig.currentPage)}`, {cache: 'no-store'});
|
|
const result = await response.json();
|
|
if (!response.ok || !result.success || !Array.isArray(result.data)) throw new Error('Backup list unavailable');
|
|
// Only show backups of this exact content page, never shared or nested pages.
|
|
this.items = result.data.filter(item => {
|
|
const match = String(item.filename || '').match(/^(.*)_\d{4}-\d{2}-\d{2}_\d{6}(?:_[a-f0-9]{6})?\.json$/);
|
|
return match && match[1] === EditorConfig.currentPage;
|
|
});
|
|
const selected = select.value;
|
|
select.replaceChildren();
|
|
const labels = this.items.map(item => this.label(item));
|
|
const seen = new Map();
|
|
this.items.forEach((item, index) => {
|
|
const option = document.createElement('option');
|
|
let label = labels[index];
|
|
if (labels.indexOf(label) !== labels.lastIndexOf(label)) label = this.label(item, true);
|
|
const occurrence = (seen.get(label) || 0) + 1;
|
|
seen.set(label, occurrence);
|
|
option.value = item.filename;
|
|
option.textContent = label + (occurrence > 1 ? ` (${occurrence})` : '');
|
|
select.appendChild(option);
|
|
});
|
|
if (this.items.some(item => item.filename === selected)) select.value = selected;
|
|
this.loadFailed = false;
|
|
} catch (error) {
|
|
this.loadFailed = true;
|
|
const option = document.createElement('option');
|
|
option.value = '';
|
|
option.textContent = t('page_backups.unavailable');
|
|
select.replaceChildren(option);
|
|
} finally {
|
|
this.loading = false;
|
|
this.update();
|
|
}
|
|
},
|
|
label(item, seconds = false) {
|
|
const date = new Date(Number(item.timestamp) * 1000);
|
|
if (!Number.isFinite(date.getTime())) return `Backup ${item.date || ''} ${item.time || ''}`.trim();
|
|
const locale = document.documentElement.lang || 'en';
|
|
const formatted = new Intl.DateTimeFormat(locale, {
|
|
day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
...(seconds ? {second: '2-digit'} : {}), hour12: false
|
|
}).format(date).replace(/,\s*/g, ' ');
|
|
return `Backup ${formatted}`;
|
|
},
|
|
requestRestore() {
|
|
if (this.loading || this.restoring || this.reloadRequired || EditorConfig.saving) return;
|
|
if (this.loadFailed) { void this.load(); return; }
|
|
const select = document.getElementById('admin-backup-select');
|
|
const item = this.items.find(backup => backup.filename === select?.value);
|
|
if (!item) return;
|
|
flushActiveInlineEdits();
|
|
const revision = window.RaijuRevisions?.get('page:' + EditorConfig.currentPage)
|
|
|| EditorConfig.contentRevisions[EditorConfig.currentPage];
|
|
if (!revision) {
|
|
this.reloadRequired = true;
|
|
this.update();
|
|
showToast(t('page_backups.reload'), 'error');
|
|
return;
|
|
}
|
|
const message = t('page_backups.confirm', {backup: select.options[select.selectedIndex].textContent});
|
|
const hint = t(EditorConfig.dirtyPages.size > 0 ? 'page_backups.unsaved' : 'page_backups.saved_first');
|
|
showConfirmDialog(t('page_backups.restore_title'), message, hint,
|
|
() => this.restore(item, revision), t('page_backups.apply'), 'editor-btn-primary');
|
|
},
|
|
async restore(item, revision) {
|
|
if (this.restoring || EditorConfig.saving) return;
|
|
this.restoring = true;
|
|
this.update();
|
|
const controls = Array.from(document.querySelectorAll('#admin-bar .admin-bar-btn'));
|
|
const disabled = controls.map(control => control.disabled);
|
|
controls.forEach(control => { control.disabled = true; });
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'restore');
|
|
formData.append('page', EditorConfig.currentPage);
|
|
formData.append('backup', item.filename);
|
|
formData.append('revision', revision);
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
const response = await fetch(EditorConfig.apiUrl, {method: 'POST', body: formData});
|
|
const result = await response.json();
|
|
if (!response.ok || !result.success) {
|
|
this.reloadRequired = response.status === 409 || response.status === 428;
|
|
showToast(this.reloadRequired ? t('page_backups.reload') : t('page_backups.failed'), 'error');
|
|
return;
|
|
}
|
|
const resumeEditing = EditorConfig.editMode;
|
|
exitEditModeClean();
|
|
if (resumeEditing) sessionStorage.setItem('site-edit-mode', 'true');
|
|
else sessionStorage.removeItem('site-edit-mode');
|
|
location.reload();
|
|
} catch (error) {
|
|
// A failed response may follow a successful write; never repeat it automatically.
|
|
this.reloadRequired = true;
|
|
showToast(t('page_backups.unconfirmed'), 'error');
|
|
} finally {
|
|
this.restoring = false;
|
|
controls.forEach((control, index) => { control.disabled = disabled[index]; });
|
|
this.update();
|
|
}
|
|
}
|
|
};
|
|
|
|
async function showAdminBar() {
|
|
// Load branding settings
|
|
let brandLogo = '/assets/images/favicon.svg';
|
|
let brandName = 'CMS';
|
|
let showBranding = true;
|
|
try {
|
|
const settingsResp = await fetch('/admin/api.php?action=load-settings');
|
|
const settingsResult = await settingsResp.json();
|
|
if (settingsResult.success && settingsResult.data) {
|
|
const d = settingsResult.data;
|
|
const b = d.branding || {};
|
|
brandLogo = d.logo || d.favicon || b.logo || brandLogo;
|
|
brandName = d.siteName || b.name || brandName;
|
|
showBranding = b.showBranding !== false;
|
|
}
|
|
} catch (e) { /* use defaults */ }
|
|
|
|
const bar = document.createElement('div');
|
|
bar.id = 'admin-bar';
|
|
// Monochrome treatment (--default) is decided from the rendered logo, see markAdminBarLogo()
|
|
const logoHtml = showBranding
|
|
? `<img src="${escHtml(brandLogo)}" alt="${escHtml(brandName)}" width="24" height="24" class="admin-bar-logo-icon">`
|
|
: '';
|
|
const seoHealth = window.RJ_SEO_HEALTH || { status: 'yellow', score: 0, label: 'SEO prüfen', issues: ['Keine SEO-Prüfung verfügbar.'] };
|
|
const seoIssues = Array.isArray(seoHealth.issues) ? seoHealth.issues : [];
|
|
const seoDetail = seoIssues.map(issue => `<li>${escHtml(issue)}</li>`).join('');
|
|
const seoA11yLabel = [seoHealth.label || 'SEO', `${Number(seoHealth.score || 0)}/100`].concat(seoIssues).join('. ');
|
|
const contentEditorHref = EditorConfig.isNewsPost
|
|
? '/admin/dashboard.php?tab=news&post=' + encodeURIComponent(EditorConfig.newsPostId || '')
|
|
: '/admin/dashboard.php#page/' + encodeURIComponent(EditorConfig.currentPage || '');
|
|
const adminBaseUrl = window.RJ_ADMIN_BASE_URL || '/admin/';
|
|
const logoutUrl = new URL(adminBaseUrl, window.location.origin);
|
|
logoutUrl.searchParams.set('logout', '1');
|
|
logoutUrl.searchParams.set('redirect', window.location.pathname + window.location.search + window.location.hash);
|
|
const seoHtml = `
|
|
<a class="admin-bar-seo admin-bar-seo--${escHtml(seoHealth.status || 'yellow')}" href="${contentEditorHref}" aria-label="${escHtml(seoA11yLabel)}">
|
|
<span class="admin-bar-seo-dot" aria-hidden="true"></span>
|
|
<span class="admin-bar-seo-label">${escHtml(seoHealth.label || 'SEO')}</span>
|
|
<span class="admin-bar-seo-score">${Number(seoHealth.score || 0)}/100</span>
|
|
<div class="admin-bar-seo-popover" role="tooltip">
|
|
<strong>${escHtml(seoHealth.label || 'SEO')}</strong>
|
|
<ul>${seoDetail}</ul>
|
|
</div>
|
|
</a>
|
|
`;
|
|
bar.innerHTML = `
|
|
<div class="admin-bar-inner">
|
|
<div class="admin-bar-left">
|
|
${logoHtml}
|
|
<a href="/admin/dashboard" class="admin-bar-link"><svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg> ${t('dashboard')}</a>
|
|
</div>
|
|
<div class="admin-bar-center">
|
|
<div class="admin-bar-backups" id="admin-page-backups" role="group" aria-label="${escHtml(t('page_backups.label'))}" hidden>
|
|
<select class="admin-bar-backup-select" id="admin-backup-select" data-native-select aria-label="${escHtml(t('page_backups.label'))}"></select>
|
|
<button type="button" class="admin-bar-btn" id="admin-btn-restore-backup">${t('page_backups.apply')}</button>
|
|
</div>
|
|
<span class="admin-bar-info" id="admin-bar-info" role="status" aria-live="polite" aria-atomic="true"></span>
|
|
<div class="admin-bar-edit-controls" style="display:none;">
|
|
<button type="button" class="admin-bar-btn admin-bar-btn-undo" id="admin-btn-undo" disabled title="${t('undo')} (${navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}+Z)">
|
|
${Icons.undo}
|
|
</button>
|
|
<button type="button" class="admin-bar-btn admin-bar-btn-redo" id="admin-btn-redo" disabled title="${t('redo')} (${navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⇧⌘' : 'Ctrl+Shift+'}Z)">
|
|
${Icons.redo}
|
|
</button>
|
|
</div>
|
|
<div class="admin-bar-edit-controls" style="display:none;">
|
|
<button type="button" class="admin-bar-btn admin-bar-btn-cancel" id="admin-btn-cancel">
|
|
${t('cancel')}
|
|
</button>
|
|
<button type="button" class="admin-bar-btn admin-bar-btn-save" id="admin-btn-save">
|
|
${Icons.save} ${t('save')}
|
|
</button>
|
|
<button type="button" class="admin-bar-btn admin-bar-btn-save admin-bar-btn-save-exit" id="admin-btn-save-exit">
|
|
${Icons.save} ${t('save_exit')}
|
|
</button>
|
|
</div>
|
|
<div class="admin-bar-actions">
|
|
<button type="button" class="admin-bar-btn admin-bar-btn-edit" id="admin-btn-edit">
|
|
${t('visual_editor')}
|
|
</button>
|
|
<a href="${contentEditorHref}" class="admin-bar-btn admin-bar-btn-content-editor" id="admin-btn-content-editor">
|
|
${t('content_editor')}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="admin-bar-right">
|
|
${seoHtml}
|
|
<a href="${logoutUrl.toString()}" class="admin-bar-link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/></svg> ${t('logout')}</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.insertBefore(bar, document.body.firstChild);
|
|
const barLogo = bar.querySelector('.admin-bar-logo-icon');
|
|
if (barLogo) markAdminBarLogo(barLogo, brandLogo);
|
|
document.body.classList.add('has-admin-bar');
|
|
|
|
const syncAdminBarHeight = () => {
|
|
document.documentElement.style.setProperty('--raiju-admin-bar-height', Math.ceil(bar.getBoundingClientRect().height) + 'px');
|
|
};
|
|
syncAdminBarHeight();
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
const barObserver = new ResizeObserver(syncAdminBarHeight);
|
|
barObserver.observe(bar);
|
|
} else {
|
|
window.addEventListener('resize', syncAdminBarHeight);
|
|
}
|
|
|
|
// Button Event Listeners
|
|
document.getElementById('admin-btn-edit').addEventListener('click', enterEditMode);
|
|
document.getElementById('admin-btn-save').addEventListener('click', () => saveAllChanges({ exitAfterSave: false }));
|
|
document.getElementById('admin-btn-save-exit').addEventListener('click', () => saveAllChanges({ exitAfterSave: true }));
|
|
document.getElementById('admin-btn-cancel').addEventListener('click', () => exitEditMode(false));
|
|
document.getElementById('admin-btn-undo').addEventListener('click', undo);
|
|
document.getElementById('admin-btn-redo').addEventListener('click', redo);
|
|
document.getElementById('admin-btn-restore-backup').addEventListener('click', () => PageBackups.requestRestore());
|
|
document.getElementById('admin-backup-select').addEventListener('change', () => PageBackups.update());
|
|
void PageBackups.load();
|
|
}
|
|
|
|
function updateAdminBarMode(editing) {
|
|
const actions = document.querySelectorAll('.admin-bar-actions');
|
|
const editControls = document.querySelectorAll('.admin-bar-edit-controls');
|
|
const infoEl = document.getElementById('admin-bar-info');
|
|
|
|
actions.forEach(el => el.style.display = editing ? 'none' : '');
|
|
editControls.forEach(el => el.style.display = editing ? '' : 'none');
|
|
|
|
if (infoEl) {
|
|
infoEl.textContent = editing
|
|
? t('edit_mode_hint')
|
|
: '';
|
|
}
|
|
}
|
|
|
|
function updateUndoRedoButtons() {
|
|
const undoBtn = document.getElementById('admin-btn-undo');
|
|
const redoBtn = document.getElementById('admin-btn-redo');
|
|
if (undoBtn) undoBtn.disabled = EditorConfig.undoStack.length === 0;
|
|
if (redoBtn) redoBtn.disabled = EditorConfig.redoStack.length === 0;
|
|
document.querySelectorAll('#admin-btn-save, #admin-btn-save-exit').forEach(saveBtn => {
|
|
if (EditorConfig.dirtyPages.size > 0) {
|
|
saveBtn.classList.add('has-changes');
|
|
} else {
|
|
saveBtn.classList.remove('has-changes');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// EDIT MODE — Enter / Exit / Save All / Undo / Redo
|
|
// ============================================================
|
|
|
|
// --- Navigation guard for unsaved changes ---
|
|
|
|
function beforeUnloadGuard(e) {
|
|
if (EditorConfig.editMode) {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
}
|
|
|
|
function showEditorConfirm(message, onConfirm) {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'raiju-editor-confirm';
|
|
overlay.innerHTML =
|
|
'<div class="raiju-editor-confirm__dialog" role="dialog" aria-modal="true">' +
|
|
'<h3>Bearbeitungsmodus verlassen?</h3>' +
|
|
'<p></p>' +
|
|
'<div class="raiju-editor-confirm__actions">' +
|
|
'<button type="button" class="raiju-editor-confirm__btn raiju-editor-confirm__btn--secondary" data-action="cancel">Abbrechen</button>' +
|
|
'<button type="button" class="raiju-editor-confirm__btn raiju-editor-confirm__btn--primary" data-action="confirm">Fortfahren</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
overlay.querySelector('p').textContent = message || '';
|
|
const style = document.createElement('style');
|
|
style.textContent = '.raiju-editor-confirm{position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;background:rgba(17,24,39,.55);padding:20px;font-family:var(--rj-font-sans,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif)}.raiju-editor-confirm__dialog{width:min(430px,100%);background:#fff;border-radius:10px;box-shadow:0 18px 60px rgba(15,23,42,.25);padding:22px}.raiju-editor-confirm__dialog h3{margin:0 0 10px;font-family:inherit;font-size:20px;letter-spacing:normal}.raiju-editor-confirm__dialog p{margin:0 0 18px;color:#374151;line-height:1.45}.raiju-editor-confirm__actions{display:flex;gap:10px;justify-content:flex-end}.raiju-editor-confirm__btn{border:1px solid #d1d5db;border-radius:6px;cursor:pointer;font:inherit;padding:9px 14px}.raiju-editor-confirm__btn--primary{background:#5aa6a6;border-color:#5aa6a6;color:#fff}.raiju-editor-confirm__btn--secondary{background:#fff;color:#111827}';
|
|
overlay.appendChild(style);
|
|
function close() {
|
|
overlay.remove();
|
|
}
|
|
overlay.addEventListener('click', function(event) {
|
|
const action = event.target && event.target.getAttribute('data-action');
|
|
if (action === 'cancel' || event.target === overlay) close();
|
|
if (action === 'confirm') {
|
|
close();
|
|
onConfirm();
|
|
}
|
|
});
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('[data-action="confirm"]').focus();
|
|
}
|
|
|
|
function navClickGuard(e) {
|
|
if (!EditorConfig.editMode) return;
|
|
|
|
// Only intercept real navigation links (not editor buttons, anchors, etc.)
|
|
const link = e.target.closest('a[href]');
|
|
if (!link) return;
|
|
const href = link.getAttribute('href');
|
|
|
|
// Editable links must be intercepted in the capture phase. Page-level
|
|
// hash/smooth-scroll handlers may be registered on the link itself
|
|
// before the editor's direct click handler.
|
|
if (link.hasAttribute('data-editable-link')) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openLinkEditor(link);
|
|
return;
|
|
}
|
|
|
|
if (!href || href === '#' || href.startsWith('#') || href.startsWith('javascript:')) return;
|
|
|
|
// Don't block admin bar buttons (they have their own logic)
|
|
if (link.closest('.admin-bar')) return;
|
|
// Don't block editor modal links
|
|
if (link.closest('.editor-modal')) return;
|
|
// Don't block footer editable fields (handled by footer editor)
|
|
if (link.classList.contains('editable-footer-field')) return;
|
|
// Editable fields inside ordinary links must enter the editor before
|
|
// the navigation guard can turn the click into a leave-page prompt.
|
|
if (e.target.closest('.editable-field, .editable-field-wrapper, [data-editable-image]')) return;
|
|
|
|
const groupedLink = link.closest('[data-editable-group]');
|
|
if (groupedLink && !link.hasAttribute('data-editable-link')) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openGroupEditor(groupedLink);
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const hasDirty = EditorConfig.dirtyPages.size > 0;
|
|
const message = hasDirty
|
|
? t('nav.unsaved_changes')
|
|
: t('nav.edit_mode_active');
|
|
|
|
showEditorConfirm(message, function() {
|
|
window.removeEventListener('beforeunload', beforeUnloadGuard);
|
|
document.removeEventListener('click', navClickGuard, true);
|
|
window.location.href = link.href;
|
|
});
|
|
}
|
|
|
|
function enterEditMode() {
|
|
EditorConfig.editMode = true;
|
|
EditorConfig.originalPageData = JSON.parse(JSON.stringify(EditorConfig.contentData));
|
|
EditorConfig.dirtyPages = new Set();
|
|
EditorConfig.undoStack = [];
|
|
EditorConfig.redoStack = [];
|
|
|
|
document.body.classList.add('edit-mode-active', 'visual-editing');
|
|
updateAdminBarMode(true);
|
|
updateUndoRedoButtons();
|
|
|
|
// Enable news post inline editing
|
|
if (EditorConfig.isNewsPost) {
|
|
enableNewsPostEditing();
|
|
}
|
|
|
|
// Register keyboard shortcuts
|
|
document.addEventListener('keydown', handleEditModeKeyboard);
|
|
|
|
// Guard against accidental navigation with unsaved changes
|
|
window.addEventListener('beforeunload', beforeUnloadGuard);
|
|
document.addEventListener('click', navClickGuard, true);
|
|
startSessionKeepalive();
|
|
}
|
|
|
|
function exitEditMode(save) {
|
|
if (save) {
|
|
// saveAllChanges handles the exit after successful save
|
|
saveAllChanges({ exitAfterSave: true });
|
|
return;
|
|
}
|
|
|
|
// Cancel: discard changes — reload gets clean state from server
|
|
EditorConfig.editMode = false;
|
|
EditorConfig.dirtyPages = new Set();
|
|
EditorConfig.undoStack = [];
|
|
EditorConfig.redoStack = [];
|
|
|
|
document.removeEventListener('keydown', handleEditModeKeyboard);
|
|
window.removeEventListener('beforeunload', beforeUnloadGuard);
|
|
document.removeEventListener('click', navClickGuard, true);
|
|
stopSessionKeepalive();
|
|
cleanupActiveEditorState();
|
|
document.body.classList.remove('edit-mode-active', 'visual-editing');
|
|
updateAdminBarMode(false);
|
|
|
|
// Disable news post editing
|
|
if (EditorConfig.isNewsPost) {
|
|
disableNewsPostEditing();
|
|
}
|
|
|
|
// If there were unsaved changes, reload to restore original content
|
|
if (Object.keys(EditorConfig.originalPageData).length > 0 || (EditorConfig.isNewsPost && EditorConfig.dirtyPages.size > 0)) {
|
|
// Restore contentData to original
|
|
EditorConfig.contentData = JSON.parse(JSON.stringify(EditorConfig.originalPageData));
|
|
// Reload to restore DOM to server state
|
|
location.reload();
|
|
}
|
|
}
|
|
|
|
async function saveAllChanges(options = {}) {
|
|
if (PageBackups.restoring || EditorConfig.saving) return false;
|
|
const exitAfterSave = options.exitAfterSave === true;
|
|
flushActiveInlineEdits();
|
|
|
|
// For news posts, check if the news post data is dirty
|
|
const hasNewsChanges = EditorConfig.isNewsPost && EditorConfig.dirtyPages.has('__news_post__');
|
|
const hasPageChanges = Array.from(EditorConfig.dirtyPages).some(p => p !== '__news_post__');
|
|
|
|
if (EditorConfig.dirtyPages.size === 0) {
|
|
showToast(t('toast.no_changes'), 'info');
|
|
if (exitAfterSave) {
|
|
exitEditModeClean();
|
|
} else {
|
|
markSavedStateClean();
|
|
}
|
|
return;
|
|
}
|
|
|
|
EditorConfig.saving = true;
|
|
PageBackups.update();
|
|
const saveButtons = Array.from(document.querySelectorAll('#admin-btn-save, #admin-btn-save-exit'));
|
|
saveButtons.forEach(saveBtn => {
|
|
saveBtn.disabled = true;
|
|
});
|
|
const activeSaveBtn = exitAfterSave
|
|
? document.getElementById('admin-btn-save-exit')
|
|
: document.getElementById('admin-btn-save');
|
|
if (activeSaveBtn) {
|
|
activeSaveBtn.textContent = exitAfterSave ? t('saving_exit') : t('saving');
|
|
}
|
|
|
|
try {
|
|
let allSuccess = true;
|
|
|
|
// Save news post if dirty
|
|
if (hasNewsChanges && EditorConfig.newsPostData) {
|
|
const postEl = document.querySelector('[data-news-post]');
|
|
if (postEl) {
|
|
const titleEl = postEl.querySelector('.news-post-page__title');
|
|
const contentEl = postEl.querySelector('.news-post-page__content');
|
|
const authorEl = postEl.querySelector('.news-post-page__author');
|
|
if (titleEl) EditorConfig.newsPostData.title = titleEl.textContent.trim();
|
|
if (contentEl) EditorConfig.newsPostData.content = contentEl.innerHTML.trim();
|
|
if (authorEl) {
|
|
// Strip "by " prefix if present
|
|
let authorText = authorEl.textContent.trim();
|
|
authorText = authorText.replace(/^by\s+/i, '');
|
|
EditorConfig.newsPostData.author = authorText;
|
|
}
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('action', 'save-news');
|
|
formData.append('post', JSON.stringify(EditorConfig.newsPostData));
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
showToast(t('toast.error_saving', { page: 'news', message: result.message || 'Unknown' }), 'error');
|
|
allSuccess = false;
|
|
}
|
|
}
|
|
|
|
// Save page content
|
|
if (allSuccess && hasPageChanges) {
|
|
const pages = Array.from(EditorConfig.dirtyPages).filter(p => p !== '__news_post__');
|
|
for (const page of pages) {
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) continue;
|
|
|
|
const formData = new FormData();
|
|
formData.append('action', 'save');
|
|
formData.append('page', page);
|
|
formData.append('content', JSON.stringify(pageData));
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await response.json();
|
|
if (result.success && result.revision) EditorConfig.contentRevisions[page] = result.revision;
|
|
|
|
if (!result.success) {
|
|
showToast(t('toast.error_saving', { page, message: result.message || 'Unknown' }), 'error');
|
|
allSuccess = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allSuccess) {
|
|
void PageBackups.load();
|
|
showToast(t('toast.saved'), 'success');
|
|
if (exitAfterSave) {
|
|
exitEditModeClean();
|
|
// Reload to ensure server-rendered HTML matches saved data
|
|
setTimeout(() => location.reload(), 500);
|
|
} else {
|
|
markSavedStateClean();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Save all error:', error);
|
|
showToast(t('toast.error_saving_short'), 'error');
|
|
} finally {
|
|
EditorConfig.saving = false;
|
|
PageBackups.update();
|
|
saveButtons.forEach(saveBtn => {
|
|
saveBtn.disabled = false;
|
|
});
|
|
const keepEditingSaveBtn = document.getElementById('admin-btn-save');
|
|
if (keepEditingSaveBtn) {
|
|
keepEditingSaveBtn.innerHTML = Icons.save + ' ' + t('save');
|
|
}
|
|
const exitSaveBtn = document.getElementById('admin-btn-save-exit');
|
|
if (exitSaveBtn) {
|
|
exitSaveBtn.innerHTML = Icons.save + ' ' + t('save_exit');
|
|
}
|
|
}
|
|
}
|
|
|
|
function markSavedStateClean() {
|
|
EditorConfig.originalPageData = JSON.parse(JSON.stringify(EditorConfig.contentData));
|
|
EditorConfig.dirtyPages = new Set();
|
|
EditorConfig.undoStack = [];
|
|
EditorConfig.redoStack = [];
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
function flushActiveInlineEdits() {
|
|
if (activeHtmlField && activeHtmlField.isContentEditable) {
|
|
finishInlineHtmlEdit(activeHtmlField, activeHtmlField._originalHtml || '', false);
|
|
}
|
|
|
|
document.querySelectorAll('.editable-field-editing').forEach(field => {
|
|
if (field === activeHtmlField || field.classList.contains('editable-field-html')) return;
|
|
if (!field.isContentEditable) return;
|
|
|
|
finishInlineEdit(field, field._originalText || '');
|
|
});
|
|
}
|
|
|
|
function exitEditModeClean() {
|
|
EditorConfig.editMode = false;
|
|
EditorConfig.dirtyPages = new Set();
|
|
EditorConfig.undoStack = [];
|
|
EditorConfig.redoStack = [];
|
|
EditorConfig.originalPageData = {};
|
|
document.removeEventListener('keydown', handleEditModeKeyboard);
|
|
window.removeEventListener('beforeunload', beforeUnloadGuard);
|
|
document.removeEventListener('click', navClickGuard, true);
|
|
stopSessionKeepalive();
|
|
cleanupActiveEditorState();
|
|
document.body.classList.remove('edit-mode-active', 'visual-editing');
|
|
updateAdminBarMode(false);
|
|
}
|
|
|
|
function cleanupActiveEditorState() {
|
|
if (activeHtmlField && activeHtmlField.isContentEditable) {
|
|
finishInlineHtmlEdit(activeHtmlField, activeHtmlField._originalHtml || '', false);
|
|
}
|
|
activeHtmlField = null;
|
|
document.querySelectorAll('.editable-field-editing').forEach(field => {
|
|
field.contentEditable = 'false';
|
|
field.classList.remove('editable-field-editing');
|
|
});
|
|
document.querySelectorAll('.is-overlay-active').forEach(el => {
|
|
el.classList.remove('is-overlay-active');
|
|
});
|
|
document.querySelectorAll('.rj-copilot-selected-field').forEach(el => {
|
|
el.classList.remove('rj-copilot-selected-field');
|
|
});
|
|
const toolbar = document.getElementById('inline-editor-toolbar');
|
|
if (toolbar) {
|
|
toolbar.hidden = true;
|
|
}
|
|
}
|
|
|
|
// --- Undo / Redo ---
|
|
|
|
function pushUndoState() {
|
|
EditorConfig.undoStack.push({
|
|
type: 'snapshot',
|
|
contentData: JSON.parse(JSON.stringify(EditorConfig.contentData))
|
|
});
|
|
if (EditorConfig.undoStack.length > 50) {
|
|
EditorConfig.undoStack.shift();
|
|
}
|
|
EditorConfig.redoStack = [];
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
function pushReorderUndo(reorderType, params) {
|
|
EditorConfig.undoStack.push({
|
|
type: reorderType,
|
|
...params
|
|
});
|
|
if (EditorConfig.undoStack.length > 50) {
|
|
EditorConfig.undoStack.shift();
|
|
}
|
|
EditorConfig.redoStack = [];
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
function undo() {
|
|
if (!EditorConfig.editMode || EditorConfig.undoStack.length === 0) return;
|
|
|
|
const entry = EditorConfig.undoStack.pop();
|
|
|
|
if (entry.type === 'reorder-list') {
|
|
// Push inverse to redo stack
|
|
EditorConfig.redoStack.push({
|
|
type: 'reorder-list',
|
|
page: entry.page,
|
|
listKey: entry.listKey,
|
|
from: entry.to,
|
|
to: entry.from
|
|
});
|
|
// Execute inverse reorder (no new undo entry)
|
|
executeListReorder(entry.page, entry.listKey, entry.to, entry.from);
|
|
} else if (entry.type === 'reorder-section') {
|
|
EditorConfig.redoStack.push({
|
|
type: 'reorder-section',
|
|
contentPage: entry.contentPage,
|
|
from: entry.to,
|
|
to: entry.from
|
|
});
|
|
executeSectionReorder(entry.to, entry.from, entry.contentPage);
|
|
} else {
|
|
// Snapshot-based undo
|
|
EditorConfig.redoStack.push({
|
|
type: 'snapshot',
|
|
contentData: JSON.parse(JSON.stringify(EditorConfig.contentData))
|
|
});
|
|
EditorConfig.contentData = entry.contentData;
|
|
refreshDomFromContentData();
|
|
}
|
|
|
|
recalcDirtyPages();
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
function redo() {
|
|
if (!EditorConfig.editMode || EditorConfig.redoStack.length === 0) return;
|
|
|
|
const entry = EditorConfig.redoStack.pop();
|
|
|
|
if (entry.type === 'reorder-list') {
|
|
EditorConfig.undoStack.push({
|
|
type: 'reorder-list',
|
|
page: entry.page,
|
|
listKey: entry.listKey,
|
|
from: entry.to,
|
|
to: entry.from
|
|
});
|
|
executeListReorder(entry.page, entry.listKey, entry.from, entry.to);
|
|
} else if (entry.type === 'reorder-section') {
|
|
EditorConfig.undoStack.push({
|
|
type: 'reorder-section',
|
|
contentPage: entry.contentPage,
|
|
from: entry.to,
|
|
to: entry.from
|
|
});
|
|
executeSectionReorder(entry.from, entry.to, entry.contentPage);
|
|
} else {
|
|
EditorConfig.undoStack.push({
|
|
type: 'snapshot',
|
|
contentData: JSON.parse(JSON.stringify(EditorConfig.contentData))
|
|
});
|
|
EditorConfig.contentData = entry.contentData;
|
|
refreshDomFromContentData();
|
|
}
|
|
|
|
recalcDirtyPages();
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
function recalcDirtyPages() {
|
|
EditorConfig.dirtyPages = new Set();
|
|
for (const page of EditorConfig.loadedPages) {
|
|
const current = JSON.stringify(EditorConfig.contentData[page]);
|
|
const original = JSON.stringify(EditorConfig.originalPageData[page]);
|
|
if (current !== original) {
|
|
EditorConfig.dirtyPages.add(page);
|
|
}
|
|
}
|
|
}
|
|
|
|
function refreshDomFromContentData() {
|
|
// Update all editable-field elements from contentData
|
|
document.querySelectorAll('.editable-field').forEach(field => {
|
|
const page = field.dataset.page;
|
|
const fieldKey = field.dataset.field;
|
|
if (!page || !fieldKey) return;
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
const value = getNestedValue(pageData, fieldKey);
|
|
if (value === null || value === undefined) return;
|
|
|
|
if (field.classList.contains('editable-field-html')) {
|
|
field.innerHTML = value;
|
|
} else {
|
|
field.textContent = value;
|
|
}
|
|
});
|
|
|
|
// Update editable links
|
|
document.querySelectorAll('[data-editable-link]').forEach(link => {
|
|
const page = link.dataset.page;
|
|
const fieldKey = link.dataset.field;
|
|
if (!page || !fieldKey) return;
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
const value = getNestedValue(pageData, fieldKey);
|
|
if (!value || typeof value !== 'object') return;
|
|
|
|
if (value.text !== undefined) link.textContent = value.text;
|
|
if (value.href !== undefined) link.setAttribute('href', value.href);
|
|
if (value.target === '_blank') {
|
|
link.setAttribute('target', '_blank');
|
|
link.setAttribute('rel', 'noopener');
|
|
} else {
|
|
link.removeAttribute('target');
|
|
link.removeAttribute('rel');
|
|
}
|
|
});
|
|
|
|
// Update editable images
|
|
document.querySelectorAll('[data-editable-image]').forEach(img => {
|
|
const page = img.dataset.page;
|
|
const fieldKey = img.dataset.field;
|
|
if (!page || !fieldKey) return;
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
const value = getNestedValue(pageData, fieldKey);
|
|
if (!value || typeof value !== 'object') return;
|
|
|
|
if (value.src) img.setAttribute('src', value.src);
|
|
if (value.alt !== undefined) img.setAttribute('alt', value.alt);
|
|
syncAiLabel(img);
|
|
});
|
|
|
|
// Update non-editable elements bound to content values via contentBindAttrs(),
|
|
// e.g. image previews or repeated labels that show the same field elsewhere
|
|
document.querySelectorAll('[data-rj-bind]').forEach(el => {
|
|
const pageData = EditorConfig.contentData[el.dataset.rjBindPage];
|
|
if (!pageData || !el.dataset.rjBindField) return;
|
|
const value = getNestedValue(pageData, el.dataset.rjBindField);
|
|
if (value === null || value === undefined) return;
|
|
|
|
if (el.dataset.rjBind === 'image') {
|
|
const src = typeof value === 'object' ? value.src : value;
|
|
if (src) el.setAttribute('src', src);
|
|
syncAiLabel(el);
|
|
} else if (typeof value !== 'object') {
|
|
if (el.dataset.rjBind === 'html') el.innerHTML = value;
|
|
else el.textContent = value;
|
|
}
|
|
});
|
|
|
|
// Update custom editable icon values
|
|
document.querySelectorAll('[data-editable-icon]').forEach(iconEl => {
|
|
const page = iconEl.dataset.page;
|
|
const fieldKey = iconEl.dataset.field;
|
|
if (!page || !fieldKey) return;
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
const value = getNestedValue(pageData, fieldKey);
|
|
if (value === null || value === undefined) return;
|
|
|
|
iconEl.textContent = value;
|
|
iconEl.dataset.icon = value;
|
|
});
|
|
|
|
// Update visible SVGs for grouped custom components that use icon fields.
|
|
document.querySelectorAll('[data-editable-group]').forEach(group => {
|
|
const page = group.dataset.page;
|
|
const groupKey = group.dataset.group || '';
|
|
if (!page || !groupKey) return;
|
|
|
|
const schema = parseGroupSchema(group);
|
|
const iconField = Array.isArray(schema.fields)
|
|
? schema.fields.find(field => (field.type || '') === 'icon')
|
|
: null;
|
|
if (!iconField) return;
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
const iconId = getNestedValue(pageData, groupFieldPath(groupKey, iconField));
|
|
const svg = group.querySelector('.feature-icon svg, .vibecoding-icon svg, svg');
|
|
if (svg && iconId !== null && iconId !== undefined) {
|
|
svg.innerHTML = getIconMarkup(iconId);
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
function getNestedValue(obj, dotKey) {
|
|
const keys = dotKey.split('.');
|
|
let current = obj;
|
|
for (const key of keys) {
|
|
if (!current || typeof current !== 'object') return undefined;
|
|
current = current[key];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
// --- Keyboard shortcuts ---
|
|
|
|
function handleEditModeKeyboard(e) {
|
|
if (e.defaultPrevented) return;
|
|
if (!EditorConfig.editMode) return;
|
|
|
|
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
|
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
|
|
|
|
if (ctrlKey && (e.key === 's' || e.key === 'S')) {
|
|
e.preventDefault();
|
|
saveAllChanges();
|
|
return;
|
|
}
|
|
|
|
// Don't intercept undo/redo while typing in input/textarea/contenteditable.
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
if (e.target.isContentEditable) return;
|
|
|
|
if (ctrlKey && !e.shiftKey && e.key === 'z') {
|
|
e.preventDefault();
|
|
undo();
|
|
} else if (ctrlKey && e.shiftKey && (e.key === 'z' || e.key === 'Z')) {
|
|
e.preventDefault();
|
|
redo();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// EDITOR UI - HAUPTMODAL
|
|
// ============================================================
|
|
|
|
/**
|
|
* Builds the DOM IDs for an editor field.
|
|
* Uses key+type so that multiple block types with the same field key
|
|
* (e.g. 'src' exists for both image and audio blocks) don't collide.
|
|
*/
|
|
function editorFieldIds(field) {
|
|
return {
|
|
fid: `editor-field-${field.key}-${field.type}`,
|
|
iid: `editor-input-${field.key}-${field.type}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Renders an editor field HTML snippet based on field definition.
|
|
*/
|
|
function renderEditorField(field) {
|
|
const { fid, iid } = editorFieldIds(field);
|
|
|
|
switch (field.type) {
|
|
case 'input': {
|
|
const hint = field.hint ? `<small>${field.hint}</small>` : '';
|
|
const preview = field.preview ? `<div id="editor-${field.preview}-preview" class="editor-media-preview"></div>` : '';
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<input type="text" id="${iid}" placeholder="${field.label}...">
|
|
${hint}${preview}
|
|
</div>`;
|
|
}
|
|
|
|
case 'textarea':
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<textarea id="${iid}" rows="3" placeholder="${field.label}..."></textarea>
|
|
</div>`;
|
|
|
|
case 'wysiwyg':
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<div class="editor-toolbar">
|
|
<button type="button" onclick="InlineEditor.execCommand('bold')" title="${t('bold')}"><b>B</b></button>
|
|
<button type="button" onclick="InlineEditor.execCommand('italic')" title="${t('italic')}"><i>I</i></button>
|
|
<button type="button" onclick="InlineEditor.insertLink()" title="${t('insert_link')}">🔗</button>
|
|
<button type="button" onclick="InlineEditor.cleanHtml()" title="${t('clean_formatting')}" class="editor-toolbar-eraser">${Icons.eraser}</button>
|
|
<span class="editor-toolbar-separator"></span>
|
|
<label class="editor-toggle">
|
|
<input type="checkbox" id="editor-html-toggle" onchange="InlineEditor.toggleHtmlMode()">
|
|
<span>HTML</span>
|
|
</label>
|
|
</div>
|
|
<div id="editor-wysiwyg" class="editor-wysiwyg" contenteditable="true"></div>
|
|
<textarea id="editor-html" class="editor-html" style="display:none;"></textarea>
|
|
</div>`;
|
|
|
|
case 'select':
|
|
const opts = (field.options || []).map(o =>
|
|
`<option value="${o.value}">${o.label}</option>`
|
|
).join('');
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<select id="${iid}">${opts}</select>
|
|
</div>`;
|
|
|
|
case 'checkbox':
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label class="editor-checkbox-label">
|
|
<input type="checkbox" id="${iid}">
|
|
<span>${field.label}</span>
|
|
</label>
|
|
</div>`;
|
|
|
|
case 'number':
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<input type="number" id="${iid}" placeholder="${field.label}...">
|
|
</div>`;
|
|
|
|
case 'url':
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<input type="url" id="${iid}" placeholder="https://...">
|
|
</div>`;
|
|
|
|
case 'image':
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<div class="editor-image-row">
|
|
<input type="text" id="${iid}" placeholder="Path to image..." readonly>
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.openImageManager(null, '${iid}')">
|
|
${Icons.folder} ${t('image_manager')}
|
|
</button>
|
|
</div>
|
|
<div id="editor-image-preview-${field.key}" class="editor-image-preview"></div>
|
|
</div>`;
|
|
|
|
case 'audio':
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<input type="text" id="${iid}" placeholder="Path to audio file..." readonly>
|
|
<div class="editor-audio-buttons">
|
|
<label class="editor-btn editor-btn-primary editor-btn-inline">
|
|
<input type="file" id="editor-audio-upload-input" accept=".mp3,.wav,.ogg,.m4a,.aac,.flac,audio/*" style="display:none;">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>
|
|
${t('image.upload')}
|
|
</label>
|
|
<button type="button" class="editor-btn editor-btn-secondary editor-btn-inline" onclick="InlineEditor.openAudioManager('${iid}')">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
|
${t('audio.manager')}
|
|
</button>
|
|
</div>
|
|
<div id="editor-audio-preview" class="editor-media-preview"></div>
|
|
</div>`;
|
|
|
|
default:
|
|
return `<div class="editor-field" id="${fid}" style="display:none;">
|
|
<label>${field.label}</label>
|
|
<input type="text" id="${iid}" placeholder="${field.label}...">
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function normalizeGroupField(field) {
|
|
const normalized = { ...field };
|
|
normalized.type = normalized.type || 'text';
|
|
if (!field.type && normalized.key) {
|
|
const key = String(normalized.key).toLowerCase();
|
|
if (/^(image|imagesrc|src|photo|portrait|logo|avatar|cover|thumbnail)$/.test(key)) {
|
|
normalized.type = 'image';
|
|
} else if (/^(link|href|url|cta|button|buttonlink)$/.test(key)) {
|
|
normalized.type = 'link';
|
|
} else if (/^(text|description|desc|intro|body|content|copy|summary)$/.test(key)) {
|
|
normalized.type = 'textarea';
|
|
} else if (/^(icon|iconid|symbol)$/.test(key)) {
|
|
normalized.type = 'icon';
|
|
}
|
|
}
|
|
if (normalized.type === 'text') normalized.type = 'input';
|
|
if (normalized.type === 'html' || normalized.type === 'richtext') normalized.type = 'wysiwyg';
|
|
return normalized;
|
|
}
|
|
|
|
function editorLanguageEntries() {
|
|
const languages = EditorConfig.languages && typeof EditorConfig.languages === 'object'
|
|
? EditorConfig.languages
|
|
: {};
|
|
return Object.entries(languages);
|
|
}
|
|
|
|
function editorLanguageCodes() {
|
|
return editorLanguageEntries().map(([code]) => code);
|
|
}
|
|
|
|
function editorIsMultilingual() {
|
|
return editorLanguageCodes().length > 1;
|
|
}
|
|
|
|
function languageLabel(code) {
|
|
const languages = EditorConfig.languages || {};
|
|
const label = languages[code] || code;
|
|
return String(label || code).toUpperCase();
|
|
}
|
|
|
|
function splitLocalizedPath(field) {
|
|
const raw = String(field.path || field.key || '');
|
|
const parts = raw.split('.');
|
|
if (parts.length < 2) return null;
|
|
const lang = parts[parts.length - 1];
|
|
if (!editorLanguageCodes().includes(lang)) return null;
|
|
return {
|
|
base: parts.slice(0, -1).join('.'),
|
|
lang,
|
|
};
|
|
}
|
|
|
|
function isLocalizedGroupField(field, page, groupKey) {
|
|
if (field.localized === true || field.multilingual === true || field.translatable === true) {
|
|
return true;
|
|
}
|
|
const path = groupFieldPath(groupKey, normalizeGroupField(field));
|
|
const value = getNestedValue(EditorConfig.contentData[page] || {}, path);
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
const codes = editorLanguageCodes();
|
|
return codes.length > 0 && codes.some(code => Object.prototype.hasOwnProperty.call(value, code));
|
|
}
|
|
|
|
function stripLanguageFromLabel(label, lang) {
|
|
const text = String(label || '');
|
|
return text
|
|
.replace(new RegExp('\\s*\\(?\\b' + lang + '\\b\\)?\\s*$', 'i'), '')
|
|
.replace(/\s*[-–—:]\s*$/, '')
|
|
.trim() || text;
|
|
}
|
|
|
|
function localizedFieldBaseLabel(field, base, lang) {
|
|
if (field.label) return stripLanguageFromLabel(field.label, lang || '');
|
|
const last = String(base || field.key || field.path || '').split('.').pop();
|
|
return ucwords(last.replace(/[_-]/g, ' '));
|
|
}
|
|
|
|
function ucwords(value) {
|
|
return String(value || '').replace(/\b\w/g, c => c.toUpperCase());
|
|
}
|
|
|
|
function fieldWithPath(field, path, label) {
|
|
const clone = { ...field, path, key: path, label };
|
|
delete clone.localized;
|
|
delete clone.multilingual;
|
|
delete clone.translatable;
|
|
return clone;
|
|
}
|
|
|
|
function buildLocalizedGroupLayout(fields, page, groupKey) {
|
|
const codes = editorLanguageCodes();
|
|
if (!editorIsMultilingual()) {
|
|
return {
|
|
flatFields: fields,
|
|
html: fields.map((field, index) => renderGroupEditorField(field, index)).join('\n')
|
|
};
|
|
}
|
|
|
|
const normalFields = [];
|
|
const localizedByBase = new Map();
|
|
|
|
fields.forEach((field) => {
|
|
const normalized = normalizeGroupField(field);
|
|
const split = splitLocalizedPath(normalized);
|
|
|
|
if (split) {
|
|
if (!localizedByBase.has(split.base)) {
|
|
localizedByBase.set(split.base, { label: localizedFieldBaseLabel(normalized, split.base, split.lang), byLang: {} });
|
|
}
|
|
localizedByBase.get(split.base).byLang[split.lang] = normalized;
|
|
return;
|
|
}
|
|
|
|
if (isLocalizedGroupField(normalized, page, groupKey)) {
|
|
const base = normalized.path || normalized.key || '';
|
|
if (!localizedByBase.has(base)) {
|
|
localizedByBase.set(base, { label: localizedFieldBaseLabel(normalized, base), byLang: {} });
|
|
}
|
|
codes.forEach(code => {
|
|
localizedByBase.get(base).byLang[code] = fieldWithPath(normalized, base + '.' + code, localizedFieldBaseLabel(normalized, base));
|
|
});
|
|
return;
|
|
}
|
|
|
|
normalFields.push(normalized);
|
|
});
|
|
|
|
if (localizedByBase.size === 0) {
|
|
return {
|
|
flatFields: fields,
|
|
html: fields.map((field, index) => renderGroupEditorField(field, index)).join('\n')
|
|
};
|
|
}
|
|
|
|
const flatFields = [];
|
|
const normalHtml = normalFields.map((field) => {
|
|
const index = flatFields.length;
|
|
flatFields.push(field);
|
|
return renderGroupEditorField(field, index);
|
|
}).join('\n');
|
|
|
|
const defaultLang = codes.includes(EditorConfig.defaultLang) ? EditorConfig.defaultLang : codes[0];
|
|
const tabId = 'group-lang-tabs-' + Date.now();
|
|
const canTranslate = EditorConfig.aiFeaturesEnabled === true;
|
|
const tabsHtml = codes.map(code => {
|
|
const active = code === defaultLang;
|
|
return `<button type="button" class="editor-lang-tab${active ? ' active' : ''}" id="${tabId}-tab-${escHtml(code)}" role="tab" aria-selected="${active ? 'true' : 'false'}" aria-controls="${tabId}-panel-${escHtml(code)}" tabindex="${active ? '0' : '-1'}" data-editor-lang-tab="${escHtml(code)}">${escHtml(languageLabel(code))}</button>`;
|
|
}).join('');
|
|
|
|
let panelsHtml = '';
|
|
codes.forEach(code => {
|
|
const active = code === defaultLang;
|
|
let panelFields = '';
|
|
localizedByBase.forEach((group, base) => {
|
|
const field = group.byLang[code] || fieldWithPath(group.byLang[defaultLang] || {}, base + '.' + code, group.label);
|
|
const index = flatFields.length;
|
|
flatFields.push(field);
|
|
const rendered = renderGroupEditorField(field, index);
|
|
panelFields += rendered.replace(
|
|
/^<div class="([^"]*)"/,
|
|
`<div class="$1" data-editor-lang="${escHtml(code)}" data-editor-lang-field="${escHtml(base)}"`
|
|
);
|
|
});
|
|
panelsHtml += `<div class="editor-lang-panel${active ? ' active' : ''}" id="${tabId}-panel-${escHtml(code)}" role="tabpanel" aria-labelledby="${tabId}-tab-${escHtml(code)}" data-editor-lang-panel="${escHtml(code)}"${active ? '' : ' hidden'}>${panelFields}</div>`;
|
|
});
|
|
|
|
const translateButton = canTranslate
|
|
? `<button type="button" class="editor-lang-translate-btn" data-editor-lang-translate>${escHtml(tFallback('editor.translate_from_active', 'Translate from active language'))}</button>`
|
|
: '';
|
|
|
|
const langHtml = `<section class="editor-lang-section" data-editor-lang-section>
|
|
<div class="editor-lang-header">
|
|
<div class="editor-lang-tabs" role="tablist" aria-label="${escHtml(tFallback('editor.language_tabs', 'Languages'))}">${tabsHtml}</div>
|
|
${translateButton}
|
|
</div>
|
|
${panelsHtml}
|
|
</section>`;
|
|
|
|
return { flatFields, html: normalHtml + langHtml };
|
|
}
|
|
|
|
function setupEditorLanguageTabs(root) {
|
|
root.querySelectorAll('[data-editor-lang-section]').forEach(section => {
|
|
const tabs = Array.from(section.querySelectorAll('[data-editor-lang-tab]'));
|
|
const panels = Array.from(section.querySelectorAll('[data-editor-lang-panel]'));
|
|
const activate = (lang, focus = false) => {
|
|
tabs.forEach(tab => {
|
|
const active = tab.dataset.editorLangTab === lang;
|
|
tab.classList.toggle('active', active);
|
|
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
tab.tabIndex = active ? 0 : -1;
|
|
if (active && focus) tab.focus();
|
|
});
|
|
panels.forEach(panel => {
|
|
const active = panel.dataset.editorLangPanel === lang;
|
|
panel.hidden = !active;
|
|
panel.classList.toggle('active', active);
|
|
});
|
|
};
|
|
|
|
tabs.forEach((tab, index) => {
|
|
tab.addEventListener('click', () => activate(tab.dataset.editorLangTab));
|
|
tab.addEventListener('keydown', e => {
|
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
|
|
e.preventDefault();
|
|
let next = index;
|
|
if (e.key === 'ArrowLeft') next = index === 0 ? tabs.length - 1 : index - 1;
|
|
if (e.key === 'ArrowRight') next = index === tabs.length - 1 ? 0 : index + 1;
|
|
if (e.key === 'Home') next = 0;
|
|
if (e.key === 'End') next = tabs.length - 1;
|
|
activate(tabs[next].dataset.editorLangTab, true);
|
|
});
|
|
});
|
|
|
|
const translateBtn = section.querySelector('[data-editor-lang-translate]');
|
|
if (translateBtn) {
|
|
translateBtn.addEventListener('click', () => translateEditorLanguageSection(section, translateBtn));
|
|
}
|
|
});
|
|
}
|
|
|
|
function getActiveEditorLanguage(section) {
|
|
return section.querySelector('[data-editor-lang-tab].active')?.dataset.editorLangTab
|
|
|| EditorConfig.defaultLang
|
|
|| editorLanguageCodes()[0];
|
|
}
|
|
|
|
function readSimpleEditorInput(fieldEl) {
|
|
const input = fieldEl.querySelector('textarea, input:not([type="hidden"]), select');
|
|
if (!input || input.type === 'checkbox') return null;
|
|
return input.value || '';
|
|
}
|
|
|
|
function writeSimpleEditorInput(fieldEl, value) {
|
|
const input = fieldEl.querySelector('textarea, input:not([type="hidden"]), select');
|
|
if (!input || input.type === 'checkbox') return;
|
|
input.value = value == null ? '' : String(value);
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
async function translateEditorLanguageSection(section, button) {
|
|
if (!EditorConfig.aiFeaturesEnabled) return;
|
|
const sourceLang = getActiveEditorLanguage(section);
|
|
const targetLangs = editorLanguageCodes().filter(code => code !== sourceLang);
|
|
if (!targetLangs.length) return;
|
|
|
|
const sourceFields = {};
|
|
section.querySelectorAll(`[data-editor-lang="${CSS.escape(sourceLang)}"][data-editor-lang-field]`).forEach(fieldEl => {
|
|
const value = readSimpleEditorInput(fieldEl);
|
|
if (value !== null && String(value).trim() !== '') {
|
|
sourceFields[fieldEl.dataset.editorLangField] = value;
|
|
}
|
|
});
|
|
if (Object.keys(sourceFields).length === 0) {
|
|
showToast(tFallback('editor.translate_no_source', 'No source text to translate.'), 'error');
|
|
return;
|
|
}
|
|
|
|
const previousText = button.textContent;
|
|
button.disabled = true;
|
|
button.textContent = tFallback('editor.translating', 'Translating...');
|
|
try {
|
|
const prompt = [
|
|
'Translate these raiju editor fields from ' + sourceLang + ' into the requested target languages.',
|
|
'Return strict JSON only. Shape: {"translations":{"LANG":{"field.path":"translated value"}}}.',
|
|
'Keep HTML tags if present. Do not add Markdown or explanations.',
|
|
'',
|
|
JSON.stringify({ sourceLang, targetLangs, fields: sourceFields }, null, 2)
|
|
].join('\n');
|
|
const formData = new FormData();
|
|
formData.append('action', 'ai-generate-text');
|
|
formData.append('prompt', prompt);
|
|
formData.append('maxOutputTokens', '1800');
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
const response = await fetch(EditorConfig.apiUrl, { method: 'POST', body: formData });
|
|
const result = await response.json();
|
|
if (!result.success) throw new Error(result.message || tFallback('toast.error', 'Error'));
|
|
let payload = {};
|
|
try {
|
|
payload = JSON.parse(String(result.data?.text || '').replace(/^```json\s*|\s*```$/g, '').trim());
|
|
} catch (e) {
|
|
throw new Error(tFallback('editor.translate_invalid_response', 'AI returned an unreadable translation.'));
|
|
}
|
|
const translations = payload.translations || payload;
|
|
targetLangs.forEach(lang => {
|
|
const langTranslations = translations[lang] || {};
|
|
Object.entries(langTranslations).forEach(([base, value]) => {
|
|
const fieldEl = section.querySelector(`[data-editor-lang="${CSS.escape(lang)}"][data-editor-lang-field="${CSS.escape(base)}"]`);
|
|
if (fieldEl) writeSimpleEditorInput(fieldEl, value);
|
|
});
|
|
});
|
|
showToast(tFallback('editor.translate_done', 'Translations inserted.'), 'success');
|
|
} catch (error) {
|
|
showToast(error.message, 'error');
|
|
} finally {
|
|
button.disabled = false;
|
|
button.textContent = previousText;
|
|
}
|
|
}
|
|
|
|
function getAvailableIconsForField(field = {}) {
|
|
const allIcons = window.RJ_AVAILABLE_ICONS && typeof window.RJ_AVAILABLE_ICONS === 'object'
|
|
? window.RJ_AVAILABLE_ICONS
|
|
: {};
|
|
const allowed = Array.isArray(field.icons) ? field.icons : null;
|
|
if (!allowed || allowed.length === 0) return allIcons;
|
|
|
|
return allowed.reduce((icons, key) => {
|
|
if (Object.prototype.hasOwnProperty.call(allIcons, key)) {
|
|
icons[key] = allIcons[key];
|
|
}
|
|
return icons;
|
|
}, {});
|
|
}
|
|
|
|
function getIconMarkup(iconId) {
|
|
const icons = window.RJ_AVAILABLE_ICONS && typeof window.RJ_AVAILABLE_ICONS === 'object'
|
|
? window.RJ_AVAILABLE_ICONS
|
|
: {};
|
|
return icons[iconId] || icons.default || '';
|
|
}
|
|
|
|
function groupFieldPath(groupKey, field) {
|
|
if (field.path) return field.path;
|
|
const key = field.key || '';
|
|
if (!groupKey) return key;
|
|
if (!key) return groupKey;
|
|
if (key === groupKey || key.startsWith(groupKey + '.')) return key;
|
|
return groupKey + '.' + key;
|
|
}
|
|
|
|
function groupFieldIds(index, suffix = '') {
|
|
const id = `group-editor-input-${index}${suffix ? '-' + suffix : ''}`;
|
|
return { id, fieldId: `group-editor-field-${index}` };
|
|
}
|
|
|
|
function renderGroupEditorField(field, index) {
|
|
const normalized = normalizeGroupField(field);
|
|
const { id, fieldId } = groupFieldIds(index);
|
|
const label = escHtml(normalized.label || normalized.key || normalized.path || '');
|
|
const hint = normalized.hint ? `<small>${escHtml(normalized.hint)}</small>` : '';
|
|
|
|
switch (normalized.type) {
|
|
case 'icon': {
|
|
const icons = getAvailableIconsForField(normalized);
|
|
const iconButtons = Object.entries(icons).map(([key, markup]) => `
|
|
<button type="button" class="group-icon-option" data-group-icon-value="${escHtml(key)}" title="${escHtml(key)}" aria-label="${escHtml(key)}">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${markup}</svg>
|
|
<span>${escHtml(key)}</span>
|
|
</button>
|
|
`).join('');
|
|
|
|
return `<div class="editor-field group-editor-field" id="${fieldId}" data-group-field-index="${index}">
|
|
<label>${label}</label>
|
|
<input type="hidden" id="${id}">
|
|
<div class="group-icon-picker" data-group-icon-picker="${index}">
|
|
<div class="group-icon-selected">
|
|
<span class="group-icon-selected-preview" id="group-editor-icon-preview-${index}"></span>
|
|
<span class="group-icon-selected-label" id="group-editor-icon-label-${index}"></span>
|
|
</div>
|
|
<input type="search" class="group-icon-search" data-group-icon-search="${index}" placeholder="Search icons...">
|
|
<div class="group-icon-grid">${iconButtons}</div>
|
|
</div>
|
|
${hint}
|
|
</div>`;
|
|
}
|
|
|
|
case 'textarea':
|
|
return `<div class="editor-field group-editor-field" id="${fieldId}" data-group-field-index="${index}">
|
|
<label>${label}</label>
|
|
<textarea id="${id}" rows="3" placeholder="${label}..."></textarea>
|
|
${hint}
|
|
</div>`;
|
|
|
|
case 'wysiwyg':
|
|
return `<div class="editor-field group-editor-field" id="${fieldId}" data-group-field-index="${index}">
|
|
<label>${label}</label>
|
|
<textarea id="${id}" class="editor-html group-editor-html" rows="6"></textarea>
|
|
${hint}
|
|
</div>`;
|
|
|
|
case 'select': {
|
|
const opts = (normalized.options || []).map(o =>
|
|
`<option value="${escHtml(o.value || '')}">${escHtml(o.label || o.value || '')}</option>`
|
|
).join('');
|
|
return `<div class="editor-field group-editor-field" id="${fieldId}" data-group-field-index="${index}">
|
|
<label>${label}</label>
|
|
<select id="${id}">${opts}</select>
|
|
${hint}
|
|
</div>`;
|
|
}
|
|
|
|
case 'checkbox':
|
|
return `<div class="editor-field group-editor-field" id="${fieldId}" data-group-field-index="${index}">
|
|
<label class="editor-checkbox-label">
|
|
<input type="checkbox" id="${id}">
|
|
<span>${label}</span>
|
|
</label>
|
|
${hint}
|
|
</div>`;
|
|
|
|
case 'number':
|
|
return `<div class="editor-field group-editor-field" id="${fieldId}" data-group-field-index="${index}">
|
|
<label>${label}</label>
|
|
<input type="number" id="${id}" placeholder="${label}...">
|
|
${hint}
|
|
</div>`;
|
|
|
|
case 'url':
|
|
return `<div class="editor-field group-editor-field" id="${fieldId}" data-group-field-index="${index}">
|
|
<label>${label}</label>
|
|
<input type="url" id="${id}" placeholder="https://...">
|
|
${hint}
|
|
</div>`;
|
|
|
|
case 'link': {
|
|
const textId = groupFieldIds(index, 'text').id;
|
|
const hrefId = groupFieldIds(index, 'href').id;
|
|
const targetId = groupFieldIds(index, 'target').id;
|
|
return `<div class="editor-field group-editor-field group-editor-special group-editor-link" id="${fieldId}" data-group-field-index="${index}">
|
|
<div class="group-special-header">
|
|
<span class="group-special-icon">${Icons.link}</span>
|
|
<label>${label}</label>
|
|
</div>
|
|
<div class="group-link-grid">
|
|
<label class="group-special-control">
|
|
<span>Text</span>
|
|
<input type="text" id="${textId}" placeholder="Link text">
|
|
</label>
|
|
<label class="group-special-control group-special-control--target">
|
|
<span>Target</span>
|
|
${buildPagePickerHtml(groupFieldIds(index, 'page').id, hrefId)}
|
|
</label>
|
|
</div>
|
|
<label class="link-editor-option group-link-option">
|
|
<input type="checkbox" id="${targetId}">
|
|
<span>${t('link_open_new_tab') || 'Open in new tab'}</span>
|
|
</label>
|
|
${hint}
|
|
</div>`;
|
|
}
|
|
|
|
case 'image': {
|
|
const srcId = groupFieldIds(index, 'src').id;
|
|
const altId = groupFieldIds(index, 'alt').id;
|
|
return `<div class="editor-field group-editor-field group-editor-special group-editor-image" id="${fieldId}" data-group-field-index="${index}">
|
|
<div class="group-special-header">
|
|
<span class="group-special-icon">${Icons.image}</span>
|
|
<label>${label}</label>
|
|
</div>
|
|
<div class="group-image-grid">
|
|
<div id="group-editor-image-preview-${index}" class="editor-image-preview group-image-preview"></div>
|
|
<div class="group-image-controls">
|
|
<div class="editor-image-row group-image-path-row">
|
|
<input type="text" id="${srcId}" placeholder="Path to image..." readonly>
|
|
<button type="button" class="editor-btn editor-btn-secondary" data-group-image-pick="${index}">
|
|
${Icons.folder} ${t('image_manager')}
|
|
</button>
|
|
</div>
|
|
<input type="text" id="${altId}" placeholder="Alt text" class="group-image-alt">
|
|
</div>
|
|
</div>
|
|
${hint}
|
|
</div>`;
|
|
}
|
|
|
|
default:
|
|
return `<div class="editor-field group-editor-field" id="${fieldId}" data-group-field-index="${index}">
|
|
<label>${label}</label>
|
|
<input type="text" id="${id}" placeholder="${label}...">
|
|
${hint}
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function createEditorUI() {
|
|
// Build all field HTML from all block types
|
|
const allFieldsHtml = {};
|
|
const renderedKeys = new Set();
|
|
|
|
for (const [type, def] of Object.entries(BlockTypes)) {
|
|
for (const field of (def.fields || [])) {
|
|
const dedupKey = `${field.key}-${field.type}`;
|
|
if (!renderedKeys.has(dedupKey)) {
|
|
renderedKeys.add(dedupKey);
|
|
allFieldsHtml[dedupKey] = renderEditorField(field);
|
|
}
|
|
}
|
|
}
|
|
|
|
const fieldsHtml = Object.values(allFieldsHtml).join('\n');
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'editor-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop" aria-hidden="true"></div>
|
|
<div class="editor-modal-content">
|
|
<div class="editor-modal-header">
|
|
<h3 id="editor-modal-title">${t('edit')}</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closeModal()" aria-label="${escHtml(tFallback('close', 'Close'))}">×</button>
|
|
</div>
|
|
<div class="editor-modal-body">
|
|
${fieldsHtml}
|
|
</div>
|
|
<div class="editor-modal-footer">
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.closeModal()">${t('cancel')}</button>
|
|
<button type="button" class="editor-btn editor-btn-primary" onclick="InlineEditor.saveSection()">${t('save')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
setupDialogA11y(modal, 'editor-modal-title', '.editor-modal-body input, .editor-modal-body textarea, .editor-modal-body select, .editor-close-btn');
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', closeModal);
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modal.classList.contains('active')) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// Attach event listeners for special field types
|
|
const youtubeInput = document.getElementById('editor-input-videoId-input');
|
|
if (youtubeInput) youtubeInput.addEventListener('input', updateYoutubePreview);
|
|
|
|
const scInput = document.getElementById('editor-input-trackId-input');
|
|
if (scInput) scInput.addEventListener('input', updateSoundcloudPreview);
|
|
|
|
const audioUpload = document.getElementById('editor-audio-upload-input');
|
|
if (audioUpload) audioUpload.addEventListener('change', handleEditorAudioUpload);
|
|
|
|
// Resizable Modal
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
}
|
|
|
|
function createGroupEditorUI() {
|
|
if (document.getElementById('editable-group-modal')) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'editable-group-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop" aria-hidden="true"></div>
|
|
<div class="editor-modal-content">
|
|
<div class="editor-modal-header">
|
|
<h3 id="editable-group-modal-title">${t('edit')}</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closeGroupEditor()" aria-label="${escHtml(tFallback('close', 'Close'))}">×</button>
|
|
</div>
|
|
<div class="editor-modal-body" id="editable-group-modal-body"></div>
|
|
<div class="editor-modal-footer">
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.closeGroupEditor()">${t('cancel')}</button>
|
|
<button type="button" class="editor-btn editor-btn-primary" onclick="InlineEditor.saveGroupEditor()">${t('save')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
setupDialogA11y(modal, 'editable-group-modal-title', '#editable-group-modal-body input, #editable-group-modal-body textarea, #editable-group-modal-body select, .editor-close-btn');
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', closeGroupEditor);
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
}
|
|
|
|
function attachGroupEditHandlers() {
|
|
const groups = document.querySelectorAll('[data-editable-group]');
|
|
if (groups.length === 0) return;
|
|
|
|
groups.forEach(group => {
|
|
if (group.dataset.groupEditorReady === 'true') return;
|
|
group.dataset.groupEditorReady = 'true';
|
|
group.classList.add('editable-group-active');
|
|
|
|
if (!group.hasAttribute('data-list-index')) {
|
|
const overlay = document.createElement('button');
|
|
overlay.type = 'button';
|
|
overlay.className = 'editable-group-overlay raiju-editor-group-overlay';
|
|
overlay.innerHTML = `${Icons.edit}<span>${t('edit')}</span>`;
|
|
overlay.title = t('edit');
|
|
overlay.addEventListener('click', (e) => {
|
|
if (!EditorConfig.editMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openGroupEditor(group);
|
|
});
|
|
group.appendChild(overlay);
|
|
}
|
|
|
|
group.addEventListener('click', (e) => {
|
|
if (!EditorConfig.editMode) return;
|
|
if (group.hasAttribute('data-list-index')) return;
|
|
if (e.target.closest('.editable-group-overlay')) return;
|
|
if (e.target.closest('.editable-list-overlay')) return;
|
|
if (e.target.closest('.editable-field, [data-editable-link], [data-editable-image]')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openGroupEditor(group);
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseGroupSchema(groupEl) {
|
|
try {
|
|
const parsed = JSON.parse(groupEl.dataset.groupSchema || '{}');
|
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch (e) {
|
|
console.warn('Invalid editable group schema', e);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function openGroupEditor(groupEl) {
|
|
const page = groupEl.dataset.page;
|
|
const groupKey = groupEl.dataset.group || '';
|
|
if (!page || !groupKey) return;
|
|
|
|
const schema = parseGroupSchema(groupEl);
|
|
const fields = Array.isArray(schema.fields) ? schema.fields : [];
|
|
if (fields.length === 0) {
|
|
showToast(t('toast.error_generic', { message: 'Editable group has no fields.' }), 'error');
|
|
return;
|
|
}
|
|
|
|
const modal = document.getElementById('editable-group-modal');
|
|
const body = document.getElementById('editable-group-modal-body');
|
|
const title = document.getElementById('editable-group-modal-title');
|
|
const label = schema.label || groupKey.split('.').pop() || groupKey;
|
|
|
|
const layout = buildLocalizedGroupLayout(fields, page, groupKey);
|
|
const renderedFields = layout.flatFields || fields;
|
|
EditorConfig.currentGroup = { element: groupEl, page, groupKey, schema, fields: renderedFields };
|
|
title.textContent = formatEditorTitle(label);
|
|
body.innerHTML = layout.html;
|
|
|
|
renderedFields.forEach((field, index) => populateGroupEditorField(field, index, page, groupKey));
|
|
setupEditorLanguageTabs(body);
|
|
body.querySelectorAll('[data-group-icon-picker]').forEach(picker => {
|
|
picker.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('[data-group-icon-value]');
|
|
if (!btn) return;
|
|
e.preventDefault();
|
|
const index = parseInt(picker.dataset.groupIconPicker, 10);
|
|
const input = document.getElementById(groupFieldIds(index).id);
|
|
if (input) input.value = btn.dataset.groupIconValue || '';
|
|
updateGroupIconPicker(index, btn.dataset.groupIconValue || '');
|
|
});
|
|
});
|
|
body.querySelectorAll('[data-group-icon-search]').forEach(search => {
|
|
search.addEventListener('input', () => {
|
|
const index = parseInt(search.dataset.groupIconSearch, 10);
|
|
filterGroupIconPicker(index, search.value);
|
|
});
|
|
});
|
|
body.querySelectorAll('[data-group-image-pick]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const index = parseInt(btn.dataset.groupImagePick, 10);
|
|
const currentSrcInput = document.getElementById(groupFieldIds(index, 'src').id);
|
|
openImageManager((imagePath) => {
|
|
const srcInput = document.getElementById(groupFieldIds(index, 'src').id);
|
|
if (srcInput) srcInput.value = imagePath;
|
|
updateGroupImagePreview(index, imagePath);
|
|
}, null, currentSrcInput ? currentSrcInput.value : null);
|
|
});
|
|
});
|
|
|
|
renderedFields.forEach((field, index) => {
|
|
if (normalizeGroupField(field).type === 'link') {
|
|
initPagePicker(groupFieldIds(index, 'page').id, groupFieldIds(index, 'href').id);
|
|
}
|
|
});
|
|
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function populateGroupEditorField(field, index, page, groupKey) {
|
|
const normalized = normalizeGroupField(field);
|
|
const pageData = EditorConfig.contentData[page] || {};
|
|
const path = groupFieldPath(groupKey, normalized);
|
|
const value = getNestedValue(pageData, path);
|
|
|
|
if (normalized.type === 'link') {
|
|
const linkValue = value && typeof value === 'object' ? value : { text: '', href: (typeof value === 'string' ? value : '') };
|
|
const textInput = document.getElementById(groupFieldIds(index, 'text').id);
|
|
const hrefInput = document.getElementById(groupFieldIds(index, 'href').id);
|
|
const targetInput = document.getElementById(groupFieldIds(index, 'target').id);
|
|
if (textInput) textInput.value = linkValue.text || '';
|
|
if (hrefInput) hrefInput.value = linkValue.href || '';
|
|
if (targetInput) targetInput.checked = linkValue.target === '_blank';
|
|
setTimeout(() => syncPagePicker(groupFieldIds(index, 'page').id, groupFieldIds(index, 'href').id, linkValue.href || ''), 0);
|
|
return;
|
|
}
|
|
|
|
if (normalized.type === 'image') {
|
|
const imgValue = value && typeof value === 'object' ? value : { src: value || '', alt: '' };
|
|
const srcInput = document.getElementById(groupFieldIds(index, 'src').id);
|
|
const altInput = document.getElementById(groupFieldIds(index, 'alt').id);
|
|
if (srcInput) srcInput.value = imgValue.src || '';
|
|
if (altInput) altInput.value = imgValue.alt || '';
|
|
updateGroupImagePreview(index, imgValue.src || '');
|
|
return;
|
|
}
|
|
|
|
const input = document.getElementById(groupFieldIds(index).id);
|
|
if (!input) return;
|
|
if (normalized.type === 'checkbox') {
|
|
input.checked = value === true;
|
|
} else if (normalized.type === 'icon') {
|
|
input.value = value || 'default';
|
|
updateGroupIconPicker(index, input.value);
|
|
} else {
|
|
input.value = value ?? '';
|
|
}
|
|
}
|
|
|
|
function readGroupEditorField(field, index, groupKey) {
|
|
const normalized = normalizeGroupField(field);
|
|
const path = groupFieldPath(groupKey, normalized);
|
|
|
|
if (normalized.type === 'link') {
|
|
const text = document.getElementById(groupFieldIds(index, 'text').id)?.value || '';
|
|
const href = document.getElementById(groupFieldIds(index, 'href').id)?.value || '';
|
|
const target = document.getElementById(groupFieldIds(index, 'target').id)?.checked ? '_blank' : '';
|
|
const value = { text, href };
|
|
if (target) value.target = target;
|
|
return { path, value };
|
|
}
|
|
|
|
if (normalized.type === 'image') {
|
|
const src = document.getElementById(groupFieldIds(index, 'src').id)?.value || '';
|
|
const alt = document.getElementById(groupFieldIds(index, 'alt').id)?.value || '';
|
|
return { path, value: { src, alt } };
|
|
}
|
|
|
|
const input = document.getElementById(groupFieldIds(index).id);
|
|
const value = normalized.type === 'checkbox' ? !!input?.checked : (input?.value || '');
|
|
return { path, value };
|
|
}
|
|
|
|
function updateGroupIconPicker(index, iconId) {
|
|
const picker = document.querySelector(`[data-group-icon-picker="${index}"]`);
|
|
if (!picker) return;
|
|
|
|
picker.querySelectorAll('[data-group-icon-value]').forEach(btn => {
|
|
btn.classList.toggle('group-icon-option-active', btn.dataset.groupIconValue === iconId);
|
|
});
|
|
|
|
const preview = document.getElementById(`group-editor-icon-preview-${index}`);
|
|
const label = document.getElementById(`group-editor-icon-label-${index}`);
|
|
if (preview) {
|
|
preview.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${getIconMarkup(iconId)}</svg>`;
|
|
}
|
|
if (label) label.textContent = iconId || '';
|
|
}
|
|
|
|
function filterGroupIconPicker(index, query) {
|
|
const picker = document.querySelector(`[data-group-icon-picker="${index}"]`);
|
|
if (!picker) return;
|
|
|
|
const q = (query || '').trim().toLowerCase();
|
|
picker.querySelectorAll('[data-group-icon-value]').forEach(btn => {
|
|
const key = (btn.dataset.groupIconValue || '').toLowerCase();
|
|
btn.hidden = q !== '' && !key.includes(q);
|
|
});
|
|
}
|
|
|
|
function updateGroupImagePreview(index, src) {
|
|
const preview = document.getElementById(`group-editor-image-preview-${index}`);
|
|
if (!preview) return;
|
|
if (!src) {
|
|
preview.innerHTML = '';
|
|
return;
|
|
}
|
|
preview.innerHTML = `<img src="${escHtml(src)}" alt="" onerror="this.style.display='none'">`;
|
|
}
|
|
|
|
function closeGroupEditor() {
|
|
const modal = document.getElementById('editable-group-modal');
|
|
if (!modal) return;
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
closeDialogA11y(modal);
|
|
EditorConfig.currentGroup = null;
|
|
}
|
|
|
|
function saveGroupEditor() {
|
|
const group = EditorConfig.currentGroup;
|
|
if (!group) return;
|
|
|
|
const { page, groupKey, fields } = group;
|
|
if (!EditorConfig.contentData[page]) EditorConfig.contentData[page] = {};
|
|
|
|
pushUndoState();
|
|
fields.forEach((field, index) => {
|
|
const { path, value } = readGroupEditorField(field, index, groupKey);
|
|
if (!path) return;
|
|
setNestedValue(EditorConfig.contentData[page], path, value);
|
|
});
|
|
|
|
EditorConfig.dirtyPages.add(page);
|
|
refreshDomFromContentData();
|
|
updateUndoRedoButtons();
|
|
showToast(t('toast.change_queued'), 'success');
|
|
closeGroupEditor();
|
|
}
|
|
|
|
// ============================================================
|
|
// "ADD SECTION" MODAL
|
|
// ============================================================
|
|
|
|
function getBlockIcon(type) {
|
|
const iconMap = {
|
|
text: Icons.text,
|
|
heading: Icons.heading,
|
|
quote: Icons.quote,
|
|
list: Icons.list,
|
|
soundcloud: Icons.soundcloud,
|
|
youtube: Icons.youtube,
|
|
audio: Icons.audio,
|
|
image: Icons.image,
|
|
card: Icons.card,
|
|
divider: '—',
|
|
spacer: Icons.spacer,
|
|
};
|
|
return iconMap[type] || Icons.text;
|
|
}
|
|
|
|
function createAddSectionUI() {
|
|
// Group block types by category
|
|
const categories = {};
|
|
for (const [type, def] of Object.entries(BlockTypes)) {
|
|
const cat = def.category || 'other';
|
|
if (!categories[cat]) categories[cat] = [];
|
|
categories[cat].push({ type, ...def });
|
|
}
|
|
|
|
// Build category HTML
|
|
let categoriesHtml = '';
|
|
for (const [cat, types] of Object.entries(categories)) {
|
|
const catLabel = CategoryLabels[cat] || cat;
|
|
let buttonsHtml = '';
|
|
for (const t of types) {
|
|
buttonsHtml += `
|
|
<button type="button" class="add-section-option" data-block-type="${t.type}" onclick="InlineEditor.addSection('${t.type}')">
|
|
<span class="add-section-icon">${getBlockIcon(t.type)}</span>
|
|
<span class="add-section-label">${t.label}</span>
|
|
</button>`;
|
|
}
|
|
categoriesHtml += `
|
|
<div class="add-section-category" data-category="${cat}">
|
|
<div class="add-section-category-label">${catLabel}</div>
|
|
<div class="add-section-options">${buttonsHtml}</div>
|
|
</div>`;
|
|
}
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'add-section-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop" aria-hidden="true"></div>
|
|
<div class="editor-modal-content editor-modal-small">
|
|
<div class="editor-modal-header">
|
|
<h3 id="add-section-modal-title">${t('add_block')}</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closeAddModal()" aria-label="${escHtml(tFallback('close', 'Close'))}">×</button>
|
|
</div>
|
|
<div class="editor-modal-body">
|
|
<div class="add-section-search">
|
|
<input type="text" id="add-section-search-input" aria-label="${escHtml(t('search_blocks'))}" placeholder="${t('search_blocks')}" autocomplete="off">
|
|
</div>
|
|
${categoriesHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
setupDialogA11y(modal, 'add-section-modal-title', '#add-section-search-input');
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', closeAddModal);
|
|
|
|
// Search filter
|
|
const searchInput = document.getElementById('add-section-search-input');
|
|
searchInput.addEventListener('input', () => {
|
|
const query = searchInput.value.toLowerCase().trim();
|
|
const allOptions = modal.querySelectorAll('.add-section-option');
|
|
const allCategories = modal.querySelectorAll('.add-section-category');
|
|
|
|
allOptions.forEach(btn => {
|
|
const label = btn.querySelector('.add-section-label').textContent.toLowerCase();
|
|
const type = btn.dataset.blockType;
|
|
btn.style.display = (!query || label.includes(query) || type.includes(query)) ? '' : 'none';
|
|
});
|
|
|
|
// Hide empty categories
|
|
allCategories.forEach(cat => {
|
|
const visible = cat.querySelectorAll('.add-section-option:not([style*="display: none"])');
|
|
cat.style.display = visible.length ? '' : 'none';
|
|
});
|
|
});
|
|
|
|
// Resizable Modal
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
}
|
|
|
|
// ============================================================
|
|
// EDIT HANDLERS MIT DRAG & DROP
|
|
// ============================================================
|
|
|
|
function getContentPageForSection(wrapper) {
|
|
const container = wrapper.closest('[data-content-page]');
|
|
if (container) {
|
|
return container.dataset.contentPage;
|
|
}
|
|
return EditorConfig.currentPage;
|
|
}
|
|
|
|
function keepOverlayOpenWhileCrossing(owner, overlay) {
|
|
let hideTimer = null;
|
|
|
|
const show = () => {
|
|
if (hideTimer) {
|
|
clearTimeout(hideTimer);
|
|
hideTimer = null;
|
|
}
|
|
owner.classList.add('is-overlay-active');
|
|
};
|
|
|
|
const hideIfInactive = () => {
|
|
hideTimer = setTimeout(() => {
|
|
if (owner.matches(':hover') || overlay.matches(':hover') || overlay.contains(document.activeElement)) {
|
|
return;
|
|
}
|
|
owner.classList.remove('is-overlay-active');
|
|
}, 650);
|
|
};
|
|
|
|
owner.addEventListener('mouseenter', show);
|
|
owner.addEventListener('mouseleave', hideIfInactive);
|
|
overlay.addEventListener('mouseenter', show);
|
|
overlay.addEventListener('mouseleave', hideIfInactive);
|
|
overlay.addEventListener('focusin', show);
|
|
overlay.addEventListener('focusout', hideIfInactive);
|
|
}
|
|
|
|
function attachEditHandlers() {
|
|
const sections = document.querySelectorAll('.editable-section');
|
|
|
|
sections.forEach((wrapper) => {
|
|
if (wrapper.dataset.editorReady === 'true') return;
|
|
wrapper.dataset.editorReady = 'true';
|
|
const index = parseInt(wrapper.dataset.sectionIndex, 10);
|
|
const type = wrapper.dataset.sectionType;
|
|
const contentPage = getContentPageForSection(wrapper);
|
|
|
|
// Replace overlay with buttons
|
|
const existingOverlay = wrapper.querySelector('.editable-overlay');
|
|
if (existingOverlay) {
|
|
existingOverlay.remove();
|
|
}
|
|
|
|
// New overlay with edit and delete buttons
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'editable-overlay editable-overlay-buttons raiju-editor-section-overlay';
|
|
// Check if section is hidden
|
|
const sectionIdx = parseInt(wrapper.dataset.sectionIndex, 10);
|
|
const pageData = EditorConfig.contentData[contentPage];
|
|
const sectionData = pageData && pageData.sections ? pageData.sections[sectionIdx] : null;
|
|
const isHidden = sectionData && sectionData.hidden === true;
|
|
const hideIcon = isHidden ? Icons.eyeClosed : Icons.eyeOpen;
|
|
const hideTitle = isHidden ? t('show') : t('hide');
|
|
|
|
overlay.innerHTML = `
|
|
<button type="button" class="overlay-btn overlay-btn-edit raiju-editor-control-btn" data-editor-tooltip="${escHtml(t('edit'))}" aria-label="${escHtml(t('edit'))}" data-action="edit">${Icons.edit}</button>
|
|
<button type="button" class="overlay-btn overlay-btn-hide raiju-editor-control-btn" data-editor-tooltip="${escHtml(hideTitle)}" aria-label="${escHtml(hideTitle)}" data-action="hide">${hideIcon}</button>
|
|
<button type="button" class="overlay-btn overlay-btn-delete raiju-editor-control-btn" data-editor-tooltip="${escHtml(t('delete'))}" aria-label="${escHtml(t('delete'))}" data-action="delete">${Icons.delete}</button>
|
|
<span class="overlay-drag-handle raiju-editor-drag-handle" data-editor-tooltip="${escHtml(t('drag_reorder'))}" aria-label="${escHtml(t('drag_reorder'))}">${Icons.drag}</span>
|
|
`;
|
|
wrapper.appendChild(overlay);
|
|
keepOverlayOpenWhileCrossing(wrapper, overlay);
|
|
|
|
// Apply hidden state
|
|
if (isHidden) {
|
|
wrapper.dataset.hidden = 'true';
|
|
}
|
|
|
|
// Drag starts only from the handle so text clicks/selection inside
|
|
// sections are not hijacked by HTML5 drag.
|
|
wrapper.setAttribute('draggable', 'false');
|
|
wrapper.dataset.contentPage = contentPage;
|
|
|
|
// Find all iframes in wrapper and disable pointer-events on start
|
|
const iframes = wrapper.querySelectorAll('iframe');
|
|
iframes.forEach(iframe => {
|
|
iframe.style.pointerEvents = 'none';
|
|
});
|
|
|
|
// Drag Events
|
|
wrapper.addEventListener('dragstart', handleDragStart);
|
|
wrapper.addEventListener('dragend', handleDragEnd);
|
|
wrapper.addEventListener('dragover', handleDragOver);
|
|
wrapper.addEventListener('drop', handleDrop);
|
|
wrapper.addEventListener('dragenter', handleDragEnter);
|
|
wrapper.addEventListener('dragleave', handleDragLeave);
|
|
|
|
// Drag handle as additional trigger
|
|
const dragHandle = overlay.querySelector('.overlay-drag-handle');
|
|
if (dragHandle) {
|
|
const armDrag = () => {
|
|
wrapper.dataset.dragArmed = 'true';
|
|
wrapper.setAttribute('draggable', 'true');
|
|
};
|
|
const disarmDrag = () => {
|
|
delete wrapper.dataset.dragArmed;
|
|
wrapper.setAttribute('draggable', 'false');
|
|
};
|
|
dragHandle.addEventListener('pointerdown', armDrag);
|
|
dragHandle.addEventListener('mousedown', armDrag);
|
|
dragHandle.addEventListener('pointerup', () => setTimeout(disarmDrag, 0));
|
|
}
|
|
|
|
// Click handler for buttons — read index dynamically (survives reorder)
|
|
overlay.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('.overlay-btn');
|
|
if (!btn) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const currentIndex = parseInt(wrapper.dataset.sectionIndex, 10);
|
|
const action = btn.dataset.action;
|
|
if (action === 'edit') {
|
|
const pageData = EditorConfig.contentData[contentPage];
|
|
if (pageData && pageData.sections && pageData.sections[currentIndex]) {
|
|
openEditor(currentIndex, pageData.sections[currentIndex], contentPage);
|
|
}
|
|
} else if (action === 'hide') {
|
|
toggleSectionHidden(currentIndex, contentPage, wrapper, btn);
|
|
} else if (action === 'delete') {
|
|
deleteSection(currentIndex, contentPage);
|
|
}
|
|
});
|
|
|
|
// Note: section body clicks no longer open the editor modal.
|
|
// Text content is edited inline via .editable-field handlers; the
|
|
// modal is reserved for structural fields and is opened via the
|
|
// edit button in the overlay (handled above).
|
|
|
|
// "Add" buttons below the section
|
|
createAddButtonsForSection(wrapper, index, contentPage);
|
|
});
|
|
}
|
|
|
|
function createAddButtonsForSection(wrapper, index, contentPage) {
|
|
// Check if already present
|
|
if (wrapper.querySelector('.section-add-buttons')) return;
|
|
|
|
const container = document.createElement('div');
|
|
container.className = 'section-add-buttons';
|
|
|
|
const addBtn = document.createElement('button');
|
|
addBtn.type = 'button';
|
|
addBtn.className = 'section-add-btn-toggle raiju-editor-add-button';
|
|
const addLabel = tFallback('add_block', 'Add block');
|
|
addBtn.dataset.editorTooltip = addLabel;
|
|
addBtn.setAttribute('aria-label', addLabel);
|
|
addBtn.setAttribute('aria-expanded', 'false');
|
|
addBtn.innerHTML = Icons.plus;
|
|
|
|
addBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!EditorConfig.editMode) return;
|
|
const currentIndex = parseInt(wrapper.dataset.sectionIndex, 10);
|
|
openSectionTypePicker(addBtn, currentIndex, contentPage);
|
|
});
|
|
|
|
container.appendChild(addBtn);
|
|
wrapper.appendChild(container);
|
|
}
|
|
|
|
// ============================================================
|
|
// SECTION TYPE PICKER POPOVER
|
|
// ============================================================
|
|
|
|
function openSectionTypePicker(anchorBtn, afterIndex, contentPage) {
|
|
// Toggle: close if already open for this anchor
|
|
const existing = document.querySelector('.section-type-picker');
|
|
if (existing) {
|
|
existing.remove();
|
|
if (existing.dataset.anchorId === anchorBtn.dataset.pickerId) {
|
|
anchorBtn.setAttribute('aria-expanded', 'false');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const pickerId = 'picker-' + Date.now();
|
|
anchorBtn.dataset.pickerId = pickerId;
|
|
anchorBtn.setAttribute('aria-expanded', 'true');
|
|
|
|
// Group types by category, using the BlockTypeRegistry definition order
|
|
const categoryOrder = ['content', 'cards', 'media', 'interactive', 'embed', 'layout'];
|
|
const grouped = {};
|
|
for (const [type, def] of Object.entries(BlockTypes)) {
|
|
const cat = def.category || 'other';
|
|
if (!grouped[cat]) grouped[cat] = [];
|
|
grouped[cat].push({ type, def });
|
|
}
|
|
|
|
const picker = document.createElement('div');
|
|
picker.className = 'section-type-picker';
|
|
picker.dataset.anchorId = pickerId;
|
|
|
|
const orderedCategories = [
|
|
...categoryOrder.filter(c => grouped[c]),
|
|
...Object.keys(grouped).filter(c => !categoryOrder.includes(c))
|
|
];
|
|
|
|
let markup = '';
|
|
for (const cat of orderedCategories) {
|
|
const label = CategoryLabels[cat] || cat;
|
|
markup += `<div class="section-type-picker__category"><span class="section-type-picker__category-label">${label}</span><div class="section-type-picker__grid">`;
|
|
for (const { type, def } of grouped[cat]) {
|
|
const icon = Icons[def.icon] || Icons[type] || Icons.plus;
|
|
const labelText = def.label || type;
|
|
markup += `<button type="button" class="section-type-picker__item" data-type="${type}" title="${labelText}"><span class="section-type-picker__icon">${icon}</span><span class="section-type-picker__label">${labelText}</span></button>`;
|
|
}
|
|
markup += `</div></div>`;
|
|
}
|
|
picker.innerHTML = markup;
|
|
|
|
// Click handler for type selection
|
|
picker.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.section-type-picker__item');
|
|
if (!item) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const type = item.dataset.type;
|
|
closePicker();
|
|
const wrapper = anchorBtn.closest('.editable-section');
|
|
const currentIndex = wrapper ? parseInt(wrapper.dataset.sectionIndex, 10) : afterIndex;
|
|
addSectionAfter(currentIndex, type, contentPage);
|
|
});
|
|
|
|
document.body.appendChild(picker);
|
|
positionPicker(picker, anchorBtn);
|
|
|
|
// Close on outside click / Escape
|
|
const closePicker = () => {
|
|
picker.remove();
|
|
anchorBtn.setAttribute('aria-expanded', 'false');
|
|
document.removeEventListener('click', outsideClick, true);
|
|
document.removeEventListener('keydown', escHandler, true);
|
|
window.removeEventListener('resize', reposition);
|
|
window.removeEventListener('scroll', reposition, true);
|
|
};
|
|
const outsideClick = (e) => {
|
|
if (picker.contains(e.target) || anchorBtn.contains(e.target)) return;
|
|
closePicker();
|
|
};
|
|
const escHandler = (e) => {
|
|
if (e.key === 'Escape') closePicker();
|
|
};
|
|
const reposition = () => positionPicker(picker, anchorBtn);
|
|
// Defer to avoid catching the click that opened it
|
|
setTimeout(() => {
|
|
document.addEventListener('click', outsideClick, true);
|
|
document.addEventListener('keydown', escHandler, true);
|
|
window.addEventListener('resize', reposition);
|
|
window.addEventListener('scroll', reposition, true);
|
|
}, 0);
|
|
}
|
|
|
|
function positionPicker(picker, anchor) {
|
|
const rect = anchor.getBoundingClientRect();
|
|
const pickerRect = picker.getBoundingClientRect();
|
|
const margin = 8;
|
|
let top = rect.bottom + margin;
|
|
let left = rect.left + rect.width / 2 - pickerRect.width / 2;
|
|
|
|
// Clamp to viewport
|
|
const maxLeft = window.innerWidth - pickerRect.width - margin;
|
|
if (left < margin) left = margin;
|
|
if (left > maxLeft) left = maxLeft;
|
|
|
|
// Flip above if not enough space below
|
|
if (top + pickerRect.height > window.innerHeight - margin) {
|
|
const flipped = rect.top - pickerRect.height - margin;
|
|
if (flipped > margin) top = flipped;
|
|
}
|
|
|
|
picker.style.top = `${top + window.scrollY}px`;
|
|
picker.style.left = `${left + window.scrollX}px`;
|
|
}
|
|
|
|
// ============================================================
|
|
// DRAG & DROP
|
|
// ============================================================
|
|
|
|
function handleDragStart(e) {
|
|
if (!EditorConfig.editMode) { e.preventDefault(); return; }
|
|
const wrapper = e.target.closest('.editable-section');
|
|
if (!wrapper) return;
|
|
if (wrapper.dataset.dragArmed !== 'true') {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
EditorConfig.draggedElement = wrapper;
|
|
EditorConfig.draggedIndex = parseInt(wrapper.dataset.sectionIndex, 10);
|
|
EditorConfig.draggedContentPage = wrapper.dataset.contentPage;
|
|
|
|
wrapper.classList.add('dragging');
|
|
|
|
// Disable all iframes during drag
|
|
document.querySelectorAll('.editable-section iframe').forEach(iframe => {
|
|
iframe.style.pointerEvents = 'none';
|
|
});
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', EditorConfig.draggedIndex.toString());
|
|
|
|
// For better visual feedback
|
|
if (e.dataTransfer.setDragImage && wrapper) {
|
|
e.dataTransfer.setDragImage(wrapper, 20, 20);
|
|
}
|
|
}
|
|
|
|
function handleDragEnd(e) {
|
|
const wrapper = e.target.closest('.editable-section') || this;
|
|
wrapper.classList.remove('dragging');
|
|
|
|
document.querySelectorAll('.editable-section').forEach(el => {
|
|
el.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom', 'dragging');
|
|
});
|
|
|
|
// Re-enable iframes (for playback etc., but in editor mode they stay blocked via CSS)
|
|
// Not necessary, CSS handles this
|
|
|
|
EditorConfig.draggedElement = null;
|
|
EditorConfig.draggedIndex = null;
|
|
EditorConfig.draggedContentPage = null;
|
|
delete wrapper.dataset.dragArmed;
|
|
wrapper.setAttribute('draggable', 'false');
|
|
}
|
|
|
|
function handleDragOver(e) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
const targetPage = this.dataset.contentPage;
|
|
if (targetPage !== EditorConfig.draggedContentPage) {
|
|
return;
|
|
}
|
|
|
|
const rect = this.getBoundingClientRect();
|
|
const midY = rect.top + rect.height / 2;
|
|
|
|
this.classList.remove('drag-over-top', 'drag-over-bottom');
|
|
if (e.clientY < midY) {
|
|
this.classList.add('drag-over-top');
|
|
} else {
|
|
this.classList.add('drag-over-bottom');
|
|
}
|
|
}
|
|
|
|
function handleDragEnter(e) {
|
|
const targetPage = this.dataset.contentPage;
|
|
if (targetPage === EditorConfig.draggedContentPage) {
|
|
this.classList.add('drag-over');
|
|
}
|
|
}
|
|
|
|
function handleDragLeave(e) {
|
|
this.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
|
|
}
|
|
|
|
function handleDrop(e) {
|
|
e.preventDefault();
|
|
|
|
const targetPage = this.dataset.contentPage;
|
|
if (targetPage !== EditorConfig.draggedContentPage) {
|
|
showToast(t('toast.sort_same_section'), 'error');
|
|
return;
|
|
}
|
|
|
|
const fromIndex = EditorConfig.draggedIndex;
|
|
let toIndex = parseInt(this.dataset.sectionIndex, 10);
|
|
|
|
const rect = this.getBoundingClientRect();
|
|
const midY = rect.top + rect.height / 2;
|
|
if (e.clientY > midY && toIndex < fromIndex) {
|
|
toIndex++;
|
|
} else if (e.clientY < midY && toIndex > fromIndex) {
|
|
toIndex--;
|
|
}
|
|
|
|
if (fromIndex !== toIndex) {
|
|
reorderSections(fromIndex, toIndex, targetPage);
|
|
}
|
|
|
|
this.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
|
|
}
|
|
|
|
function reorderSections(fromIndex, toIndex, contentPage) {
|
|
pushReorderUndo('reorder-section', { contentPage, from: fromIndex, to: toIndex });
|
|
executeSectionReorder(fromIndex, toIndex, contentPage);
|
|
}
|
|
|
|
function executeSectionReorder(fromIndex, toIndex, contentPage) {
|
|
const pageData = EditorConfig.contentData[contentPage];
|
|
if (!pageData || !pageData.sections) return;
|
|
|
|
// Update data
|
|
const sections = pageData.sections;
|
|
const [movedItem] = sections.splice(fromIndex, 1);
|
|
sections.splice(toIndex, 0, movedItem);
|
|
|
|
EditorConfig.dirtyPages.add(contentPage);
|
|
|
|
// Update DOM
|
|
const allSections = Array.from(
|
|
document.querySelectorAll(`.editable-section[data-content-page="${contentPage}"]`)
|
|
);
|
|
if (allSections[fromIndex]) {
|
|
const movedEl = allSections[fromIndex];
|
|
if (fromIndex < toIndex) {
|
|
const refEl = allSections[toIndex];
|
|
refEl.parentNode.insertBefore(movedEl, refEl.nextSibling);
|
|
} else {
|
|
const refEl = allSections[toIndex];
|
|
refEl.parentNode.insertBefore(movedEl, refEl);
|
|
}
|
|
// Update indices
|
|
document.querySelectorAll(`.editable-section[data-content-page="${contentPage}"]`).forEach((el, i) => {
|
|
el.dataset.sectionIndex = i;
|
|
});
|
|
}
|
|
|
|
showToast(t('toast.order_changed'), 'success');
|
|
}
|
|
|
|
// ============================================================
|
|
// ADD SECTION
|
|
// ============================================================
|
|
|
|
let addAfterIndex = null;
|
|
let addContentPage = null;
|
|
|
|
function openAddModal(afterIndex, contentPage) {
|
|
addAfterIndex = afterIndex;
|
|
addContentPage = contentPage;
|
|
const modal = document.getElementById('add-section-modal');
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
openDialogA11y(modal);
|
|
|
|
// Reset search and show all
|
|
const searchInput = document.getElementById('add-section-search-input');
|
|
if (searchInput) {
|
|
searchInput.value = '';
|
|
searchInput.dispatchEvent(new Event('input'));
|
|
setTimeout(() => searchInput.focus(), 100);
|
|
}
|
|
}
|
|
|
|
function closeAddModal() {
|
|
const modal = document.getElementById('add-section-modal');
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
closeDialogA11y(modal);
|
|
addAfterIndex = null;
|
|
addContentPage = null;
|
|
}
|
|
|
|
function addSectionAfter(afterIndex, type, contentPage) {
|
|
const pageData = EditorConfig.contentData[contentPage];
|
|
if (!pageData) return;
|
|
|
|
pushUndoState();
|
|
const newSection = createNewSection(type, pageData.sections.length);
|
|
const insertIndex = afterIndex + 1;
|
|
pageData.sections.splice(insertIndex, 0, newSection);
|
|
|
|
saveAndInsertSection(insertIndex, contentPage, false);
|
|
}
|
|
|
|
function addSection(type) {
|
|
if (addAfterIndex === null || !addContentPage) return;
|
|
|
|
const pageData = EditorConfig.contentData[addContentPage];
|
|
if (!pageData) return;
|
|
|
|
pushUndoState();
|
|
const newSection = createNewSection(type, pageData.sections.length);
|
|
const insertIndex = addAfterIndex + 1;
|
|
pageData.sections.splice(insertIndex, 0, newSection);
|
|
|
|
closeAddModal();
|
|
saveAndInsertSection(insertIndex, addContentPage, false);
|
|
}
|
|
|
|
function createNewSection(type, count) {
|
|
const id = `new_${type}_${Date.now()}`;
|
|
const def = BlockTypes[type];
|
|
|
|
if (def && def.defaults) {
|
|
return { id, type, ...JSON.parse(JSON.stringify(def.defaults)) };
|
|
}
|
|
return { id, type };
|
|
}
|
|
|
|
/**
|
|
* Persist contentData, fetch a freshly-rendered .editable-content-area
|
|
* block from the server, replace the existing DOM region in-place and
|
|
* re-wire handlers. Avoids a full page reload so the admin bar and
|
|
* edit-mode state stay intact.
|
|
*/
|
|
async function saveAndInsertSection(index, contentPage, shouldPushUndo = true) {
|
|
if (shouldPushUndo) pushUndoState();
|
|
EditorConfig.dirtyPages.add(contentPage);
|
|
updateUndoRedoButtons();
|
|
|
|
try {
|
|
const pageData = EditorConfig.contentData[contentPage];
|
|
const formData = new FormData();
|
|
formData.append('action', 'save');
|
|
formData.append('page', contentPage);
|
|
formData.append('content', JSON.stringify(pageData));
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
formData.append('render_sections', '1');
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, { method: 'POST', body: formData });
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
showToast(t('toast.error_generic', { message: result.message || 'Unknown' }), 'error');
|
|
return;
|
|
}
|
|
|
|
if (result.data && result.data.sectionsHtml) {
|
|
replaceContentArea(result.data.sectionsHtml, contentPage, index);
|
|
showToast(tFallback('toast.section_added', 'Section added'), 'success');
|
|
EditorConfig.dirtyPages.delete(contentPage);
|
|
} else {
|
|
// Server did not return HTML (shouldn't happen) — fall back to reload.
|
|
sessionStorage.setItem('site-edit-mode', 'true');
|
|
location.reload();
|
|
}
|
|
} catch (error) {
|
|
showToast(t('toast.error_saving', { page: contentPage, message: error.message }), 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replace the <div class="editable-content-area"> for the given page with
|
|
* freshly-rendered server HTML, then re-wire editor handlers on the new
|
|
* nodes. Scrolls the section at `focusIndex` into view.
|
|
*/
|
|
function replaceContentArea(html, contentPage, focusIndex) {
|
|
const oldArea = document.querySelector(`.editable-content-area[data-content-page="${CSS.escape(contentPage)}"]`);
|
|
if (!oldArea) {
|
|
sessionStorage.setItem('site-edit-mode', 'true');
|
|
location.reload();
|
|
return;
|
|
}
|
|
|
|
const tpl = document.createElement('template');
|
|
tpl.innerHTML = html.trim();
|
|
const newArea = tpl.content.firstElementChild;
|
|
if (!newArea) return;
|
|
|
|
oldArea.replaceWith(newArea);
|
|
|
|
// Re-wire handlers. attach* functions guard via data-editor-ready,
|
|
// but the new DOM nodes have no such marker so they all get wired.
|
|
attachEditHandlers();
|
|
attachFieldEditHandlers();
|
|
attachLinkEditHandlers();
|
|
attachImageEditHandlers();
|
|
attachListEditHandlers();
|
|
|
|
if (EditorConfig.editMode) {
|
|
document.body.classList.add('edit-mode-active', 'visual-editing');
|
|
}
|
|
|
|
const focused = newArea.querySelectorAll('.editable-section')[focusIndex];
|
|
if (focused) focused.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
// ============================================================
|
|
// HIDE / SHOW SECTION
|
|
// ============================================================
|
|
|
|
function toggleSectionHidden(index, contentPage, wrapperEl, btnEl) {
|
|
const pageData = EditorConfig.contentData[contentPage];
|
|
if (!pageData || !pageData.sections || !pageData.sections[index]) return;
|
|
|
|
pushUndoState();
|
|
|
|
const section = pageData.sections[index];
|
|
const nowHidden = !section.hidden;
|
|
|
|
if (nowHidden) {
|
|
section.hidden = true;
|
|
wrapperEl.dataset.hidden = 'true';
|
|
} else {
|
|
delete section.hidden;
|
|
delete wrapperEl.dataset.hidden;
|
|
}
|
|
|
|
// Update button icon
|
|
btnEl.innerHTML = nowHidden ? Icons.eyeClosed : Icons.eyeOpen;
|
|
const label = nowHidden ? t('show') : t('hide');
|
|
btnEl.dataset.editorTooltip = label;
|
|
btnEl.setAttribute('aria-label', label);
|
|
|
|
EditorConfig.dirtyPages.add(contentPage);
|
|
updateUndoRedoButtons();
|
|
|
|
showToast(nowHidden ? t('toast.hidden') : t('toast.visible'), 'success');
|
|
}
|
|
|
|
// ============================================================
|
|
// DELETE SECTION
|
|
// ============================================================
|
|
|
|
function deleteSection(index, contentPage) {
|
|
const pageData = EditorConfig.contentData[contentPage];
|
|
if (!pageData || !pageData.sections || !pageData.sections[index]) return;
|
|
|
|
const section = pageData.sections[index];
|
|
const typeName = BlockTypes[section.type]?.label || section.type;
|
|
|
|
showConfirmDialog(
|
|
t('section.delete'),
|
|
t('section.delete_confirm', { type: typeName }),
|
|
t('section.reload_hint'),
|
|
() => {
|
|
pushUndoState();
|
|
pageData.sections.splice(index, 1);
|
|
EditorConfig.dirtyPages.add(contentPage);
|
|
updateUndoRedoButtons();
|
|
saveStructuralChange(contentPage, 'Section deleted');
|
|
}
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// OPEN/CLOSE EDITOR
|
|
// ============================================================
|
|
|
|
function getSectionTypeName(type) {
|
|
return SectionTypes[type]?.label || type;
|
|
}
|
|
|
|
/**
|
|
* Populates an editor field with data from the section.
|
|
*/
|
|
function populateEditorField(field, section, defaults = {}) {
|
|
const { fid, iid } = editorFieldIds(field);
|
|
const fieldEl = document.getElementById(fid);
|
|
if (!fieldEl) return;
|
|
|
|
fieldEl.style.display = 'block';
|
|
const value = section[field.key] ?? defaults[field.key] ?? '';
|
|
|
|
switch (field.type) {
|
|
case 'input':
|
|
case 'number':
|
|
case 'url':
|
|
document.getElementById(iid).value = value;
|
|
break;
|
|
|
|
case 'textarea':
|
|
document.getElementById(iid).value = value;
|
|
break;
|
|
|
|
case 'wysiwyg': {
|
|
const htmlToggle = document.getElementById('editor-html-toggle');
|
|
if (htmlToggle) htmlToggle.checked = false;
|
|
const wysiwyg = document.getElementById('editor-wysiwyg');
|
|
const htmlArea = document.getElementById('editor-html');
|
|
if (wysiwyg) { wysiwyg.style.display = 'block'; wysiwyg.innerHTML = value; }
|
|
if (htmlArea) { htmlArea.style.display = 'none'; htmlArea.value = value; }
|
|
break;
|
|
}
|
|
|
|
case 'select': {
|
|
const selectEl = document.getElementById(iid);
|
|
// Fall back to first option when no value is set
|
|
if (value) selectEl.value = value;
|
|
else if (selectEl.options.length > 0) selectEl.selectedIndex = 0;
|
|
break;
|
|
}
|
|
|
|
case 'checkbox':
|
|
document.getElementById(iid).checked = !!value;
|
|
break;
|
|
|
|
case 'image':
|
|
document.getElementById(iid).value = value;
|
|
updateImagePreview(iid);
|
|
break;
|
|
|
|
case 'audio':
|
|
document.getElementById(iid).value = value;
|
|
updateAudioPreview();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads an editor field value back.
|
|
*/
|
|
function readEditorField(field) {
|
|
const { iid } = editorFieldIds(field);
|
|
|
|
switch (field.type) {
|
|
case 'input':
|
|
case 'textarea':
|
|
case 'number':
|
|
case 'url':
|
|
case 'image':
|
|
return (document.getElementById(iid)?.value || '').trim();
|
|
|
|
case 'wysiwyg':
|
|
if (EditorConfig.isHtmlMode) {
|
|
return document.getElementById('editor-html')?.value || '';
|
|
}
|
|
return document.getElementById('editor-wysiwyg')?.innerHTML || '';
|
|
|
|
case 'select':
|
|
return document.getElementById(iid)?.value || '';
|
|
|
|
case 'checkbox':
|
|
return document.getElementById(iid)?.checked || false;
|
|
|
|
case 'audio':
|
|
return (document.getElementById(iid)?.value || '').trim();
|
|
|
|
default:
|
|
return (document.getElementById(iid)?.value || '').trim();
|
|
}
|
|
}
|
|
|
|
function formatEditorTitle(label) {
|
|
const template = t('edit_block_title');
|
|
return template && template !== 'edit_block_title'
|
|
? template.replace('{label}', label)
|
|
: t('edit') + ' ' + label;
|
|
}
|
|
|
|
function openEditor(index, section, contentPage) {
|
|
EditorConfig.currentSectionIndex = index;
|
|
EditorConfig.currentSection = { ...section };
|
|
EditorConfig.currentContentPage = contentPage;
|
|
EditorConfig.isHtmlMode = false;
|
|
|
|
const modal = document.getElementById('editor-modal');
|
|
const modalTitle = document.getElementById('editor-modal-title');
|
|
|
|
// Hide all fields
|
|
modal.querySelectorAll('.editor-field').forEach(f => f.style.display = 'none');
|
|
|
|
// Resolve type (legacy alias)
|
|
const type = section.type === 'project' ? 'card' : section.type;
|
|
const def = BlockTypes[type];
|
|
|
|
if (!def) {
|
|
modalTitle.textContent = formatEditorTitle(type);
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
openDialogA11y(modal);
|
|
return;
|
|
}
|
|
|
|
modalTitle.textContent = formatEditorTitle(def.label);
|
|
|
|
// Show and populate fields from registry
|
|
for (const field of (def.fields || [])) {
|
|
populateEditorField(field, section, def.defaults || {});
|
|
|
|
// Special preview triggers
|
|
if (field.key === 'videoId') updateYoutubePreview();
|
|
if (field.key === 'trackId') updateSoundcloudPreview();
|
|
}
|
|
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
openDialogA11y(modal);
|
|
}
|
|
|
|
function closeModal() {
|
|
const modal = document.getElementById('editor-modal');
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
closeDialogA11y(modal);
|
|
EditorConfig.currentSection = null;
|
|
EditorConfig.currentSectionIndex = null;
|
|
EditorConfig.currentContentPage = null;
|
|
}
|
|
|
|
// ============================================================
|
|
// WYSIWYG FUNKTIONEN
|
|
// ============================================================
|
|
|
|
function execCommand(command) {
|
|
document.execCommand(command, false, null);
|
|
document.getElementById('editor-wysiwyg').focus();
|
|
}
|
|
|
|
/**
|
|
* Sanitizes an HTML string: removes style/class attributes, unwraps disallowed tags.
|
|
* Keeps: P, H1-H6, A, B, STRONG, I, EM, BR, UL, OL, LI, IMG
|
|
* @param {string} html - Raw HTML string
|
|
* @returns {string} Cleaned HTML string
|
|
*/
|
|
function sanitizeHtmlString(html) {
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = html;
|
|
|
|
function cleanNode(node) {
|
|
const children = Array.from(node.childNodes);
|
|
children.forEach(child => {
|
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
const tagName = child.tagName.toLowerCase();
|
|
const allowedTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'b', 'strong', 'i', 'em', 'br', 'ul', 'ol', 'li', 'img'];
|
|
|
|
if (allowedTags.includes(tagName)) {
|
|
const attrs = Array.from(child.attributes);
|
|
attrs.forEach(attr => {
|
|
if (tagName === 'a' && (attr.name === 'href' || attr.name === 'target' || attr.name === 'rel')) {
|
|
// keep href, target, rel on links
|
|
} else if (tagName === 'img' && (attr.name === 'src' || attr.name === 'alt')) {
|
|
// keep src/alt on images
|
|
} else {
|
|
child.removeAttribute(attr.name);
|
|
}
|
|
});
|
|
// Auto-set target="_blank" for external links
|
|
if (tagName === 'a') {
|
|
const href = child.getAttribute('href') || '';
|
|
if (/^https?:\/\//i.test(href)) {
|
|
child.setAttribute('target', '_blank');
|
|
child.setAttribute('rel', 'noopener');
|
|
} else {
|
|
child.removeAttribute('target');
|
|
child.removeAttribute('rel');
|
|
}
|
|
}
|
|
cleanNode(child);
|
|
} else {
|
|
cleanNode(child);
|
|
while (child.firstChild) {
|
|
node.insertBefore(child.firstChild, child);
|
|
}
|
|
node.removeChild(child);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
cleanNode(temp);
|
|
return temp.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Cleans HTML in the WYSIWYG modal editor (uses sanitizeHtmlString internally)
|
|
*/
|
|
function cleanHtml() {
|
|
const wysiwyg = document.getElementById('editor-wysiwyg');
|
|
wysiwyg.innerHTML = sanitizeHtmlString(wysiwyg.innerHTML);
|
|
wysiwyg.focus();
|
|
showToast(t('toast.formatting_cleaned'), 'success');
|
|
}
|
|
|
|
// ============================================================
|
|
// FLOATING TOOLBAR (inline rich text editing)
|
|
// ============================================================
|
|
|
|
let floatingToolbar = null;
|
|
let activeHtmlField = null;
|
|
|
|
function createFloatingToolbar() {
|
|
if (floatingToolbar) return floatingToolbar;
|
|
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'floating-toolbar';
|
|
toolbar.innerHTML = `
|
|
<button type="button" data-command="bold" data-editor-tooltip="${escHtml(t('bold'))} (Ctrl+B)" aria-label="${escHtml(t('bold'))} (Ctrl+B)"><b>B</b></button>
|
|
<button type="button" data-command="italic" data-editor-tooltip="${escHtml(t('italic'))} (Ctrl+I)" aria-label="${escHtml(t('italic'))} (Ctrl+I)"><i>I</i></button>
|
|
<button type="button" data-action="link" data-editor-tooltip="${escHtml(t('insert_link'))}" aria-label="${escHtml(t('insert_link'))}"><svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg></button>
|
|
<button type="button" data-command="removeFormat" data-editor-tooltip="${escHtml(t('clean_formatting'))}" aria-label="${escHtml(t('clean_formatting'))}">${Icons.eraser}</button>
|
|
<span class="floating-toolbar-separator"></span>
|
|
<button type="button" data-action="html-source" data-editor-tooltip="HTML" aria-label="HTML"></></button>
|
|
`;
|
|
|
|
// Prevent blur on toolbar button clicks
|
|
toolbar.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
});
|
|
|
|
// Handle button clicks
|
|
toolbar.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button');
|
|
if (!btn || !activeHtmlField) return;
|
|
|
|
const command = btn.dataset.command;
|
|
const action = btn.dataset.action;
|
|
|
|
if (command) {
|
|
document.execCommand(command, false, null);
|
|
activeHtmlField.focus();
|
|
} else if (action === 'link') {
|
|
insertLinkInField(activeHtmlField);
|
|
} else if (action === 'html-source') {
|
|
// Finish inline editing first, then open modal
|
|
const field = activeHtmlField;
|
|
finishInlineHtmlEdit(field, field._originalHtml, false);
|
|
openFieldHtmlEditor(field);
|
|
}
|
|
});
|
|
|
|
document.body.appendChild(toolbar);
|
|
floatingToolbar = toolbar;
|
|
return toolbar;
|
|
}
|
|
|
|
function insertLinkInField(field) {
|
|
const selection = window.getSelection();
|
|
let savedRange = null;
|
|
|
|
if (selection.rangeCount > 0) {
|
|
savedRange = selection.getRangeAt(0).cloneRange();
|
|
}
|
|
|
|
const selectedText = selection.toString();
|
|
if (!selectedText) {
|
|
showToast(t('toast.select_text_first'), 'error');
|
|
return;
|
|
}
|
|
|
|
// Reuse the link editor modal with page picker
|
|
createLinkEditorDialog();
|
|
initPagePicker('link-editor-page-select', 'link-editor-href');
|
|
|
|
document.getElementById('link-editor-text').value = selectedText;
|
|
document.getElementById('link-editor-text').readOnly = true;
|
|
document.getElementById('link-editor-text').style.opacity = '0.6';
|
|
syncPagePicker('link-editor-page-select', 'link-editor-href', '');
|
|
|
|
const modal = document.getElementById('link-editor-modal');
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
// Override save to insert link via execCommand
|
|
modal._editTarget = null;
|
|
modal._inlineInsert = { field, savedRange };
|
|
|
|
setTimeout(() => {
|
|
const select = document.getElementById('link-editor-page-select');
|
|
if (select) select.focus();
|
|
}, 100);
|
|
}
|
|
|
|
function positionFloatingToolbar(field) {
|
|
const toolbar = createFloatingToolbar();
|
|
const rect = field.getBoundingClientRect();
|
|
toolbar.style.display = 'flex';
|
|
|
|
// Measure toolbar
|
|
const tbRect = toolbar.getBoundingClientRect();
|
|
const gap = 8;
|
|
|
|
// Center horizontally above field
|
|
let left = rect.left + (rect.width / 2) - (tbRect.width / 2);
|
|
let top = rect.top - tbRect.height - gap;
|
|
|
|
// If no room above, place below
|
|
if (top < 50) {
|
|
top = rect.bottom + gap;
|
|
}
|
|
|
|
// Keep within viewport horizontally
|
|
left = Math.max(8, Math.min(left, window.innerWidth - tbRect.width - 8));
|
|
|
|
toolbar.style.left = left + 'px';
|
|
toolbar.style.top = top + 'px';
|
|
}
|
|
|
|
function hideFloatingToolbar() {
|
|
if (floatingToolbar) {
|
|
floatingToolbar.style.display = 'none';
|
|
}
|
|
if (activeHtmlField && activeHtmlField._scrollHandler) {
|
|
window.removeEventListener('scroll', activeHtmlField._scrollHandler, true);
|
|
window.removeEventListener('resize', activeHtmlField._scrollHandler);
|
|
delete activeHtmlField._scrollHandler;
|
|
}
|
|
activeHtmlField = null;
|
|
}
|
|
|
|
function startInlineHtmlEdit(field) {
|
|
// Finish any other active inline HTML edit
|
|
if (activeHtmlField && activeHtmlField !== field) {
|
|
finishInlineHtmlEdit(activeHtmlField, activeHtmlField._originalHtml, false);
|
|
}
|
|
|
|
const originalHtml = field.innerHTML;
|
|
field._originalHtml = originalHtml;
|
|
field.contentEditable = 'true';
|
|
field.classList.add('editable-field-editing');
|
|
|
|
// Show and position toolbar
|
|
activeHtmlField = field;
|
|
positionFloatingToolbar(field);
|
|
|
|
// Reposition on scroll/resize
|
|
const scrollHandler = () => positionFloatingToolbar(field);
|
|
field._scrollHandler = scrollHandler;
|
|
window.addEventListener('scroll', scrollHandler, true);
|
|
window.addEventListener('resize', scrollHandler);
|
|
|
|
// Keyboard handler
|
|
const keyHandler = (e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
cancelInlineHtmlEdit(field, originalHtml);
|
|
} else if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
finishInlineHtmlEdit(field, originalHtml, false);
|
|
saveAllChanges();
|
|
}
|
|
// Bold/Italic/etc. handled by browser natively in contentEditable
|
|
};
|
|
|
|
// Blur handler
|
|
const blurHandler = () => {
|
|
setTimeout(() => {
|
|
// Check if focus moved to toolbar or prompt dialog
|
|
const active = document.activeElement;
|
|
if (floatingToolbar && floatingToolbar.contains(active)) return;
|
|
if (document.querySelector('.prompt-dialog-overlay')) return;
|
|
if (field.isContentEditable) {
|
|
finishInlineHtmlEdit(field, originalHtml, false);
|
|
}
|
|
}, 200);
|
|
};
|
|
|
|
// Paste handler — sanitize pasted HTML
|
|
const pasteHandler = (e) => {
|
|
e.preventDefault();
|
|
const html = e.clipboardData.getData('text/html');
|
|
const text = e.clipboardData.getData('text/plain');
|
|
|
|
if (html) {
|
|
const clean = sanitizeHtmlString(html);
|
|
document.execCommand('insertHTML', false, clean);
|
|
} else if (text) {
|
|
document.execCommand('insertText', false, text);
|
|
}
|
|
};
|
|
|
|
field.addEventListener('keydown', keyHandler);
|
|
field.addEventListener('blur', blurHandler);
|
|
field.addEventListener('paste', pasteHandler);
|
|
field._inlineHtmlHandlers = { keyHandler, blurHandler, pasteHandler };
|
|
|
|
field.focus();
|
|
}
|
|
|
|
function finishInlineHtmlEdit(field, originalHtml, cancelled) {
|
|
if (!field.isContentEditable) return;
|
|
|
|
const newHtml = sanitizeHtmlString(field.innerHTML).trim();
|
|
field.contentEditable = 'false';
|
|
field.classList.remove('editable-field-editing');
|
|
cleanupInlineHtmlHandlers(field);
|
|
hideFloatingToolbar();
|
|
|
|
if (!cancelled && newHtml !== originalHtml) {
|
|
saveField(field.dataset.page, field.dataset.field, newHtml, field, true);
|
|
} else if (cancelled) {
|
|
field.innerHTML = originalHtml;
|
|
}
|
|
}
|
|
|
|
function cancelInlineHtmlEdit(field, originalHtml) {
|
|
finishInlineHtmlEdit(field, originalHtml, true);
|
|
}
|
|
|
|
function cleanupInlineHtmlHandlers(field) {
|
|
if (field._inlineHtmlHandlers) {
|
|
field.removeEventListener('keydown', field._inlineHtmlHandlers.keyHandler);
|
|
field.removeEventListener('blur', field._inlineHtmlHandlers.blurHandler);
|
|
field.removeEventListener('paste', field._inlineHtmlHandlers.pasteHandler);
|
|
delete field._inlineHtmlHandlers;
|
|
}
|
|
delete field._originalHtml;
|
|
delete field._scrollHandler;
|
|
}
|
|
|
|
function insertLink() {
|
|
const wysiwyg = document.getElementById('editor-wysiwyg');
|
|
|
|
// Save current selection before dialog opens
|
|
const selection = window.getSelection();
|
|
let savedRange = null;
|
|
|
|
if (selection.rangeCount > 0) {
|
|
savedRange = selection.getRangeAt(0).cloneRange();
|
|
}
|
|
|
|
// Check if text is selected
|
|
const selectedText = selection.toString();
|
|
if (!selectedText) {
|
|
showToast(t('toast.select_text_first'), 'error');
|
|
return;
|
|
}
|
|
|
|
showPromptDialog(t('insert_link'), t('insert_link'), 'https://', (url) => {
|
|
if (url && savedRange) {
|
|
// Set focus on editor
|
|
wysiwyg.focus();
|
|
|
|
// Selektion wiederherstellen
|
|
const newSelection = window.getSelection();
|
|
newSelection.removeAllRanges();
|
|
newSelection.addRange(savedRange);
|
|
|
|
// Link erstellen
|
|
document.execCommand('createLink', false, url);
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleHtmlMode() {
|
|
const wysiwyg = document.getElementById('editor-wysiwyg');
|
|
const html = document.getElementById('editor-html');
|
|
const toggle = document.getElementById('editor-html-toggle');
|
|
|
|
EditorConfig.isHtmlMode = toggle.checked;
|
|
|
|
if (EditorConfig.isHtmlMode) {
|
|
html.value = formatHtml(wysiwyg.innerHTML);
|
|
wysiwyg.style.display = 'none';
|
|
html.style.display = 'block';
|
|
html.focus();
|
|
} else {
|
|
wysiwyg.innerHTML = html.value;
|
|
html.style.display = 'none';
|
|
wysiwyg.style.display = 'block';
|
|
wysiwyg.focus();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// MEDIA PREVIEWS
|
|
// ============================================================
|
|
|
|
function updateYoutubePreview() {
|
|
const input = document.getElementById('editor-input-videoId-input');
|
|
const preview = document.getElementById('editor-youtube-preview');
|
|
const videoId = input.value.trim();
|
|
|
|
if (videoId) {
|
|
preview.innerHTML = `
|
|
<iframe src="https://www.youtube-nocookie.com/embed/${escHtml(videoId)}"
|
|
frameborder="0" allowfullscreen style="width:100%;height:200px;"></iframe>
|
|
`;
|
|
} else {
|
|
preview.innerHTML = '<p class="preview-placeholder">Enter video ID for preview</p>';
|
|
}
|
|
}
|
|
|
|
function updateSoundcloudPreview() {
|
|
const input = document.getElementById('editor-input-trackId-input');
|
|
const preview = document.getElementById('editor-soundcloud-preview');
|
|
const trackId = input.value.trim();
|
|
|
|
if (trackId) {
|
|
preview.innerHTML = `
|
|
<iframe width="100%" height="120" scrolling="no" frameborder="no"
|
|
src="https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/${escHtml(trackId)}&color=%233b82f6&inverse=true&auto_play=false&show_user=true">
|
|
</iframe>
|
|
`;
|
|
} else {
|
|
preview.innerHTML = '<p class="preview-placeholder">Enter track ID for preview</p>';
|
|
}
|
|
}
|
|
|
|
function updateAudioPreview() {
|
|
const input = document.getElementById('editor-input-src-audio');
|
|
const preview = document.getElementById('editor-audio-preview');
|
|
const src = input.value.trim();
|
|
|
|
if (src) {
|
|
preview.innerHTML = `
|
|
<audio controls preload="metadata" style="width:100%;">
|
|
<source src="${escHtml(src)}" type="audio/mpeg">
|
|
Your browser does not support audio.
|
|
</audio>
|
|
`;
|
|
} else {
|
|
preview.innerHTML = '<p class="preview-placeholder">Select an audio file for preview</p>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Direkter Audio-Upload im Editor-Modal
|
|
* Automatically generates a title from the filename
|
|
*/
|
|
async function handleEditorAudioUpload(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
// Check file type
|
|
const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/x-m4a', 'audio/aac', 'audio/flac'];
|
|
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i)) {
|
|
showToast(t('audio.format_error'), 'error');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
// Check file size (max 50MB)
|
|
if (file.size > 50 * 1024 * 1024) {
|
|
showToast(t('audio.size_error'), 'error');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'upload-audio');
|
|
formData.append('audio', file);
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
showToast(t('image.uploading'), 'success');
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
// Set audio path
|
|
document.getElementById('editor-input-src-audio').value = result.data.path;
|
|
updateAudioPreview();
|
|
|
|
// Generate title from filename (without extension, hyphens/underscores → spaces)
|
|
const titleInput = document.getElementById('editor-input-title-input');
|
|
if (!titleInput.value) {
|
|
const filename = result.data.name;
|
|
// Remove file extension
|
|
const nameWithoutExt = filename.replace(/\.[^.]+$/, '');
|
|
// Replace hyphens and underscores with spaces
|
|
const title = nameWithoutExt.replace(/[-_]/g, ' ');
|
|
titleInput.value = title;
|
|
}
|
|
|
|
showToast(t('audio.uploaded'), 'success');
|
|
} else {
|
|
showToast(t('toast.error_generic', { message: result.message }), 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast(t('toast.upload_error', { message: error.message }), 'error');
|
|
}
|
|
|
|
e.target.value = '';
|
|
}
|
|
|
|
// ============================================================
|
|
// SPEICHERN
|
|
// ============================================================
|
|
|
|
/**
|
|
* Generische Undo-Funktion: Stellt vorherigen Zustand wieder her
|
|
*/
|
|
async function restoreContentState(previousData, contentPage) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'save');
|
|
formData.append('page', contentPage);
|
|
formData.append('content', JSON.stringify(previousData));
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast(t('toast.undone'), 'success');
|
|
setTimeout(() => location.reload(), 500);
|
|
} else {
|
|
showToast(t('toast.error_undo', { message: result.message }), 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast(t('toast.error_generic', { message: error.message }), 'error');
|
|
}
|
|
}
|
|
|
|
function saveSection() {
|
|
const section = EditorConfig.currentSection;
|
|
const index = EditorConfig.currentSectionIndex;
|
|
const contentPage = EditorConfig.currentContentPage;
|
|
|
|
if (!section || index === null || !contentPage) return;
|
|
|
|
pushUndoState();
|
|
|
|
// Resolve type (legacy alias)
|
|
const type = section.type === 'project' ? 'card' : section.type;
|
|
const def = BlockTypes[type];
|
|
|
|
if (def) {
|
|
for (const field of (def.fields || [])) {
|
|
const value = readEditorField(field);
|
|
|
|
// For select fields with empty string meaning "no value", clean up
|
|
if (field.type === 'select' && value === '' && field.key !== 'titleTag') {
|
|
delete section[field.key];
|
|
} else if (field.type === 'checkbox' && !value) {
|
|
delete section[field.key];
|
|
} else {
|
|
section[field.key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up titleTag if no title is set
|
|
if (!section.title && section.titleTag) {
|
|
delete section.titleTag;
|
|
}
|
|
|
|
// Update in-memory data
|
|
EditorConfig.contentData[contentPage].sections[index] = section;
|
|
EditorConfig.dirtyPages.add(contentPage);
|
|
|
|
// Live-update the section in the DOM so admins see changes immediately
|
|
patchSectionInDom(index, section);
|
|
|
|
updateUndoRedoButtons();
|
|
showToast(t('toast.change_queued'), 'success');
|
|
closeModal();
|
|
}
|
|
|
|
/**
|
|
* Patch a rendered section in the DOM with new data, so the editor shows
|
|
* changes immediately without a full page reload.
|
|
*/
|
|
function patchSectionInDom(index, section) {
|
|
const sectionEl = document.querySelector('.editable-section[data-section-index="' + index + '"]');
|
|
if (!sectionEl) return;
|
|
const type = section.type === 'project' ? 'card' : section.type;
|
|
const fieldSelector = (field) => `[data-field="sections.${index}.${field}"]`;
|
|
const replaceHeadingTag = (fieldEl, tagName) => {
|
|
if (!fieldEl || !tagName) return fieldEl;
|
|
const current = fieldEl.parentElement;
|
|
if (!current || !/^H[1-6]$/.test(current.tagName) || current.tagName.toLowerCase() === tagName) return fieldEl;
|
|
const replacement = document.createElement(tagName);
|
|
replacement.innerHTML = current.innerHTML;
|
|
Array.from(current.attributes).forEach(attr => replacement.setAttribute(attr.name, attr.value));
|
|
current.replaceWith(replacement);
|
|
return replacement.querySelector('[data-field]') || replacement;
|
|
};
|
|
|
|
switch (type) {
|
|
case 'image': {
|
|
const img = sectionEl.querySelector('.block-image img');
|
|
if (img && section.src) {
|
|
img.src = section.src;
|
|
img.alt = section.alt || '';
|
|
syncAiLabel(img);
|
|
}
|
|
const caption = sectionEl.querySelector('.block-image figcaption');
|
|
if (caption) caption.textContent = section.caption || '';
|
|
break;
|
|
}
|
|
case 'heading': {
|
|
const textField = replaceHeadingTag(sectionEl.querySelector(fieldSelector('text')), section.level || 'h2');
|
|
if (textField) textField.textContent = section.text || '';
|
|
const subtitleField = sectionEl.querySelector(fieldSelector('subtitle'));
|
|
if (subtitleField) subtitleField.textContent = section.subtitle || '';
|
|
break;
|
|
}
|
|
case 'text': {
|
|
const titleField = replaceHeadingTag(sectionEl.querySelector(fieldSelector('title')), section.titleTag || 'h2');
|
|
if (titleField) titleField.textContent = section.title || '';
|
|
const contentField = sectionEl.querySelector(fieldSelector('content'));
|
|
if (contentField && section.content !== undefined) contentField.innerHTML = section.content;
|
|
break;
|
|
}
|
|
case 'quote': {
|
|
const quoteText = sectionEl.querySelector('.block-quote__text');
|
|
if (quoteText && section.text !== undefined) quoteText.textContent = section.text;
|
|
const attr = sectionEl.querySelector('.block-quote__attribution');
|
|
if (attr) attr.textContent = section.attribution || '';
|
|
break;
|
|
}
|
|
// For complex block types (card, list, etc.) a partial DOM patch would
|
|
// duplicate renderer logic. Full reload happens after the global "Save"
|
|
// admin button anyway, which is already persisted in dirtyPages.
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// TOAST NACHRICHTEN
|
|
// ============================================================
|
|
|
|
let toastTimeout = null;
|
|
|
|
function showToast(message, type = 'success') {
|
|
const existing = document.querySelector('.editor-toast');
|
|
if (existing) existing.remove();
|
|
if (toastTimeout) clearTimeout(toastTimeout);
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `editor-toast editor-toast-${type}`;
|
|
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
|
toast.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
|
|
toast.setAttribute('aria-atomic', 'true');
|
|
toast.innerHTML = `<span class="toast-message">${message}</span>`;
|
|
toastTimeout = setTimeout(() => toast.remove(), 3000);
|
|
|
|
document.body.appendChild(toast);
|
|
}
|
|
|
|
// ============================================================
|
|
// EVENT-EDITOR
|
|
// ============================================================
|
|
|
|
function createEventEditorUI() {
|
|
const langs = Object.entries(EditorConfig.languages);
|
|
const defaultLang = EditorConfig.defaultLang;
|
|
const isMultiLang = langs.length > 1;
|
|
|
|
// Build language tabs
|
|
const tabsHtml = isMultiLang ? langs.map(([code, name], i) => {
|
|
const isDefault = code === defaultLang;
|
|
const active = isDefault ? ' active' : '';
|
|
return `<button type="button" class="event-lang-tab editor-lang-tab${active}" role="tab" aria-selected="${isDefault ? 'true' : 'false'}" tabindex="${isDefault ? '0' : '-1'}" data-lang="${code}">${escHtml(languageLabel(code))}${isDefault ? ' ★' : ''}</button>`;
|
|
}).join('') : '';
|
|
|
|
// Build language panels (translatable fields)
|
|
const panelsHtml = langs.map(([code, name]) => {
|
|
const isDefault = code === defaultLang;
|
|
const display = !isMultiLang || isDefault ? '' : ' hidden';
|
|
const reqMark = isDefault ? ' *' : '';
|
|
return `
|
|
<div class="event-lang-panel editor-lang-panel${isDefault ? ' active' : ''}" role="tabpanel" data-lang="${code}"${display}>
|
|
<div class="editor-field" data-editor-lang="${code}" data-editor-lang-field="title">
|
|
<label>${t('event.title')}${reqMark}</label>
|
|
<input type="text" id="event-title-${code}"${isDefault ? ' required' : ''}>
|
|
</div>
|
|
<div class="editor-field" data-editor-lang="${code}" data-editor-lang-field="location">
|
|
<label>${t('event.location')}</label>
|
|
<input type="text" id="event-location-${code}">
|
|
</div>
|
|
<div class="editor-field" data-editor-lang="${code}" data-editor-lang-field="description">
|
|
<label>${t('event.description')}</label>
|
|
<textarea id="event-description-${code}" rows="3"></textarea>
|
|
</div>
|
|
<div class="editor-field" data-editor-lang="${code}" data-editor-lang-field="admission">
|
|
<label>${t('event.admission')}</label>
|
|
<input type="text" id="event-admission-${code}">
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'event-editor-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop"></div>
|
|
<div class="editor-modal-content editor-modal-wide">
|
|
<div class="editor-modal-header">
|
|
<h3 id="event-editor-title">${t('event.edit')}</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closeEventModal()">×</button>
|
|
</div>
|
|
<div class="editor-modal-body">
|
|
<input type="hidden" id="event-id">
|
|
|
|
<div class="editor-row">
|
|
<div class="editor-field">
|
|
<label>${t('event.start_date')}</label>
|
|
<input type="date" id="event-date" required>
|
|
</div>
|
|
<div class="editor-field">
|
|
<label>${t('event.start_time')}</label>
|
|
<input type="time" id="event-time">
|
|
</div>
|
|
<div class="editor-field">
|
|
<label>${t('event.end_date')}</label>
|
|
<input type="date" id="event-end-date">
|
|
</div>
|
|
<div class="editor-field">
|
|
<label>${t('event.end_time')}</label>
|
|
<input type="time" id="event-end-time">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-field">
|
|
<label>${t('event.url')}</label>
|
|
<input type="url" id="event-url" placeholder="https://...">
|
|
</div>
|
|
|
|
<div class="editor-field">
|
|
<label>${t('event.image')}</label>
|
|
<div class="editor-image-row">
|
|
<input type="text" id="event-image" placeholder="...">
|
|
<button type="button" class="editor-btn editor-btn-secondary editor-btn-inline" onclick="InlineEditor.openImageManagerForEvent()">
|
|
${Icons.folder} ${t('image_manager')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="event-lang-section editor-lang-section" data-event-lang-section>
|
|
${isMultiLang ? `<div class="event-lang-tabs editor-lang-header">
|
|
<div class="editor-lang-tabs" role="tablist" aria-label="${escHtml(tFallback('editor.language_tabs', 'Languages'))}">
|
|
${tabsHtml}
|
|
</div>
|
|
${EditorConfig.aiFeaturesEnabled ? `<button type="button" class="editor-lang-translate-btn" data-event-lang-translate>${escHtml(tFallback('editor.translate_from_active', 'Translate from active language'))}</button>` : ''}
|
|
<button type="button" class="event-lang-copy-btn" id="event-copy-default" title="${t('event.copy_to_all')}">
|
|
${Icons.copy || '⧉'} ${t('event.copy_to_all')}
|
|
</button>
|
|
</div>` : ''}
|
|
${panelsHtml}
|
|
</div>
|
|
</div>
|
|
<div class="editor-modal-footer">
|
|
<button type="button" class="editor-btn editor-btn-danger" id="event-delete-btn" onclick="InlineEditor.deleteEvent()" style="margin-right:auto;">${t('delete')}</button>
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.closeEventModal()">${t('cancel')}</button>
|
|
<button type="button" class="editor-btn editor-btn-primary" onclick="InlineEditor.saveEvent()">${t('save')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
// Tab switching
|
|
modal.querySelectorAll('.event-lang-tab').forEach((tab, index) => {
|
|
const activateEventLang = (targetTab, focus = false) => {
|
|
modal.querySelectorAll('.event-lang-tab').forEach(t => {
|
|
const active = t === targetTab;
|
|
t.classList.toggle('active', active);
|
|
t.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
t.tabIndex = active ? 0 : -1;
|
|
if (active && focus) t.focus();
|
|
});
|
|
const lang = targetTab.dataset.lang;
|
|
modal.querySelectorAll('.event-lang-panel').forEach(p => {
|
|
const active = p.dataset.lang === lang;
|
|
p.hidden = !active;
|
|
p.classList.toggle('active', active);
|
|
});
|
|
};
|
|
tab.addEventListener('click', () => activateEventLang(tab));
|
|
tab.addEventListener('keydown', e => {
|
|
const tabs = Array.from(modal.querySelectorAll('.event-lang-tab'));
|
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
|
|
e.preventDefault();
|
|
let next = index;
|
|
if (e.key === 'ArrowLeft') next = index === 0 ? tabs.length - 1 : index - 1;
|
|
if (e.key === 'ArrowRight') next = index === tabs.length - 1 ? 0 : index + 1;
|
|
if (e.key === 'Home') next = 0;
|
|
if (e.key === 'End') next = tabs.length - 1;
|
|
activateEventLang(tabs[next], true);
|
|
});
|
|
});
|
|
|
|
// Copy default language to all others
|
|
document.getElementById('event-copy-default')?.addEventListener('click', () => {
|
|
const dl = defaultLang;
|
|
const fields = ['title', 'location', 'description', 'admission'];
|
|
const otherLangs = langs.map(([c]) => c).filter(c => c !== dl);
|
|
fields.forEach(field => {
|
|
const val = document.getElementById(`event-${field}-${dl}`)?.value || '';
|
|
otherLangs.forEach(lang => {
|
|
const el = document.getElementById(`event-${field}-${lang}`);
|
|
if (el && !el.value) el.value = val;
|
|
});
|
|
});
|
|
showToast(t('toast.copied_to_empty'), 'success');
|
|
});
|
|
modal.querySelector('[data-event-lang-translate]')?.addEventListener('click', function() {
|
|
translateEditorLanguageSection(modal.querySelector('[data-event-lang-section]'), this);
|
|
});
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', closeEventModal);
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modal.classList.contains('active')) {
|
|
closeEventModal();
|
|
}
|
|
});
|
|
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
}
|
|
|
|
function attachEventEditHandlers() {
|
|
document.querySelectorAll('.event-card[data-event-id]').forEach(card => {
|
|
const eventId = card.dataset.eventId;
|
|
if (!eventId) return;
|
|
|
|
card.querySelectorAll('.event-edit-btn, .event-hide-btn, .event-delete-btn').forEach(btn => {
|
|
if (btn.dataset.eventButtonReady === 'true') return;
|
|
btn.dataset.eventButtonReady = 'true';
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (btn.classList.contains('event-edit-btn')) {
|
|
openEventEditor(eventId);
|
|
} else if (btn.classList.contains('event-hide-btn')) {
|
|
toggleEventVisibility(eventId);
|
|
} else if (btn.classList.contains('event-delete-btn')) {
|
|
deleteEvent(eventId);
|
|
}
|
|
});
|
|
});
|
|
|
|
if (card.dataset.eventCardReady === 'true') return;
|
|
card.dataset.eventCardReady = 'true';
|
|
card.addEventListener('click', (e) => {
|
|
if (!EditorConfig.editMode) return;
|
|
if (e.target.closest('a')) return;
|
|
if (e.target.closest('.event-edit-buttons')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openEventEditor(eventId);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// FOOTER EDITING
|
|
// ============================================================
|
|
|
|
function attachFooterEditHandlers() {
|
|
const footerFields = document.querySelectorAll('.editable-footer-field');
|
|
if (footerFields.length === 0) return;
|
|
|
|
footerFields.forEach(field => {
|
|
field.classList.add('editable-footer-active');
|
|
|
|
field.addEventListener('click', (e) => {
|
|
if (!EditorConfig.editMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openFooterFieldEditor(field);
|
|
});
|
|
});
|
|
}
|
|
|
|
function openFooterFieldEditor(field) {
|
|
const fieldName = field.dataset.field;
|
|
const lang = field.dataset.lang;
|
|
const linkHref = field.dataset.linkHref;
|
|
|
|
// Link fields: edit text + href
|
|
if (fieldName === 'credit.link') {
|
|
const currentText = field.textContent.trim();
|
|
const currentHref = linkHref || field.getAttribute('href') || '';
|
|
showPromptDialog(t('footer.edit'), t('footer.link_text'), currentText, (newText) => {
|
|
if (newText === null) return;
|
|
showPromptDialog(t('footer.edit'), t('footer.link_url'), currentHref, (newHref) => {
|
|
if (newHref === null) return;
|
|
saveFooterLinkField('credit', { linkText: newText, link: newHref }, field);
|
|
field.textContent = newText;
|
|
field.href = newHref;
|
|
field.dataset.linkHref = newHref;
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Copyright field: edit raw shortcode text
|
|
if (fieldName === 'copyright') {
|
|
// Get the raw shortcode text from JSON (innerHTML contains rendered HTML)
|
|
const footerData = EditorConfig.contentData['footer'] || {};
|
|
const currentRaw = footerData.copyright || field.innerHTML
|
|
.replace(/<span\s+id="([^"]*)"[^>]*>(.*?)<\/span>/g, '[id="$1"]$2[/id]')
|
|
.replace(/<span\s+class="([^"]*)"[^>]*>(.*?)<\/span>/g, '[class="$1"]$2[/class]');
|
|
showPromptDialog(t('footer.edit'), t('footer.copyright'), currentRaw, (newValue) => {
|
|
if (newValue !== null && newValue !== currentRaw) {
|
|
saveFooterFieldDirect('copyright', newValue, field);
|
|
// Render shortcodes to HTML for display
|
|
field.innerHTML = newValue
|
|
.replace(/\[id="([^"]*)"\](.*?)\[\/id\]/g, '<span id="$1">$2</span>')
|
|
.replace(/\[class="([^"]*)"\](.*?)\[\/class\]/g, '<span class="$1">$2</span>');
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Simple text fields (with or without lang key)
|
|
const currentValue = field.textContent.trim();
|
|
const label = getFooterFieldLabel(fieldName);
|
|
showPromptDialog(t('footer.edit'), label, currentValue, (newValue) => {
|
|
if (newValue !== null && newValue !== currentValue) {
|
|
saveFooterField(fieldName, lang, newValue, field);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getFooterFieldLabel(fieldName) {
|
|
const labels = {
|
|
'tagline': 'Tagline',
|
|
'services': 'Services',
|
|
'claim': 'Claim',
|
|
'contact.phone': 'Telefonnummer',
|
|
'contact.email': 'E-Mail-Adresse',
|
|
'credit.text': 'Credit text',
|
|
'contactHeading': 'Contact heading',
|
|
'legalHeading': 'Legal heading'
|
|
};
|
|
return labels[fieldName] || fieldName;
|
|
}
|
|
|
|
function saveFooterField(fieldName, lang, newValue, element) {
|
|
pushUndoState();
|
|
|
|
if (!EditorConfig.contentData['footer']) EditorConfig.contentData['footer'] = {};
|
|
const footerData = EditorConfig.contentData['footer'];
|
|
|
|
// Fields with dot notation but no lang (e.g. contact.phone, credit.text)
|
|
if (fieldName.includes('.')) {
|
|
const parts = fieldName.split('.');
|
|
if (!footerData[parts[0]]) footerData[parts[0]] = {};
|
|
footerData[parts[0]][parts[1]] = newValue;
|
|
} else if (lang) {
|
|
// Lang-dependent fields (tagline, services, claim, contactHeading, legalHeading)
|
|
if (!footerData[fieldName]) footerData[fieldName] = {};
|
|
footerData[fieldName][lang] = newValue;
|
|
} else {
|
|
// Plain flat fields
|
|
footerData[fieldName] = newValue;
|
|
}
|
|
|
|
EditorConfig.dirtyPages.add('footer');
|
|
|
|
// Update DOM
|
|
element.textContent = newValue;
|
|
if (fieldName === 'contact.email' && element.tagName === 'A') {
|
|
element.href = 'mailto:' + newValue;
|
|
}
|
|
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
// Save credit link (text + href stored as credit.linkText / credit.link)
|
|
function saveFooterLinkField(prefix, values, element) {
|
|
pushUndoState();
|
|
|
|
if (!EditorConfig.contentData['footer']) EditorConfig.contentData['footer'] = {};
|
|
const footerData = EditorConfig.contentData['footer'];
|
|
if (!footerData[prefix]) footerData[prefix] = {};
|
|
Object.assign(footerData[prefix], values);
|
|
|
|
EditorConfig.dirtyPages.add('footer');
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
// Save legal link (lang-dependent text + flat href)
|
|
// Save a direct (non-nested, non-lang) footer field
|
|
function saveFooterFieldDirect(fieldName, newValue, element) {
|
|
pushUndoState();
|
|
|
|
if (!EditorConfig.contentData['footer']) EditorConfig.contentData['footer'] = {};
|
|
EditorConfig.contentData['footer'][fieldName] = newValue;
|
|
|
|
EditorConfig.dirtyPages.add('footer');
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
// ============================================================
|
|
// EDITABLE FIELDS FOR CUSTOM LAYOUTS
|
|
// ============================================================
|
|
|
|
function attachFieldEditHandlers() {
|
|
const fields = document.querySelectorAll('.editable-field');
|
|
if (fields.length === 0) return;
|
|
|
|
fields.forEach(field => {
|
|
if (field.dataset.editorReady === 'true') return;
|
|
field.dataset.editorReady = 'true';
|
|
field.classList.add('editable-field-active');
|
|
applyBlockHitboxHint(field);
|
|
|
|
// Add hide toggle button
|
|
addFieldHideButton(field);
|
|
|
|
if (field.classList.contains('editable-field-html')) {
|
|
field.addEventListener('click', (e) => {
|
|
if (!EditorConfig.editMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (field.isContentEditable) return;
|
|
startInlineHtmlEdit(field);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Plain text fields: inline editing with contentEditable
|
|
field.addEventListener('click', (e) => {
|
|
if (!EditorConfig.editMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (field.isContentEditable) return;
|
|
startInlineEdit(field);
|
|
});
|
|
});
|
|
}
|
|
|
|
function applyBlockHitboxHint(field) {
|
|
if (!field.closest('[data-editable-group], [data-list-index]')) return;
|
|
const parentTag = (field.parentElement && field.parentElement.tagName || '').toLowerCase();
|
|
const fieldTag = (field.tagName || '').toLowerCase();
|
|
const text = (field.textContent || '').trim();
|
|
if (
|
|
field.classList.contains('editable-field-html') ||
|
|
fieldTag === 'p' ||
|
|
/^(p|li|blockquote|figcaption|h[1-6])$/.test(parentTag) ||
|
|
text.length > 38
|
|
) {
|
|
field.classList.add('editable-field-block-hitbox');
|
|
}
|
|
}
|
|
|
|
function addFieldHideButton(field) {
|
|
// Skip fields inside comparison table (rows handle hiding at row level)
|
|
if (field.closest('.comparison-table')) return;
|
|
|
|
const isHidden = field.dataset.hidden === 'true';
|
|
// Field-level hiding is intentionally quiet by default. It created too
|
|
// much chrome on every heading and text span, while the common editorial
|
|
// use case is hiding whole sections or list items. Keep an escape hatch
|
|
// for already-hidden fields so editors can make legacy content visible.
|
|
if (!isHidden && field.dataset.allowFieldHide !== 'true') return;
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'field-hide-btn';
|
|
btn.title = isHidden ? t('show') : t('hide');
|
|
btn.innerHTML = isHidden ? Icons.eyeClosed : Icons.eyeOpen;
|
|
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!EditorConfig.editMode) return;
|
|
toggleFieldHidden(field, btn);
|
|
});
|
|
|
|
// Wrap field in a relative container for positioning
|
|
const parent = field.parentNode;
|
|
const wrapper = document.createElement('span');
|
|
wrapper.className = 'editable-field-wrapper';
|
|
parent.insertBefore(wrapper, field);
|
|
wrapper.appendChild(field);
|
|
wrapper.appendChild(btn);
|
|
}
|
|
|
|
function toggleFieldHidden(field, btn) {
|
|
const page = field.dataset.page;
|
|
const fieldKey = field.dataset.field;
|
|
if (!page || !fieldKey) return;
|
|
|
|
pushUndoState();
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
const hiddenKey = fieldKey + '__hidden';
|
|
const currentlyHidden = getNestedValue(pageData, hiddenKey) === true;
|
|
const nowHidden = !currentlyHidden;
|
|
|
|
if (nowHidden) {
|
|
setNestedValue(pageData, hiddenKey, true);
|
|
field.dataset.hidden = 'true';
|
|
} else {
|
|
// Remove the __hidden key entirely when showing
|
|
deleteNestedValue(pageData, hiddenKey);
|
|
delete field.dataset.hidden;
|
|
}
|
|
|
|
btn.innerHTML = nowHidden ? Icons.eyeClosed : Icons.eyeOpen;
|
|
btn.title = nowHidden ? t('show') : t('hide');
|
|
|
|
EditorConfig.dirtyPages.add(page);
|
|
updateUndoRedoButtons();
|
|
showToast(nowHidden ? t('toast.hidden') : t('toast.visible'), 'success');
|
|
}
|
|
|
|
function startInlineEdit(field) {
|
|
const originalText = field.textContent;
|
|
field._originalText = originalText;
|
|
field.contentEditable = 'true';
|
|
field.classList.add('editable-field-editing');
|
|
field.focus();
|
|
|
|
// Text komplett markieren
|
|
const range = document.createRange();
|
|
range.selectNodeContents(field);
|
|
const sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
|
|
const keyHandler = (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
finishInlineEdit(field, originalText);
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
cancelInlineEdit(field, originalText);
|
|
}
|
|
};
|
|
|
|
const blurHandler = () => {
|
|
// Short delay so overlay clicks are not treated as blur
|
|
setTimeout(() => {
|
|
if (field.isContentEditable) {
|
|
finishInlineEdit(field, originalText);
|
|
}
|
|
}, 150);
|
|
};
|
|
|
|
field.addEventListener('keydown', keyHandler);
|
|
field.addEventListener('blur', blurHandler);
|
|
field._inlineEditHandlers = { keyHandler, blurHandler };
|
|
}
|
|
|
|
function finishInlineEdit(field, originalText) {
|
|
const newText = field.textContent.trim();
|
|
field.contentEditable = 'false';
|
|
field.classList.remove('editable-field-editing');
|
|
cleanupInlineHandlers(field);
|
|
|
|
if (newText !== originalText && newText !== '') {
|
|
saveField(field.dataset.page, field.dataset.field, newText, field, false);
|
|
} else if (newText === '') {
|
|
field.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
function cancelInlineEdit(field, originalText) {
|
|
field.textContent = originalText;
|
|
field.contentEditable = 'false';
|
|
field.classList.remove('editable-field-editing');
|
|
cleanupInlineHandlers(field);
|
|
}
|
|
|
|
function cleanupInlineHandlers(field) {
|
|
if (field._inlineEditHandlers) {
|
|
field.removeEventListener('keydown', field._inlineEditHandlers.keyHandler);
|
|
field.removeEventListener('blur', field._inlineEditHandlers.blurHandler);
|
|
delete field._inlineEditHandlers;
|
|
}
|
|
delete field._originalText;
|
|
}
|
|
|
|
function openFieldHtmlEditor(field) {
|
|
const fieldKey = field.dataset.field;
|
|
const page = field.dataset.page;
|
|
const currentValue = field.innerHTML.trim();
|
|
const label = fieldKey.split('.').pop();
|
|
|
|
createHtmlSourceDialog();
|
|
|
|
const titleEl = document.getElementById('html-source-dialog-title');
|
|
const textarea = document.getElementById('html-source-textarea');
|
|
const modal = document.getElementById('html-source-dialog-modal');
|
|
|
|
titleEl.textContent = `HTML — ${label}`;
|
|
textarea.value = formatHtml(currentValue);
|
|
|
|
_htmlSourceCallback = (newValue) => {
|
|
if (newValue !== null && newValue !== currentValue) {
|
|
saveField(page, fieldKey, newValue, field, true);
|
|
}
|
|
};
|
|
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
setTimeout(() => {
|
|
textarea.focus();
|
|
textarea.setSelectionRange(0, 0);
|
|
}, 100);
|
|
}
|
|
|
|
var _htmlSourceCallback = null;
|
|
|
|
function createHtmlSourceDialog() {
|
|
if (document.getElementById('html-source-dialog-modal')) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'html-source-dialog-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop"></div>
|
|
<div class="editor-modal-content editor-modal-wide">
|
|
<div class="editor-modal-header">
|
|
<h3 id="html-source-dialog-title">HTML</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closeHtmlSourceDialog(null)">×</button>
|
|
</div>
|
|
<div class="editor-modal-body" style="flex:1; display:flex; flex-direction:column; overflow:hidden;">
|
|
<textarea id="html-source-textarea" class="html-source-textarea" spellcheck="false"></textarea>
|
|
</div>
|
|
<div class="editor-modal-footer">
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.closeHtmlSourceDialog(null)">${t('cancel') || 'Cancel'}</button>
|
|
<button type="button" class="editor-btn editor-btn-primary" onclick="InlineEditor.submitHtmlSourceDialog()">OK</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', () => closeHtmlSourceDialog(null));
|
|
|
|
// Ctrl+Enter to confirm
|
|
document.getElementById('html-source-textarea').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
submitHtmlSourceDialog();
|
|
}
|
|
// Tab inserts spaces instead of moving focus
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const ta = e.target;
|
|
const start = ta.selectionStart;
|
|
const end = ta.selectionEnd;
|
|
ta.value = ta.value.substring(0, start) + ' ' + ta.value.substring(end);
|
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
|
}
|
|
});
|
|
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
}
|
|
|
|
function closeHtmlSourceDialog(value) {
|
|
const modal = document.getElementById('html-source-dialog-modal');
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
|
|
if (_htmlSourceCallback) {
|
|
const cb = _htmlSourceCallback;
|
|
_htmlSourceCallback = null;
|
|
cb(value);
|
|
}
|
|
}
|
|
|
|
function submitHtmlSourceDialog() {
|
|
const value = document.getElementById('html-source-textarea').value;
|
|
closeHtmlSourceDialog(value);
|
|
}
|
|
|
|
function formatHtml(html) {
|
|
// Simple HTML formatter for readability in the source editor
|
|
let formatted = '';
|
|
let indent = 0;
|
|
const tokens = html.replace(/>\s*</g, '>\n<').split('\n');
|
|
|
|
for (let token of tokens) {
|
|
token = token.trim();
|
|
if (!token) continue;
|
|
|
|
// Closing tag or self-closing: decrease indent first
|
|
if (/^<\//.test(token)) {
|
|
indent = Math.max(0, indent - 1);
|
|
}
|
|
|
|
formatted += ' '.repeat(indent) + token + '\n';
|
|
|
|
// Opening tag (not self-closing, not void): increase indent
|
|
if (/^<[a-z][^\/]*>$/i.test(token) && !/^<(br|hr|img|input|meta|link|source)\b/i.test(token)) {
|
|
indent++;
|
|
}
|
|
}
|
|
return formatted.trim();
|
|
}
|
|
|
|
function setNestedValue(obj, dotKey, value) {
|
|
const keys = dotKey.split('.');
|
|
let current = obj;
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
const key = keys[i];
|
|
if (!current[key] || typeof current[key] !== 'object') {
|
|
current[key] = {};
|
|
}
|
|
current = current[key];
|
|
}
|
|
current[keys[keys.length - 1]] = value;
|
|
}
|
|
|
|
function deleteNestedValue(obj, dotKey) {
|
|
const keys = dotKey.split('.');
|
|
let current = obj;
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
if (!current[keys[i]] || typeof current[keys[i]] !== 'object') return;
|
|
current = current[keys[i]];
|
|
}
|
|
delete current[keys[keys.length - 1]];
|
|
}
|
|
|
|
function saveField(page, fieldKey, newValue, element, isHtml) {
|
|
// Push undo state before making the change
|
|
pushUndoState();
|
|
|
|
// Update in-memory contentData
|
|
if (!EditorConfig.contentData[page]) EditorConfig.contentData[page] = {};
|
|
setNestedValue(EditorConfig.contentData[page], fieldKey, newValue);
|
|
|
|
// Mark page as dirty
|
|
EditorConfig.dirtyPages.add(page);
|
|
|
|
// Update DOM immediately
|
|
if (isHtml) {
|
|
element.innerHTML = newValue;
|
|
} else {
|
|
element.textContent = newValue;
|
|
}
|
|
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
// ============================================================
|
|
// EDITABLE LINKS FOR CUSTOM LAYOUTS
|
|
// ============================================================
|
|
|
|
function attachLinkEditHandlers() {
|
|
const links = document.querySelectorAll('[data-editable-link]');
|
|
if (links.length === 0) return;
|
|
|
|
links.forEach(link => {
|
|
if (link.dataset.editorReady === 'true') return;
|
|
link.dataset.editorReady = 'true';
|
|
link.classList.add('editable-field-active');
|
|
if (link.closest('[data-editable-group], [data-list-index]')) {
|
|
link.classList.add('editable-field-block-hitbox');
|
|
}
|
|
|
|
// Add hide toggle button
|
|
addFieldHideButton(link);
|
|
|
|
link.addEventListener('click', (e) => {
|
|
if (!EditorConfig.editMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openLinkEditor(link);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds HTML for a page picker: dropdown for internal pages + URL input.
|
|
*/
|
|
function buildPagePickerHtml(selectId, inputId) {
|
|
const pages = window.RJ_PAGES || [];
|
|
let options = `<option value="">${t('link_external') || 'Custom'}</option>`;
|
|
if (pages.length) {
|
|
options += `<option disabled value="">──── ${t('link_internal') || 'Site pages'} ────</option>`;
|
|
pages.forEach(p => {
|
|
options += `<option value="${p.href}">${p.title}</option>`;
|
|
});
|
|
}
|
|
return `
|
|
<select class="prompt-dialog-input link-page-select" id="${selectId}">
|
|
${options}
|
|
</select>
|
|
<input type="text" class="prompt-dialog-input" id="${inputId}" placeholder="https://..." style="margin-top: 8px;">
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Wires up page-select ↔ URL-input sync after DOM insertion.
|
|
*/
|
|
function initPagePicker(selectId, inputId) {
|
|
const select = document.getElementById(selectId);
|
|
const input = document.getElementById(inputId);
|
|
if (!select || !input) return;
|
|
|
|
select.addEventListener('change', () => {
|
|
if (select.value) {
|
|
input.value = select.value;
|
|
input.readOnly = true;
|
|
input.style.opacity = '0.6';
|
|
} else {
|
|
input.value = '';
|
|
input.readOnly = false;
|
|
input.style.opacity = '1';
|
|
input.focus();
|
|
}
|
|
});
|
|
|
|
// On input change, reset select if user types a custom URL
|
|
input.addEventListener('input', () => {
|
|
if (!input.readOnly) {
|
|
select.value = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Syncs page picker state when modal opens with an existing URL.
|
|
*/
|
|
function syncPagePicker(selectId, inputId, currentHref) {
|
|
const select = document.getElementById(selectId);
|
|
const input = document.getElementById(inputId);
|
|
if (!select || !input) return;
|
|
|
|
input.value = currentHref;
|
|
|
|
// Check if currentHref matches a page
|
|
const pages = window.RJ_PAGES || [];
|
|
const match = pages.find(p => p.href === currentHref);
|
|
if (match) {
|
|
select.value = match.href;
|
|
input.readOnly = true;
|
|
input.style.opacity = '0.6';
|
|
} else {
|
|
select.value = '';
|
|
input.readOnly = false;
|
|
input.style.opacity = '1';
|
|
}
|
|
}
|
|
|
|
function openLinkEditor(element) {
|
|
const page = element.dataset.page;
|
|
const fieldKey = element.dataset.field;
|
|
const currentText = element.textContent.trim();
|
|
const currentHref = element.getAttribute('href') || '';
|
|
const currentTarget = element.getAttribute('target') || '';
|
|
const currentDownload = element.hasAttribute('download');
|
|
|
|
createLinkEditorDialog();
|
|
initPagePicker('link-editor-page-select', 'link-editor-href');
|
|
|
|
document.getElementById('link-editor-text').value = currentText;
|
|
syncPagePicker('link-editor-page-select', 'link-editor-href', currentHref);
|
|
document.getElementById('link-editor-target').checked = currentTarget === '_blank';
|
|
document.getElementById('link-editor-download').checked = currentDownload;
|
|
updateDownloadCheckboxState();
|
|
|
|
const modal = document.getElementById('link-editor-modal');
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
// Store reference for save
|
|
modal._editTarget = { element, page, fieldKey };
|
|
|
|
setTimeout(() => {
|
|
document.getElementById('link-editor-text').focus();
|
|
}, 100);
|
|
}
|
|
|
|
// Files that typically warrant a "download" attribute on links
|
|
const DOWNLOAD_EXTENSIONS = [
|
|
// Documents
|
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
|
'odt', 'ods', 'odp', 'rtf', 'txt', 'csv',
|
|
// Images
|
|
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff', 'avif',
|
|
// Archives
|
|
'zip', 'rar', '7z', 'tar', 'gz'
|
|
];
|
|
|
|
function isDownloadableHref(href) {
|
|
if (!href) return false;
|
|
// Strip query string and hash, then extract extension
|
|
const cleaned = href.split('#')[0].split('?')[0];
|
|
const match = cleaned.match(/\.([a-z0-9]+)$/i);
|
|
if (!match) return false;
|
|
return DOWNLOAD_EXTENSIONS.includes(match[1].toLowerCase());
|
|
}
|
|
|
|
function updateDownloadCheckboxState() {
|
|
const hrefInput = document.getElementById('link-editor-href');
|
|
const downloadCheckbox = document.getElementById('link-editor-download');
|
|
const downloadRow = document.getElementById('link-editor-download-row');
|
|
if (!hrefInput || !downloadCheckbox || !downloadRow) return;
|
|
|
|
const allowed = isDownloadableHref(hrefInput.value);
|
|
downloadCheckbox.disabled = !allowed;
|
|
downloadRow.classList.toggle('link-editor-option--disabled', !allowed);
|
|
if (!allowed) downloadCheckbox.checked = false;
|
|
}
|
|
|
|
function createLinkEditorDialog() {
|
|
if (document.getElementById('link-editor-modal')) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'link-editor-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop"></div>
|
|
<div class="editor-modal-content editor-modal-small">
|
|
<div class="editor-modal-header">
|
|
<h3>${formatEditorTitle('Link')}</h3>
|
|
<button type="button" class="editor-close-btn" id="link-editor-cancel-btn">×</button>
|
|
</div>
|
|
<div class="editor-modal-body">
|
|
<div class="prompt-dialog-content">
|
|
<label class="prompt-dialog-label">Text</label>
|
|
<input type="text" class="prompt-dialog-input" id="link-editor-text">
|
|
<label class="prompt-dialog-label" style="margin-top: 12px;">${t('link_target') || 'Link target'}</label>
|
|
${buildPagePickerHtml('link-editor-page-select', 'link-editor-href')}
|
|
<div class="link-editor-options">
|
|
<label class="link-editor-option">
|
|
<input type="checkbox" id="link-editor-target">
|
|
<span>${t('link_open_new_tab') || 'Open in new tab'}</span>
|
|
</label>
|
|
<label class="link-editor-option" id="link-editor-download-row">
|
|
<input type="checkbox" id="link-editor-download">
|
|
<span>${t('link_offer_download') || 'Offer as download'}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="editor-modal-footer">
|
|
<button type="button" class="editor-btn editor-btn-secondary" id="link-editor-cancel-btn2">${t('cancel')}</button>
|
|
<button type="button" class="editor-btn editor-btn-primary" id="link-editor-save-btn">${t('save')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', closeLinkEditor);
|
|
document.getElementById('link-editor-cancel-btn').addEventListener('click', closeLinkEditor);
|
|
document.getElementById('link-editor-cancel-btn2').addEventListener('click', closeLinkEditor);
|
|
document.getElementById('link-editor-save-btn').addEventListener('click', saveLinkEditor);
|
|
|
|
document.getElementById('link-editor-href').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
saveLinkEditor();
|
|
}
|
|
});
|
|
|
|
// Keep the download checkbox in sync with the href type
|
|
document.getElementById('link-editor-href').addEventListener('input', updateDownloadCheckboxState);
|
|
document.getElementById('link-editor-page-select').addEventListener('change', () => {
|
|
// change handler on select already updates the hidden href value; wait a tick
|
|
setTimeout(updateDownloadCheckboxState, 0);
|
|
});
|
|
}
|
|
|
|
function closeLinkEditor() {
|
|
const modal = document.getElementById('link-editor-modal');
|
|
if (modal) {
|
|
modal.classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
// Reset text input state
|
|
const textInput = document.getElementById('link-editor-text');
|
|
if (textInput) { textInput.readOnly = false; textInput.style.opacity = '1'; }
|
|
modal._inlineInsert = null;
|
|
}
|
|
}
|
|
|
|
function saveLinkEditor() {
|
|
const modal = document.getElementById('link-editor-modal');
|
|
const inlineInsert = modal._inlineInsert;
|
|
const editTarget = modal._editTarget;
|
|
const newText = document.getElementById('link-editor-text').value;
|
|
const newHref = document.getElementById('link-editor-href').value;
|
|
const newTarget = document.getElementById('link-editor-target').checked ? '_blank' : '';
|
|
const downloadBox = document.getElementById('link-editor-download');
|
|
const newDownload = !downloadBox.disabled && downloadBox.checked;
|
|
|
|
// Reset text input state
|
|
const textInput = document.getElementById('link-editor-text');
|
|
textInput.readOnly = false;
|
|
textInput.style.opacity = '1';
|
|
|
|
closeLinkEditor();
|
|
|
|
// Mode: inline insert (from floating toolbar)
|
|
if (inlineInsert) {
|
|
const { field, savedRange } = inlineInsert;
|
|
if (newHref && savedRange) {
|
|
field.focus();
|
|
const sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(savedRange);
|
|
document.execCommand('createLink', false, newHref);
|
|
// Apply target/download to the freshly-created <a>. execCommand
|
|
// wraps the selection; find the anchor at the current cursor.
|
|
const anchor = sel.anchorNode && sel.anchorNode.parentElement
|
|
? sel.anchorNode.parentElement.closest('a')
|
|
: null;
|
|
if (anchor) {
|
|
if (newTarget === '_blank') {
|
|
anchor.setAttribute('target', '_blank');
|
|
anchor.setAttribute('rel', 'noopener');
|
|
}
|
|
if (newDownload) anchor.setAttribute('download', '');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Mode: editableLink field
|
|
if (!editTarget) return;
|
|
const { element, page, fieldKey } = editTarget;
|
|
|
|
// Push undo state
|
|
pushUndoState();
|
|
|
|
// Build the value object. Omit target/download when unset so the JSON
|
|
// stays minimal and existing content files aren't bloated with defaults.
|
|
const linkValue = { text: newText, href: newHref };
|
|
if (newTarget) linkValue.target = newTarget;
|
|
if (newDownload) linkValue.download = true;
|
|
|
|
// Update in-memory contentData
|
|
if (!EditorConfig.contentData[page]) EditorConfig.contentData[page] = {};
|
|
setNestedValue(EditorConfig.contentData[page], fieldKey, linkValue);
|
|
|
|
// Mark as dirty
|
|
EditorConfig.dirtyPages.add(page);
|
|
|
|
// Update DOM
|
|
element.textContent = newText;
|
|
element.setAttribute('href', newHref);
|
|
if (newTarget === '_blank') {
|
|
element.setAttribute('target', '_blank');
|
|
element.setAttribute('rel', 'noopener');
|
|
} else {
|
|
element.removeAttribute('target');
|
|
element.removeAttribute('rel');
|
|
}
|
|
if (newDownload) {
|
|
element.setAttribute('download', '');
|
|
} else {
|
|
element.removeAttribute('download');
|
|
}
|
|
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
// ============================================================
|
|
// EDITABLE IMAGES FOR CUSTOM LAYOUTS
|
|
// ============================================================
|
|
|
|
function attachImageEditHandlers() {
|
|
const images = document.querySelectorAll('[data-editable-image]');
|
|
if (images.length === 0) return;
|
|
|
|
images.forEach(img => {
|
|
if (img.dataset.editorReady === 'true') return;
|
|
img.dataset.editorReady = 'true';
|
|
// Skip images inside event cards — those are edited via the event editor modal
|
|
if (img.closest('.event-card[data-event-id]')) return;
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'editable-image-wrapper';
|
|
// Styled in inline-editor.css (not inline) so sites can adapt it. Images that
|
|
// fill their frame (object-fit) or are absolutely positioned keep doing so.
|
|
const imgStyle = window.getComputedStyle(img);
|
|
if (imgStyle.position === 'absolute') {
|
|
wrapper.classList.add('editable-image-wrapper--positioned');
|
|
} else if (imgStyle.objectFit && imgStyle.objectFit !== 'fill' && img.parentElement) {
|
|
const box = img.getBoundingClientRect();
|
|
const frame = img.parentElement.getBoundingClientRect();
|
|
if (Math.abs(box.width - frame.width) < 2 && Math.abs(box.height - frame.height) < 2) {
|
|
// A positioned frame (e.g. an aspect-ratio box) is covered absolutely, because
|
|
// percentage heights do not resolve against aspect-ratio heights
|
|
const framePosition = window.getComputedStyle(img.parentElement).position;
|
|
wrapper.classList.add(framePosition === 'static'
|
|
? 'editable-image-wrapper--fill'
|
|
: 'editable-image-wrapper--positioned');
|
|
}
|
|
}
|
|
img.parentNode.insertBefore(wrapper, img);
|
|
wrapper.appendChild(img);
|
|
|
|
wrapper.classList.add('editable-field-active');
|
|
|
|
// Add placeholder for empty images so they're visible and clickable
|
|
if (!img.getAttribute('src') || img.getAttribute('src') === '' || img.getAttribute('src') === '/') {
|
|
wrapper.classList.add('editable-image-empty');
|
|
}
|
|
img.addEventListener('error', () => wrapper.classList.add('editable-image-empty'));
|
|
img.addEventListener('load', () => wrapper.classList.remove('editable-image-empty'));
|
|
|
|
wrapper.addEventListener('click', (e) => {
|
|
if (!EditorConfig.editMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openImageFieldEditor(img);
|
|
});
|
|
});
|
|
}
|
|
|
|
function openImageFieldEditor(imgElement) {
|
|
const page = imgElement.dataset.page;
|
|
const fieldKey = imgElement.dataset.field;
|
|
const currentAlt = imgElement.getAttribute('alt') || '';
|
|
const format = imgElement.dataset.imageFormat || 'object';
|
|
|
|
openImageManager((imagePath) => {
|
|
if (format === 'string') {
|
|
// String-format images (e.g. news cover): save path directly, no alt dialog
|
|
saveImageField(page, fieldKey, imagePath, currentAlt, imgElement);
|
|
} else if (format === 'split') {
|
|
// Split-format: src and alt live at two separate keys
|
|
showPromptDialog('Alt text', 'Alt text', currentAlt, (newAlt) => {
|
|
if (newAlt === null) newAlt = currentAlt;
|
|
saveImageField(page, fieldKey, imagePath, newAlt, imgElement);
|
|
});
|
|
} else {
|
|
// Object-format images: ask for alt text, save as {src, alt}
|
|
showPromptDialog('Alt text', 'Alt text', currentAlt, (newAlt) => {
|
|
if (newAlt === null) newAlt = currentAlt;
|
|
saveImageField(page, fieldKey, imagePath, newAlt, imgElement);
|
|
});
|
|
}
|
|
}, null, imageFieldPath(imgElement));
|
|
}
|
|
|
|
// Stored path of an editable image (the library preselects it); falls back to the rendered src
|
|
function imageFieldPath(imgElement) {
|
|
const page = imgElement.dataset.page;
|
|
const fieldKey = imgElement.dataset.field;
|
|
let stored = page === '__news_post__'
|
|
? (EditorConfig.newsPostData ? EditorConfig.newsPostData[fieldKey] : undefined)
|
|
: (EditorConfig.contentData[page] ? getNestedValue(EditorConfig.contentData[page], fieldKey) : undefined);
|
|
if (stored && typeof stored === 'object') stored = stored.src;
|
|
return typeof stored === 'string' && stored ? stored : imageElementPath(imgElement);
|
|
}
|
|
|
|
// Site path of a rendered image: without origin, query and hash
|
|
function imageElementPath(img) {
|
|
const src = img ? (img.getAttribute('src') || '') : '';
|
|
if (!src || src.indexOf('data:') === 0) return null;
|
|
return src.replace(/^https?:\/\/[^/]+/i, '').split(/[?#]/)[0] || null;
|
|
}
|
|
|
|
function saveImageField(page, fieldKey, newSrc, newAlt, element) {
|
|
// Push undo state
|
|
pushUndoState();
|
|
|
|
const format = element.dataset.imageFormat || 'object';
|
|
|
|
// Update in-memory data
|
|
if (page === '__news_post__' && EditorConfig.newsPostData) {
|
|
// News post: save image path as flat string
|
|
EditorConfig.newsPostData[fieldKey] = newSrc;
|
|
} else {
|
|
if (!EditorConfig.contentData[page]) EditorConfig.contentData[page] = {};
|
|
if (format === 'string') {
|
|
setNestedValue(EditorConfig.contentData[page], fieldKey, newSrc);
|
|
} else if (format === 'split') {
|
|
// Save src and alt at separate sibling keys — preserves other
|
|
// fields in the parent object (e.g. type, caption, width).
|
|
setNestedValue(EditorConfig.contentData[page], fieldKey, newSrc);
|
|
const altFieldKey = element.dataset.altField;
|
|
if (altFieldKey) {
|
|
setNestedValue(EditorConfig.contentData[page], altFieldKey, newAlt);
|
|
}
|
|
} else {
|
|
setNestedValue(EditorConfig.contentData[page], fieldKey, { src: newSrc, alt: newAlt });
|
|
}
|
|
}
|
|
|
|
// Mark as dirty
|
|
EditorConfig.dirtyPages.add(page);
|
|
|
|
// Update DOM
|
|
element.setAttribute('src', newSrc);
|
|
element.setAttribute('alt', newAlt);
|
|
syncAiLabel(element);
|
|
// Remove empty placeholder class if image was set
|
|
const wrapper = element.closest('.editable-image-wrapper');
|
|
if (wrapper) wrapper.classList.remove('editable-image-empty');
|
|
const heroDiv = element.closest('.news-post-page__hero--empty');
|
|
if (heroDiv) heroDiv.classList.remove('news-post-page__hero--empty');
|
|
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
// ============================================================
|
|
// AI DISCLOSURE LABELS
|
|
// The label next to an AI-generated or AI-modified image follows image swaps
|
|
// and label changes in the media library right away (window.RJ_AI_LABELS).
|
|
// ============================================================
|
|
|
|
function aiLabelKindForSrc(src) {
|
|
const files = window.RJ_AI_LABELS && window.RJ_AI_LABELS.files;
|
|
if (!files || !src) return '';
|
|
let path;
|
|
try {
|
|
path = decodeURIComponent(new URL(src, location.href).pathname);
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
for (const [type, marker] of [['image', 'assets/images/'], ['video', 'assets/videos/']]) {
|
|
const position = path.indexOf(marker);
|
|
if (position === -1) continue;
|
|
const name = path.slice(position + marker.length);
|
|
return files[type] && Object.prototype.hasOwnProperty.call(files[type], name) ? files[type][name] : '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function syncAiLabel(img) {
|
|
const markup = window.RJ_AI_LABELS && window.RJ_AI_LABELS.markup;
|
|
if (!markup || !img) return;
|
|
// The label follows the image, or its editor wrapper
|
|
const anchor = img.closest('.editable-image-wrapper') || img;
|
|
const next = anchor.nextElementSibling;
|
|
const label = next && next.classList.contains('rj-ai-label') ? next : null;
|
|
const kind = aiLabelKindForSrc(img.getAttribute('src'));
|
|
if (label && kind && label.classList.contains('rj-ai-label--' + kind)) return;
|
|
if (label) label.remove();
|
|
if (kind && markup[kind]) anchor.insertAdjacentHTML('afterend', markup[kind]);
|
|
}
|
|
|
|
document.addEventListener('raiju:ai-label-change', event => {
|
|
const config = window.RJ_AI_LABELS;
|
|
const detail = event.detail || {};
|
|
if (!config || !detail.type || !detail.name) return;
|
|
if (!config.files || Array.isArray(config.files)) config.files = {};
|
|
if (!config.files[detail.type] || Array.isArray(config.files[detail.type])) config.files[detail.type] = {};
|
|
if (detail.label) config.files[detail.type][detail.name] = detail.label;
|
|
else delete config.files[detail.type][detail.name];
|
|
document.querySelectorAll('img[data-editable-image], img[data-rj-bind="image"]').forEach(syncAiLabel);
|
|
});
|
|
|
|
// ============================================================
|
|
// EDITABLE LISTS (Repeatable Items — Add/Delete/Reorder)
|
|
// ============================================================
|
|
|
|
let listDragState = {
|
|
element: null,
|
|
index: null,
|
|
page: null,
|
|
listKey: null
|
|
};
|
|
|
|
function attachListEditHandlers() {
|
|
const lists = document.querySelectorAll('[data-editable-list]');
|
|
if (lists.length === 0) return;
|
|
|
|
lists.forEach(listContainer => {
|
|
if (listContainer.dataset.editorReady === 'true') return;
|
|
listContainer.dataset.editorReady = 'true';
|
|
const page = listContainer.dataset.listPage;
|
|
const listKey = listContainer.dataset.listKey;
|
|
const defaults = JSON.parse(listContainer.dataset.listDefaults || '{}');
|
|
|
|
const items = listContainer.querySelectorAll('[data-list-index]');
|
|
|
|
items.forEach(item => {
|
|
const index = parseInt(item.dataset.listIndex, 10);
|
|
item.classList.add('editable-list-item');
|
|
|
|
// Check if list item is hidden
|
|
const pageData = EditorConfig.contentData[page];
|
|
const listItems = pageData ? getNestedObj(pageData, listKey) : null;
|
|
const itemData = listItems ? (Array.isArray(listItems) ? listItems[index] : listItems[index.toString()]) : null;
|
|
const isItemHidden = itemData && itemData.hidden === true;
|
|
|
|
// Create overlay with drag handle + hide + delete buttons
|
|
const overlay = document.createElement('div');
|
|
const hasGroupEditor = item.hasAttribute('data-editable-group');
|
|
overlay.className = 'editable-list-overlay raiju-editor-list-overlay';
|
|
overlay.innerHTML = `
|
|
${hasGroupEditor ? `<button type="button" class="overlay-btn overlay-btn-edit list-edit-btn raiju-editor-control-btn" data-editor-tooltip="${escHtml(t('edit'))}" aria-label="${escHtml(t('edit'))}" data-action="edit">${Icons.edit}</button>` : ''}
|
|
<button type="button" class="overlay-btn overlay-btn-hide list-hide-btn raiju-editor-control-btn" data-editor-tooltip="${escHtml(isItemHidden ? t('show') : t('hide'))}" aria-label="${escHtml(isItemHidden ? t('show') : t('hide'))}" data-action="hide">${isItemHidden ? Icons.eyeClosed : Icons.eyeOpen}</button>
|
|
<button type="button" class="overlay-btn overlay-btn-delete list-delete-btn raiju-editor-control-btn" data-editor-tooltip="${escHtml(t('delete'))}" aria-label="${escHtml(t('delete'))}" data-action="delete">${Icons.delete}</button>
|
|
<span class="list-drag-handle raiju-editor-drag-handle" data-editor-tooltip="${escHtml(t('drag_reorder'))}" aria-label="${escHtml(t('drag_reorder'))}">${Icons.drag}</span>
|
|
`;
|
|
item.style.position = 'relative';
|
|
item.appendChild(overlay);
|
|
keepOverlayOpenWhileCrossing(item, overlay);
|
|
|
|
// Apply hidden state
|
|
if (isItemHidden) {
|
|
item.dataset.hidden = 'true';
|
|
}
|
|
|
|
// Drag starts only from the handle; inline editing and text
|
|
// selection inside list cards remain normal clicks.
|
|
item.setAttribute('draggable', 'false');
|
|
|
|
if (hasGroupEditor) {
|
|
overlay.querySelector('.list-edit-btn').addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openGroupEditor(item);
|
|
});
|
|
}
|
|
|
|
const dragHandle = overlay.querySelector('.list-drag-handle');
|
|
if (dragHandle) {
|
|
const armDrag = () => {
|
|
item.dataset.dragArmed = 'true';
|
|
item.setAttribute('draggable', 'true');
|
|
};
|
|
const disarmDrag = () => {
|
|
delete item.dataset.dragArmed;
|
|
item.setAttribute('draggable', 'false');
|
|
};
|
|
dragHandle.addEventListener('pointerdown', armDrag);
|
|
dragHandle.addEventListener('mousedown', armDrag);
|
|
dragHandle.addEventListener('pointerup', () => setTimeout(disarmDrag, 0));
|
|
}
|
|
|
|
// Drag events — use dynamic index from dataset (survives reorder)
|
|
item.addEventListener('dragstart', (e) => {
|
|
if (!EditorConfig.editMode) { e.preventDefault(); return; }
|
|
if (item.dataset.dragArmed !== 'true') { e.preventDefault(); return; }
|
|
const currentIndex = parseInt(item.dataset.listIndex, 10);
|
|
listDragState = { element: item, index: currentIndex, page, listKey };
|
|
item.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', currentIndex.toString());
|
|
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none');
|
|
});
|
|
|
|
item.addEventListener('dragend', () => {
|
|
item.classList.remove('dragging');
|
|
document.querySelectorAll('.editable-list-item').forEach(el => {
|
|
el.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
|
|
});
|
|
listDragState = { element: null, index: null, page: null, listKey: null };
|
|
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = '');
|
|
delete item.dataset.dragArmed;
|
|
item.setAttribute('draggable', 'false');
|
|
});
|
|
|
|
item.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
if (listDragState.listKey !== listKey) return;
|
|
|
|
const rect = item.getBoundingClientRect();
|
|
const midY = rect.top + rect.height / 2;
|
|
item.classList.remove('drag-over-top', 'drag-over-bottom');
|
|
if (e.clientY < midY) {
|
|
item.classList.add('drag-over-top');
|
|
} else {
|
|
item.classList.add('drag-over-bottom');
|
|
}
|
|
});
|
|
|
|
item.addEventListener('dragenter', (e) => {
|
|
e.preventDefault();
|
|
if (listDragState.listKey === listKey) {
|
|
item.classList.add('drag-over');
|
|
}
|
|
});
|
|
|
|
item.addEventListener('dragleave', () => {
|
|
item.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
|
|
});
|
|
|
|
item.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
item.classList.remove('drag-over', 'drag-over-top', 'drag-over-bottom');
|
|
|
|
if (listDragState.listKey !== listKey) return;
|
|
|
|
const fromIndex = listDragState.index;
|
|
const currentIndex = parseInt(item.dataset.listIndex, 10);
|
|
const rect = item.getBoundingClientRect();
|
|
const midY = rect.top + rect.height / 2;
|
|
let toIndex = e.clientY < midY ? currentIndex : currentIndex + 1;
|
|
if (fromIndex < toIndex) toIndex--;
|
|
|
|
if (fromIndex === toIndex) return;
|
|
|
|
reorderListItems(page, listKey, fromIndex, toIndex);
|
|
});
|
|
|
|
// Hide button handler — read index dynamically
|
|
overlay.querySelector('.list-hide-btn').addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const currentIndex = parseInt(item.dataset.listIndex, 10);
|
|
toggleListItemHidden(page, listKey, currentIndex, item, e.currentTarget);
|
|
});
|
|
|
|
// Delete button handler — read index dynamically
|
|
overlay.querySelector('.list-delete-btn').addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const currentIndex = parseInt(item.dataset.listIndex, 10);
|
|
deleteListItem(page, listKey, currentIndex);
|
|
});
|
|
|
|
// Add button at bottom edge of each item
|
|
const addItemBtn = document.createElement('button');
|
|
addItemBtn.type = 'button';
|
|
addItemBtn.className = 'list-item-add-btn';
|
|
addItemBtn.innerHTML = Icons.plus;
|
|
addItemBtn.title = 'Add item';
|
|
addItemBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const currentIndex = parseInt(item.dataset.listIndex, 10);
|
|
addListItemAfter(page, listKey, defaults, currentIndex);
|
|
});
|
|
item.appendChild(addItemBtn);
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Comparison table row hide buttons ---
|
|
|
|
function attachComparisonRowHandlers() {
|
|
const rows = document.querySelectorAll('[data-comparison-row]');
|
|
if (rows.length === 0) return;
|
|
|
|
rows.forEach(tr => {
|
|
const rowIndex = parseInt(tr.dataset.comparisonRow, 10);
|
|
const page = tr.dataset.rowPage;
|
|
const isHidden = tr.dataset.hidden === 'true';
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'comparison-row-hide-btn';
|
|
btn.title = isHidden ? t('show') : t('hide');
|
|
btn.innerHTML = isHidden ? Icons.eyeClosed : Icons.eyeOpen;
|
|
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!EditorConfig.editMode) return;
|
|
|
|
pushUndoState();
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
const items = getNestedObj(pageData, 'comparison.rows');
|
|
if (!items) return;
|
|
|
|
const itemData = Array.isArray(items) ? items[rowIndex] : items[rowIndex.toString()];
|
|
if (!itemData) return;
|
|
|
|
const nowHidden = !itemData.hidden;
|
|
if (nowHidden) {
|
|
itemData.hidden = true;
|
|
tr.dataset.hidden = 'true';
|
|
} else {
|
|
delete itemData.hidden;
|
|
delete tr.dataset.hidden;
|
|
}
|
|
|
|
btn.innerHTML = nowHidden ? Icons.eyeClosed : Icons.eyeOpen;
|
|
btn.title = nowHidden ? t('show') : t('hide');
|
|
|
|
EditorConfig.dirtyPages.add(page);
|
|
updateUndoRedoButtons();
|
|
showToast(nowHidden ? t('toast.row_hidden') : t('toast.row_visible'), 'success');
|
|
});
|
|
|
|
// Position eye button at the left edge of the label cell
|
|
const labelCell = tr.querySelector('.comparison-table__label');
|
|
if (!labelCell) return;
|
|
labelCell.classList.add('comparison-label-with-eye');
|
|
labelCell.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// COMPARISON CELL TOGGLE BUTTONS (yes / no / text)
|
|
// ============================================================
|
|
|
|
function attachComparisonCellToggles() {
|
|
const cells = document.querySelectorAll('[data-cell-field]');
|
|
if (cells.length === 0) return;
|
|
|
|
cells.forEach(td => {
|
|
const fieldKey = td.dataset.cellField;
|
|
const page = td.dataset.cellPage;
|
|
const currentType = td.dataset.cellType; // 'yes', 'no', or 'text'
|
|
const editableSpan = td.querySelector('.editable-field');
|
|
if (!editableSpan) return;
|
|
|
|
// Create toggle button group
|
|
const group = document.createElement('div');
|
|
group.className = 'cell-toggle-group';
|
|
|
|
['yes', 'no', 'text'].forEach(mode => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'cell-toggle-btn' + (mode === currentType ? ' cell-toggle-btn--active' : '');
|
|
btn.dataset.mode = mode;
|
|
btn.textContent = mode === 'yes' ? '✓' : mode === 'no' ? '✗' : 'Aa';
|
|
btn.title = mode === 'yes' ? 'Yes (checkmark)' : mode === 'no' ? 'No (cross)' : 'Custom text';
|
|
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!EditorConfig.editMode) return;
|
|
|
|
pushUndoState();
|
|
|
|
// Update the value in contentData
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
let newValue;
|
|
if (mode === 'yes') {
|
|
newValue = 'yes';
|
|
} else if (mode === 'no') {
|
|
newValue = 'no';
|
|
} else {
|
|
// Switch to text mode — keep current value if already text, else clear
|
|
const current = getNestedValue(pageData, fieldKey);
|
|
newValue = (current === 'yes' || current === 'no') ? '' : (current || '');
|
|
}
|
|
|
|
setNestedValue(pageData, fieldKey, newValue);
|
|
editableSpan.textContent = newValue;
|
|
td.dataset.cellType = mode === 'text' ? 'text' : mode;
|
|
|
|
// Update active state on buttons
|
|
group.querySelectorAll('.cell-toggle-btn').forEach(b => {
|
|
b.classList.toggle('cell-toggle-btn--active', b.dataset.mode === mode);
|
|
});
|
|
|
|
// Show/hide the editable text span based on mode
|
|
editableSpan.classList.toggle('cell-text-hidden', mode !== 'text');
|
|
if (mode === 'text') {
|
|
// Show placeholder if empty
|
|
if (!newValue) {
|
|
editableSpan.dataset.empty = 'true';
|
|
} else {
|
|
delete editableSpan.dataset.empty;
|
|
}
|
|
editableSpan.focus();
|
|
}
|
|
|
|
// Update the icon element
|
|
const icon = td.querySelector('.cell-type-icon');
|
|
if (mode === 'text') {
|
|
if (icon) icon.remove();
|
|
} else {
|
|
if (icon) {
|
|
icon.className = 'cell-type-icon ' + (mode === 'yes' ? 'comparison-yes' : 'comparison-no');
|
|
icon.textContent = mode === 'yes' ? '✓' : '✗';
|
|
} else {
|
|
const newIcon = document.createElement('span');
|
|
newIcon.className = 'cell-type-icon ' + (mode === 'yes' ? 'comparison-yes' : 'comparison-no');
|
|
newIcon.textContent = mode === 'yes' ? '✓' : '✗';
|
|
td.insertBefore(newIcon, group);
|
|
}
|
|
}
|
|
|
|
EditorConfig.dirtyPages.add(page);
|
|
updateUndoRedoButtons();
|
|
});
|
|
|
|
group.appendChild(btn);
|
|
});
|
|
|
|
// Insert toggle group before the editable span
|
|
td.insertBefore(group, editableSpan);
|
|
|
|
// For yes/no cells, add an icon element for admin non-edit view
|
|
if (currentType === 'yes' || currentType === 'no') {
|
|
const icon = document.createElement('span');
|
|
icon.className = 'cell-type-icon ' + (currentType === 'yes' ? 'comparison-yes' : 'comparison-no');
|
|
icon.textContent = currentType === 'yes' ? '✓' : '✗';
|
|
td.insertBefore(icon, group);
|
|
}
|
|
|
|
// Mark empty text cells for placeholder
|
|
if (currentType === 'text' && !editableSpan.textContent.trim()) {
|
|
editableSpan.dataset.empty = 'true';
|
|
}
|
|
|
|
// Track empty state on input
|
|
editableSpan.addEventListener('input', () => {
|
|
if (editableSpan.textContent.trim()) {
|
|
delete editableSpan.dataset.empty;
|
|
} else {
|
|
editableSpan.dataset.empty = 'true';
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- List helpers: convert between numbered objects and arrays ---
|
|
|
|
function objectToArray(obj) {
|
|
const keys = Object.keys(obj).map(Number).filter(k => !isNaN(k)).sort((a, b) => a - b);
|
|
return keys.map(k => obj[k.toString()]);
|
|
}
|
|
|
|
function arrayToNumberedObject(arr) {
|
|
const obj = {};
|
|
arr.forEach((item, i) => { obj[i.toString()] = item; });
|
|
return obj;
|
|
}
|
|
|
|
function getNestedObj(obj, dotKey) {
|
|
const keys = dotKey.split('.');
|
|
let current = obj;
|
|
for (const key of keys) {
|
|
if (!current || typeof current !== 'object') return null;
|
|
current = current[key];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function setNestedObj(obj, dotKey, value) {
|
|
const keys = dotKey.split('.');
|
|
let current = obj;
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
if (!current[keys[i]] || typeof current[keys[i]] !== 'object') {
|
|
current[keys[i]] = {};
|
|
}
|
|
current = current[keys[i]];
|
|
}
|
|
current[keys[keys.length - 1]] = value;
|
|
}
|
|
|
|
// --- List operations (batch mode — modify in-memory, save on "Save") ---
|
|
|
|
function cloneListDefault(defaults) {
|
|
if (Array.isArray(defaults)) return [...defaults];
|
|
if (defaults && typeof defaults === 'object') return { ...defaults };
|
|
return defaults;
|
|
}
|
|
|
|
function reorderListItems(page, listKey, fromIndex, toIndex) {
|
|
pushReorderUndo('reorder-list', { page, listKey, from: fromIndex, to: toIndex });
|
|
executeListReorder(page, listKey, fromIndex, toIndex);
|
|
}
|
|
|
|
function executeListReorder(page, listKey, fromIndex, toIndex) {
|
|
const pageData = EditorConfig.contentData[page];
|
|
const items = getNestedObj(pageData, listKey);
|
|
if (!items) return;
|
|
|
|
// Update data
|
|
const arr = Array.isArray(items) ? [...items] : objectToArray(items);
|
|
const [moved] = arr.splice(fromIndex, 1);
|
|
arr.splice(toIndex, 0, moved);
|
|
setNestedObj(pageData, listKey, Array.isArray(items) ? arr : arrayToNumberedObject(arr));
|
|
|
|
EditorConfig.dirtyPages.add(page);
|
|
|
|
// Update DOM
|
|
const listContainer = document.querySelector(
|
|
`[data-editable-list][data-list-page="${page}"][data-list-key="${listKey}"]`
|
|
);
|
|
if (listContainer) {
|
|
const domItems = Array.from(listContainer.querySelectorAll(':scope > [data-list-index]'));
|
|
if (domItems[fromIndex]) {
|
|
const movedEl = domItems[fromIndex];
|
|
if (fromIndex < toIndex) {
|
|
const refEl = domItems[toIndex];
|
|
refEl.parentNode.insertBefore(movedEl, refEl.nextSibling);
|
|
} else {
|
|
const refEl = domItems[toIndex];
|
|
refEl.parentNode.insertBefore(movedEl, refEl);
|
|
}
|
|
// Update indices
|
|
listContainer.querySelectorAll(':scope > [data-list-index]').forEach((el, i) => {
|
|
el.dataset.listIndex = i;
|
|
});
|
|
}
|
|
}
|
|
|
|
showToast(t('toast.order_changed'), 'success');
|
|
}
|
|
|
|
function deleteListItem(page, listKey, index) {
|
|
showConfirmDialog(
|
|
t('item.delete'),
|
|
t('item.delete_confirm'),
|
|
t('section.reload_hint'),
|
|
() => {
|
|
pushUndoState();
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
const items = getNestedObj(pageData, listKey);
|
|
const arr = Array.isArray(items) ? [...items] : objectToArray(items);
|
|
arr.splice(index, 1);
|
|
|
|
setNestedObj(pageData, listKey, Array.isArray(items) ? arr : arrayToNumberedObject(arr));
|
|
EditorConfig.dirtyPages.add(page);
|
|
updateUndoRedoButtons();
|
|
|
|
saveStructuralChange(page, 'Item deleted');
|
|
}
|
|
);
|
|
}
|
|
|
|
function addListItem(page, listKey, defaults) {
|
|
pushUndoState();
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
const items = getNestedObj(pageData, listKey) || {};
|
|
|
|
if (Array.isArray(items)) {
|
|
items.push(cloneListDefault(defaults));
|
|
setNestedObj(pageData, listKey, items);
|
|
} else {
|
|
const keys = Object.keys(items).map(Number).filter(k => !isNaN(k));
|
|
const nextIndex = keys.length > 0 ? Math.max(...keys) + 1 : 0;
|
|
items[nextIndex.toString()] = cloneListDefault(defaults);
|
|
setNestedObj(pageData, listKey, items);
|
|
}
|
|
EditorConfig.dirtyPages.add(page);
|
|
updateUndoRedoButtons();
|
|
|
|
saveStructuralChange(page, 'Item added');
|
|
}
|
|
|
|
function addListItemAfter(page, listKey, defaults, afterIndex) {
|
|
pushUndoState();
|
|
|
|
const pageData = EditorConfig.contentData[page];
|
|
const items = getNestedObj(pageData, listKey) || {};
|
|
|
|
const itemsAreArray = Array.isArray(items);
|
|
const arr = itemsAreArray ? [...items] : objectToArray(items);
|
|
arr.splice(afterIndex + 1, 0, cloneListDefault(defaults));
|
|
|
|
setNestedObj(pageData, listKey, itemsAreArray ? arr : arrayToNumberedObject(arr));
|
|
EditorConfig.dirtyPages.add(page);
|
|
updateUndoRedoButtons();
|
|
|
|
saveStructuralChange(page, 'Item added');
|
|
}
|
|
|
|
// --- Hide / Show list items ---
|
|
|
|
function toggleListItemHidden(page, listKey, index, itemEl, btnEl) {
|
|
const pageData = EditorConfig.contentData[page];
|
|
if (!pageData) return;
|
|
|
|
const items = getNestedObj(pageData, listKey);
|
|
if (!items) return;
|
|
|
|
const itemData = Array.isArray(items) ? items[index] : items[index.toString()];
|
|
if (!itemData) return;
|
|
|
|
pushUndoState();
|
|
|
|
const nowHidden = !itemData.hidden;
|
|
|
|
if (nowHidden) {
|
|
itemData.hidden = true;
|
|
itemEl.dataset.hidden = 'true';
|
|
} else {
|
|
delete itemData.hidden;
|
|
delete itemEl.dataset.hidden;
|
|
}
|
|
|
|
// Update button icon
|
|
btnEl.innerHTML = nowHidden ? Icons.eyeClosed : Icons.eyeOpen;
|
|
const label = nowHidden ? t('show') : t('hide');
|
|
btnEl.dataset.editorTooltip = label;
|
|
btnEl.setAttribute('aria-label', label);
|
|
|
|
EditorConfig.dirtyPages.add(page);
|
|
updateUndoRedoButtons();
|
|
|
|
showToast(nowHidden ? t('toast.hidden') : t('toast.visible'), 'success');
|
|
}
|
|
|
|
/**
|
|
* Structural changes that require a page reload (add/delete sections or list items)
|
|
* because PHP templates render the HTML server-side.
|
|
* Saves ALL dirty pages first, then reloads with edit-mode auto-restore.
|
|
*/
|
|
async function saveStructuralChange(page, description) {
|
|
try {
|
|
// Collect all dirty pages (including the structural change page)
|
|
const pagesToSave = new Set(EditorConfig.dirtyPages);
|
|
pagesToSave.add(page);
|
|
|
|
for (const p of pagesToSave) {
|
|
const pageData = EditorConfig.contentData[p];
|
|
if (!pageData) continue;
|
|
|
|
const formData = new FormData();
|
|
formData.append('action', 'save');
|
|
formData.append('page', p);
|
|
formData.append('content', JSON.stringify(pageData));
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, { method: 'POST', body: formData });
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
showToast(t('toast.error_generic', { message: result.message || 'Unknown' }), 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Persist edit-mode flag so it auto-restores after reload
|
|
sessionStorage.setItem('site-edit-mode', 'true');
|
|
showToast(description, 'success');
|
|
// Detach beforeunload guard before the programmatic reload —
|
|
// otherwise the browser shows its native "leave page?" dialog
|
|
// even though the editor itself is triggering the reload and
|
|
// all dirty pages were just persisted above.
|
|
window.removeEventListener('beforeunload', beforeUnloadGuard);
|
|
setTimeout(() => location.reload(), 300);
|
|
} catch (error) {
|
|
showToast(t('toast.error_saving', { page: '', message: error.message }), 'error');
|
|
}
|
|
}
|
|
|
|
function openEventEditor(eventId) {
|
|
const modal = document.getElementById('event-editor-modal');
|
|
const title = document.getElementById('event-editor-title');
|
|
const deleteBtn = document.getElementById('event-delete-btn');
|
|
const langs = Object.keys(EditorConfig.languages);
|
|
const translatableFields = ['title', 'location', 'description', 'admission'];
|
|
|
|
// Reset all fields
|
|
document.getElementById('event-id').value = '';
|
|
document.getElementById('event-date').value = '';
|
|
document.getElementById('event-time').value = '';
|
|
document.getElementById('event-end-date').value = '';
|
|
document.getElementById('event-end-time').value = '';
|
|
document.getElementById('event-url').value = '';
|
|
document.getElementById('event-image').value = '';
|
|
translatableFields.forEach(field => {
|
|
langs.forEach(lang => {
|
|
const el = document.getElementById(`event-${field}-${lang}`);
|
|
if (el) el.value = '';
|
|
});
|
|
});
|
|
|
|
// Reset tabs to default language
|
|
modal.querySelectorAll('.event-lang-tab').forEach(t => {
|
|
t.classList.toggle('active', t.dataset.lang === EditorConfig.defaultLang);
|
|
t.setAttribute('aria-selected', t.dataset.lang === EditorConfig.defaultLang ? 'true' : 'false');
|
|
t.tabIndex = t.dataset.lang === EditorConfig.defaultLang ? 0 : -1;
|
|
});
|
|
modal.querySelectorAll('.event-lang-panel').forEach(p => {
|
|
const active = p.dataset.lang === EditorConfig.defaultLang;
|
|
p.hidden = !active;
|
|
p.classList.toggle('active', active);
|
|
});
|
|
|
|
if (eventId) {
|
|
title.textContent = t('event.edit');
|
|
deleteBtn.style.display = 'inline-block';
|
|
|
|
const event = EditorConfig.eventsData?.events?.find(c => c.id === eventId);
|
|
if (event) {
|
|
EditorConfig.currentEvent = event;
|
|
document.getElementById('event-id').value = event.id;
|
|
document.getElementById('event-date').value = event.date || '';
|
|
document.getElementById('event-time').value = event.time || '';
|
|
document.getElementById('event-end-date').value = event['end-date'] || '';
|
|
document.getElementById('event-end-time').value = event['end-time'] || '';
|
|
document.getElementById('event-url').value = event.url || '';
|
|
document.getElementById('event-image').value = event.image || '';
|
|
|
|
translatableFields.forEach(field => {
|
|
langs.forEach(lang => {
|
|
const el = document.getElementById(`event-${field}-${lang}`);
|
|
if (el) el.value = (event[field] && event[field][lang]) || '';
|
|
});
|
|
});
|
|
}
|
|
} else {
|
|
title.textContent = t('event.new');
|
|
deleteBtn.style.display = 'none';
|
|
EditorConfig.currentEvent = null;
|
|
}
|
|
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeEventModal() {
|
|
const modal = document.getElementById('event-editor-modal');
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
EditorConfig.currentEvent = null;
|
|
}
|
|
|
|
async function saveEvent() {
|
|
const langs = Object.keys(EditorConfig.languages);
|
|
const defaultLang = EditorConfig.defaultLang;
|
|
const translatableFields = ['title', 'location', 'description', 'admission'];
|
|
|
|
// Build translatable field objects
|
|
const fieldObjects = {};
|
|
translatableFields.forEach(field => {
|
|
fieldObjects[field] = {};
|
|
langs.forEach(lang => {
|
|
const el = document.getElementById(`event-${field}-${lang}`);
|
|
if (el) fieldObjects[field][lang] = el.value;
|
|
});
|
|
});
|
|
|
|
const event = {
|
|
id: document.getElementById('event-id').value || '',
|
|
date: document.getElementById('event-date').value,
|
|
time: document.getElementById('event-time').value,
|
|
'end-date': document.getElementById('event-end-date').value,
|
|
'end-time': document.getElementById('event-end-time').value,
|
|
url: document.getElementById('event-url').value,
|
|
...fieldObjects,
|
|
image: document.getElementById('event-image').value
|
|
};
|
|
|
|
if (!event.date || !event.title[defaultLang]) {
|
|
showToast(t('event.date_required'), 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'save-event');
|
|
formData.append('event', JSON.stringify(event));
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast(result.message || t('toast.saved'), 'success');
|
|
closeEventModal();
|
|
setTimeout(() => location.reload(), 500);
|
|
} else {
|
|
showToast(t('toast.error_generic', { message: result.message }), 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast(t('toast.error_saving', { page: '', message: error.message }), 'error');
|
|
}
|
|
}
|
|
|
|
function deleteEvent() {
|
|
const eventId = document.getElementById('event-id').value;
|
|
if (!eventId) return;
|
|
|
|
showConfirmDialog(
|
|
t('event.delete'),
|
|
t('event.delete_confirm'),
|
|
t('event.cannot_undo'),
|
|
async () => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'delete-event');
|
|
formData.append('id', eventId);
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast(t('toast.event_deleted'), 'success');
|
|
closeEventModal();
|
|
setTimeout(() => location.reload(), 500);
|
|
} else {
|
|
showToast(t('toast.error_generic', { message: result.message }), 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast(t('toast.error_deleting', { message: error.message }), 'error');
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
// Toggle event visibility (hide/show)
|
|
async function toggleEventVisibility(eventId) {
|
|
if (!eventId) return;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'toggle-event-visibility');
|
|
formData.append('id', eventId);
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
const nowHidden = result.data.hidden;
|
|
const card = document.querySelector('.event-card[data-event-id="' + eventId + '"]');
|
|
if (card) {
|
|
card.classList.toggle('event-card-hidden', nowHidden);
|
|
const hideBtn = card.querySelector('.event-hide-btn');
|
|
if (hideBtn) {
|
|
hideBtn.title = nowHidden ? t('show') : t('hide');
|
|
hideBtn.dataset.hidden = nowHidden ? 'true' : 'false';
|
|
hideBtn.innerHTML = nowHidden ? Icons.eyeClosed : Icons.eyeOpen;
|
|
}
|
|
}
|
|
showToast(nowHidden ? t('toast.event_hidden') : t('toast.event_visible'), 'success');
|
|
} else {
|
|
showToast(t('toast.error_generic', { message: result.message }), 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast(t('toast.error_generic', { message: error.message }), 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// VERLAUF-MODAL (Undo-Historie)
|
|
// ============================================================
|
|
|
|
function createHistoryModal() {
|
|
if (document.getElementById('history-modal')) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'history-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop"></div>
|
|
<div class="editor-modal-content editor-modal-medium">
|
|
<div class="editor-modal-header">
|
|
<h3>${t('history.title')}</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closeHistoryModal()">×</button>
|
|
</div>
|
|
<div class="editor-modal-body">
|
|
<div id="history-list" class="history-list">
|
|
<!-- Dynamically populated -->
|
|
</div>
|
|
</div>
|
|
<div class="editor-modal-footer">
|
|
<button type="button" class="editor-btn editor-btn-danger" onclick="InlineEditor.clearHistory()">${t('history.clear')}</button>
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.closeHistoryModal()">${t('close')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', closeHistoryModal);
|
|
|
|
// Resizable Modal
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
}
|
|
|
|
function openHistoryModal() {
|
|
createHistoryModal();
|
|
|
|
const listEl = document.getElementById('history-list');
|
|
listEl.innerHTML = '';
|
|
|
|
// Collect all pages with backups
|
|
const allPages = UndoHistory.getAllPages();
|
|
let hasAnyBackups = false;
|
|
|
|
allPages.forEach(page => {
|
|
const backups = UndoHistory.getBackups(page);
|
|
if (backups.length > 0) {
|
|
hasAnyBackups = true;
|
|
|
|
// Page header
|
|
const pageHeader = document.createElement('div');
|
|
pageHeader.className = 'history-page-header';
|
|
pageHeader.textContent = page.replace('_', ' → ').toUpperCase();
|
|
listEl.appendChild(pageHeader);
|
|
|
|
// Backup entries
|
|
backups.forEach((backup, index) => {
|
|
const entry = document.createElement('div');
|
|
entry.className = 'history-entry';
|
|
entry.innerHTML = `
|
|
<div class="history-entry-info">
|
|
<span class="history-entry-date">${backup.date}</span>
|
|
<span class="history-entry-action">${backup.action}</span>
|
|
</div>
|
|
<button type="button" class="editor-btn editor-btn-small editor-btn-primary"
|
|
onclick="InlineEditor.restoreFromHistory('${page}', ${backup.timestamp})">
|
|
${t('history.restore')}
|
|
</button>
|
|
`;
|
|
listEl.appendChild(entry);
|
|
});
|
|
}
|
|
});
|
|
|
|
if (!hasAnyBackups) {
|
|
listEl.innerHTML = '<p class="history-empty">' + t('history.empty') + '</p>';
|
|
}
|
|
|
|
document.getElementById('history-modal').classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeHistoryModal() {
|
|
const modal = document.getElementById('history-modal');
|
|
if (modal) {
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
}
|
|
}
|
|
|
|
async function restoreFromHistory(contentPage, timestamp) {
|
|
const backups = UndoHistory.getBackups(contentPage);
|
|
const backup = backups.find(b => b.timestamp === timestamp);
|
|
|
|
if (!backup) {
|
|
showToast(t('history.not_found'), 'error');
|
|
return;
|
|
}
|
|
|
|
showConfirmDialog(
|
|
t('history.restore_title'),
|
|
t('history.restore_confirm', { date: backup.date }),
|
|
t('history.overwrite_warning'),
|
|
async () => {
|
|
closeHistoryModal();
|
|
await restoreContentState(backup.data, contentPage);
|
|
},
|
|
t('history.overwrite')
|
|
);
|
|
}
|
|
|
|
function clearHistory() {
|
|
showConfirmDialog(
|
|
t('history.clear'),
|
|
t('history.clear_confirm'),
|
|
t('event.cannot_undo'),
|
|
() => {
|
|
UndoHistory.clearAllHistory();
|
|
closeHistoryModal();
|
|
showToast(t('history.cleared'), 'success');
|
|
}
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// IMAGE MANAGER — thin wrappers around NbImageManager (js/image-manager.js)
|
|
// ============================================================
|
|
|
|
// Initialize shared image manager with frontend editor context.
|
|
// Called lazily on first open() since EditorConfig.csrfToken is set
|
|
// by initEditor() after the meta tag is parsed.
|
|
let _nbImgMgrInitialized = false;
|
|
function ensureImageManagerInit() {
|
|
if (_nbImgMgrInitialized || !window.NbImageManager) return;
|
|
NbImageManager.init({
|
|
apiUrl: EditorConfig.apiUrl,
|
|
csrfToken: EditorConfig.csrfToken,
|
|
t: t,
|
|
showToast: showToast,
|
|
showConfirm: function(title, message, onYes) {
|
|
showConfirmDialog(title, message, '', onYes);
|
|
}
|
|
});
|
|
_nbImgMgrInitialized = true;
|
|
}
|
|
|
|
function updateImagePreview(inputId) {
|
|
// Derive preview element ID from input ID (editor-input-{key}-{type} → editor-image-preview-{key})
|
|
let previewId = 'editor-image-preview';
|
|
let input;
|
|
if (inputId) {
|
|
input = document.getElementById(inputId);
|
|
const m = inputId.match(/^editor-input-(.+?)-[^-]+$/);
|
|
if (m) previewId = 'editor-image-preview-' + m[1];
|
|
} else {
|
|
// Fallback: find any visible image input in the editor modal
|
|
input = document.querySelector('.editor-field[style*="block"] input[id^="editor-input-"][readonly]');
|
|
if (input) {
|
|
inputId = input.id;
|
|
const m = inputId.match(/^editor-input-(.+?)-[^-]+$/);
|
|
if (m) previewId = 'editor-image-preview-' + m[1];
|
|
}
|
|
}
|
|
const preview = document.getElementById(previewId);
|
|
if (!input || !preview) return;
|
|
const imagePath = input.value.trim();
|
|
|
|
if (imagePath) {
|
|
const img = document.createElement('img');
|
|
img.src = imagePath;
|
|
img.alt = 'Preview';
|
|
img.onerror = function() { preview.innerHTML = '<p class="preview-placeholder">Image not found</p>'; };
|
|
preview.innerHTML = '';
|
|
preview.appendChild(img);
|
|
} else {
|
|
preview.innerHTML = '<p class="preview-placeholder">' + t('image.no_selection') + '</p>';
|
|
}
|
|
}
|
|
|
|
function openImageManager(callback, targetInputId, currentPath) {
|
|
ensureImageManagerInit();
|
|
// Default callback: update a specific editor image input by ID
|
|
const cb = callback || function(imagePath) {
|
|
const input = targetInputId ? document.getElementById(targetInputId) : null;
|
|
if (input) {
|
|
input.value = imagePath;
|
|
updateImagePreview(targetInputId);
|
|
}
|
|
};
|
|
const currentInput = targetInputId ? document.getElementById(targetInputId) : null;
|
|
// The library preselects the current image
|
|
const current = currentPath || (currentInput ? currentInput.value : null);
|
|
NbImageManager.open(cb, current, { types: ['image'], type: 'image' });
|
|
}
|
|
|
|
function closeImageManager() { NbImageManager.close(); }
|
|
function confirmImageSelection() { NbImageManager.confirmSelection(); }
|
|
|
|
function openImageManagerForEvent() {
|
|
ensureImageManagerInit();
|
|
const eventInput = document.getElementById('event-image');
|
|
NbImageManager.open(function(imagePath) {
|
|
const input = document.getElementById('event-image');
|
|
if (input) {
|
|
input.value = imagePath;
|
|
// Update preview if an updater exists
|
|
if (typeof updateEventImagePreview === 'function') {
|
|
updateEventImagePreview();
|
|
}
|
|
}
|
|
}, eventInput ? eventInput.value : null, { types: ['image'], type: 'image' });
|
|
}
|
|
|
|
// Lightbox for previewing the image currently entered in the editor's image input
|
|
function openEditorImageLightbox(inputId) {
|
|
const input = inputId ? document.getElementById(inputId) : null;
|
|
if (!input) return;
|
|
const imagePath = input.value.trim();
|
|
if (!imagePath) {
|
|
showToast(t('image.no_selection'), 'error');
|
|
return;
|
|
}
|
|
const imageName = imagePath.split('/').pop();
|
|
// NbImageManager lightbox is internal; open it via a temporary approach:
|
|
// simplest: use a built-in modal by opening the manager and letting the user preview
|
|
// For quick preview without manager, create a minimal inline lightbox:
|
|
let lb = document.getElementById('rj-imgmgr-inline-lightbox');
|
|
if (!lb) {
|
|
lb = document.createElement('div');
|
|
lb.id = 'rj-imgmgr-inline-lightbox';
|
|
lb.className = 'rj-imgmgr-lightbox';
|
|
lb.innerHTML =
|
|
'<div class="rj-imgmgr-lightbox-content">' +
|
|
'<button type="button" class="rj-imgmgr-lightbox-close" aria-label="Close">×</button>' +
|
|
'<img alt="">' +
|
|
'<div class="rj-imgmgr-lightbox-info"></div>' +
|
|
'</div>';
|
|
document.body.appendChild(lb);
|
|
lb.addEventListener('click', function(e) { if (e.target === lb) lb.classList.remove('active'); });
|
|
lb.querySelector('.rj-imgmgr-lightbox-close').addEventListener('click', function() { lb.classList.remove('active'); });
|
|
}
|
|
lb.querySelector('img').src = imagePath;
|
|
lb.querySelector('.rj-imgmgr-lightbox-info').textContent = imageName;
|
|
lb.classList.add('active');
|
|
}
|
|
|
|
// Legacy no-ops (in case any old onclick attribute in dynamically-rendered HTML still references these)
|
|
function closeLightbox() {
|
|
const lb = document.getElementById('rj-imgmgr-inline-lightbox');
|
|
if (lb) lb.classList.remove('active');
|
|
}
|
|
function closeReplaceDialog() { /* handled internally by NbImageManager */ }
|
|
|
|
// ============================================================
|
|
|
|
// ============================================================
|
|
// AUDIO-MANAGER
|
|
// ============================================================
|
|
|
|
let audioManagerCallback = null;
|
|
let audioManagerData = [];
|
|
|
|
function createAudioManagerModal() {
|
|
if (document.getElementById('audio-manager-modal')) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'audio-manager-modal';
|
|
modal.className = 'editor-modal';
|
|
modal.innerHTML = `
|
|
<div class="editor-modal-backdrop"></div>
|
|
<div class="editor-modal-content editor-modal-wide">
|
|
<div class="editor-modal-header">
|
|
<h3>${t('audio.manager')}</h3>
|
|
<button type="button" class="editor-close-btn" onclick="InlineEditor.closeAudioManager()">×</button>
|
|
</div>
|
|
<div class="editor-modal-body">
|
|
<div class="image-manager-toolbar">
|
|
<label class="editor-btn editor-btn-primary image-upload-btn">
|
|
${Icons.upload} ${t('audio.upload')}
|
|
<input type="file" id="audio-upload-input" accept=".mp3,.wav,.ogg,.m4a,.aac,.flac,audio/*" style="display:none;">
|
|
</label>
|
|
<span class="image-manager-info">${t('audio.formats_hint')}</span>
|
|
<div class="image-manager-search">
|
|
<input type="text" id="audio-search-input" class="image-search-input" placeholder="${t('audio.search')}">
|
|
</div>
|
|
</div>
|
|
<div class="image-manager-container">
|
|
<div id="audio-manager-list" class="audio-manager-list"></div>
|
|
</div>
|
|
</div>
|
|
<div class="editor-modal-footer">
|
|
<button type="button" class="editor-btn editor-btn-secondary" onclick="InlineEditor.closeAudioManager()">${t('close')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
modal.querySelector('.editor-modal-backdrop').addEventListener('click', closeAudioManager);
|
|
|
|
// Resizable Modal
|
|
ModalResize.init(modal.querySelector('.editor-modal-content'));
|
|
|
|
// Upload Event
|
|
document.getElementById('audio-upload-input').addEventListener('change', handleAudioUpload);
|
|
|
|
// Suche Event
|
|
document.getElementById('audio-search-input').addEventListener('input', filterAndRenderAudio);
|
|
}
|
|
|
|
function openAudioManager(targetInputIdOrCallback) {
|
|
ensureImageManagerInit();
|
|
// Allow either a callback function or a target input ID
|
|
let currentPath = null;
|
|
let callback;
|
|
if (typeof targetInputIdOrCallback === 'function') {
|
|
callback = targetInputIdOrCallback;
|
|
} else {
|
|
const targetInputId = targetInputIdOrCallback || 'editor-input-src-audio';
|
|
const input = document.getElementById(targetInputId);
|
|
currentPath = input ? input.value : null;
|
|
callback = (audioPath) => {
|
|
const input = document.getElementById(targetInputId);
|
|
if (input) input.value = audioPath;
|
|
updateAudioPreview();
|
|
};
|
|
}
|
|
|
|
NbImageManager.open(callback, currentPath, { types: ['audio'], type: 'audio' });
|
|
}
|
|
|
|
function closeAudioManager() {
|
|
const modal = document.getElementById('audio-manager-modal');
|
|
if (!modal) {
|
|
if (window.NbImageManager) NbImageManager.close();
|
|
return;
|
|
}
|
|
modal.classList.remove('active');
|
|
ModalResize.reset(modal.querySelector('.editor-modal-content'));
|
|
document.body.style.overflow = '';
|
|
audioManagerCallback = null;
|
|
}
|
|
|
|
async function loadAudioFiles() {
|
|
const listEl = document.getElementById('audio-manager-list');
|
|
listEl.innerHTML = '<div class="image-manager-loading">' + t('audio.loading') + '</div>';
|
|
|
|
try {
|
|
const response = await fetch(`${EditorConfig.apiUrl}?action=list-audio`);
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
audioManagerData = result.data || [];
|
|
filterAndRenderAudio();
|
|
} else {
|
|
listEl.innerHTML = '<div class="image-manager-error">' + t('audio.error_loading') + '</div>';
|
|
}
|
|
} catch (error) {
|
|
listEl.innerHTML = '<div class="image-manager-error">' + t('toast.error_generic', { message: error.message }) + '</div>';
|
|
}
|
|
}
|
|
|
|
function filterAndRenderAudio() {
|
|
const searchInput = document.getElementById('audio-search-input');
|
|
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
|
|
|
let filtered = audioManagerData;
|
|
if (searchTerm) {
|
|
filtered = audioManagerData.filter(audio =>
|
|
audio.name.toLowerCase().includes(searchTerm)
|
|
);
|
|
}
|
|
|
|
renderAudioList(filtered);
|
|
}
|
|
|
|
function renderAudioList(audioFiles) {
|
|
const listEl = document.getElementById('audio-manager-list');
|
|
|
|
if (audioFiles.length === 0) {
|
|
listEl.innerHTML = '<div class="image-manager-empty">' + t('audio.no_files') + '</div>';
|
|
return;
|
|
}
|
|
|
|
let html = `
|
|
<div class="image-list-header">
|
|
<span></span>
|
|
<span class="image-list-header-col">${t('image.col_filename')}</span>
|
|
<span class="image-list-header-col">${t('image.col_size')}</span>
|
|
<span class="image-list-header-col">${t('image.col_date')}</span>
|
|
<span class="image-list-header-col">${t('image.col_actions')}</span>
|
|
</div>
|
|
<div class="image-list-body">
|
|
`;
|
|
|
|
audioFiles.forEach(audio => {
|
|
html += `
|
|
<div class="image-list-row" data-name="${audio.name}">
|
|
<span class="audio-list-icon">${Icons.audio}</span>
|
|
<span class="image-list-name" title="${audio.name}">${audio.name}</span>
|
|
<span class="image-list-size">${audio.size}</span>
|
|
<span class="image-list-date">${audio.dateFormatted}</span>
|
|
<span class="image-list-actions">
|
|
<button type="button" class="image-action-btn image-action-select audio-action-select" title="${t('audio.select')}">
|
|
${Icons.check}
|
|
</button>
|
|
<button type="button" class="image-action-btn image-action-preview audio-action-play" title="${t('audio.play')}">
|
|
▶
|
|
</button>
|
|
<button type="button" class="image-action-btn image-action-delete audio-action-delete" title="${t('delete')}">
|
|
${Icons.delete}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '</div>';
|
|
listEl.innerHTML = html;
|
|
|
|
// Add event listeners
|
|
listEl.querySelectorAll('.image-list-row').forEach((row, idx) => {
|
|
const audio = audioFiles[idx];
|
|
|
|
row.querySelector('.audio-action-select').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
selectAudio(audio.path);
|
|
});
|
|
|
|
row.querySelector('.audio-action-play').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
playAudioPreview(audio.path, audio.name);
|
|
});
|
|
|
|
row.querySelector('.audio-action-delete').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
deleteAudio(audio.name);
|
|
});
|
|
});
|
|
}
|
|
|
|
function selectAudio(audioPath) {
|
|
if (audioManagerCallback) {
|
|
audioManagerCallback(audioPath);
|
|
}
|
|
closeAudioManager();
|
|
}
|
|
|
|
function playAudioPreview(audioPath, audioName) {
|
|
// Einfacher Audio-Preview
|
|
let previewAudio = document.getElementById('audio-preview-player');
|
|
if (!previewAudio) {
|
|
previewAudio = document.createElement('audio');
|
|
previewAudio.id = 'audio-preview-player';
|
|
previewAudio.controls = true;
|
|
previewAudio.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:30000;background:#222;border-radius:8px;padding:5px;';
|
|
document.body.appendChild(previewAudio);
|
|
|
|
// Remove on end or pause
|
|
previewAudio.addEventListener('ended', () => previewAudio.remove());
|
|
previewAudio.addEventListener('pause', () => setTimeout(() => previewAudio.remove(), 2000));
|
|
}
|
|
|
|
previewAudio.src = audioPath;
|
|
previewAudio.play();
|
|
showToast(t('audio.playing', { name: audioName }), 'success');
|
|
}
|
|
|
|
async function deleteAudio(filename) {
|
|
showConfirmDialog(
|
|
t('audio.delete'),
|
|
t('audio.delete_confirm', { filename }),
|
|
t('audio.trash_hint'),
|
|
async () => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'delete-audio');
|
|
formData.append('filename', filename);
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast(t('audio.trashed'), 'success');
|
|
loadAudioFiles();
|
|
} else {
|
|
showToast(t('toast.error_generic', { message: result.message }), 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast(t('toast.error_generic', { message: error.message }), 'error');
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
async function handleAudioUpload(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
// Check file type
|
|
const allowedTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/x-m4a', 'audio/aac', 'audio/flac'];
|
|
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i)) {
|
|
showToast(t('audio.format_error'), 'error');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
// Check file size (max 50MB)
|
|
if (file.size > 50 * 1024 * 1024) {
|
|
showToast(t('audio.size_error'), 'error');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('action', 'upload-audio');
|
|
formData.append('audio', file);
|
|
formData.append('csrf_token', EditorConfig.csrfToken);
|
|
|
|
showToast(t('image.uploading'), 'success');
|
|
|
|
const response = await fetch(EditorConfig.apiUrl, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast(t('audio.uploaded'), 'success');
|
|
loadAudioFiles();
|
|
// Optional: select audio directly
|
|
if (result.data && result.data.path) {
|
|
selectAudio(result.data.path);
|
|
}
|
|
} else {
|
|
showToast(t('toast.error_generic', { message: result.message }), 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast(t('toast.upload_error', { message: error.message }), 'error');
|
|
}
|
|
|
|
e.target.value = '';
|
|
}
|
|
|
|
// ============================================================
|
|
// NEWS POST INLINE EDITING
|
|
// ============================================================
|
|
|
|
function enableNewsPostEditing() {
|
|
const postEl = document.querySelector('[data-news-post]');
|
|
if (!postEl) return;
|
|
|
|
const titleEl = postEl.querySelector('.news-post-page__title');
|
|
const contentEl = postEl.querySelector('.news-post-page__content');
|
|
const authorEl = postEl.querySelector('.news-post-page__author');
|
|
const heroEl = postEl.querySelector('.news-post-page__hero img');
|
|
|
|
if (titleEl) {
|
|
titleEl.contentEditable = 'true';
|
|
titleEl.classList.add('news-editable');
|
|
titleEl.addEventListener('input', markNewsPostDirty);
|
|
}
|
|
|
|
if (contentEl) {
|
|
contentEl.contentEditable = 'true';
|
|
contentEl.classList.add('news-editable');
|
|
contentEl.addEventListener('input', markNewsPostDirty);
|
|
}
|
|
|
|
if (authorEl) {
|
|
authorEl.contentEditable = 'true';
|
|
authorEl.classList.add('news-editable');
|
|
authorEl.addEventListener('input', markNewsPostDirty);
|
|
}
|
|
|
|
if (heroEl) {
|
|
heroEl.style.cursor = 'pointer';
|
|
heroEl.classList.add('news-editable');
|
|
heroEl.title = t('editor.change_image') || 'Change image';
|
|
heroEl._newsClickHandler = function() {
|
|
openImageManager(function(imagePath) {
|
|
heroEl.src = imagePath;
|
|
syncAiLabel(heroEl);
|
|
EditorConfig.newsPostData.image = imagePath;
|
|
markNewsPostDirty();
|
|
closeImageManager();
|
|
}, null, EditorConfig.newsPostData.image || imageElementPath(heroEl));
|
|
};
|
|
heroEl.addEventListener('click', heroEl._newsClickHandler);
|
|
}
|
|
}
|
|
|
|
function disableNewsPostEditing() {
|
|
const postEl = document.querySelector('[data-news-post]');
|
|
if (!postEl) return;
|
|
|
|
const titleEl = postEl.querySelector('.news-post-page__title');
|
|
const contentEl = postEl.querySelector('.news-post-page__content');
|
|
const authorEl = postEl.querySelector('.news-post-page__author');
|
|
const heroEl = postEl.querySelector('.news-post-page__hero img');
|
|
|
|
if (titleEl) {
|
|
titleEl.contentEditable = 'false';
|
|
titleEl.classList.remove('news-editable');
|
|
titleEl.removeEventListener('input', markNewsPostDirty);
|
|
}
|
|
|
|
if (contentEl) {
|
|
contentEl.contentEditable = 'false';
|
|
contentEl.classList.remove('news-editable');
|
|
contentEl.removeEventListener('input', markNewsPostDirty);
|
|
}
|
|
|
|
if (authorEl) {
|
|
authorEl.contentEditable = 'false';
|
|
authorEl.classList.remove('news-editable');
|
|
authorEl.removeEventListener('input', markNewsPostDirty);
|
|
}
|
|
|
|
if (heroEl) {
|
|
heroEl.style.cursor = '';
|
|
heroEl.classList.remove('news-editable');
|
|
heroEl.title = '';
|
|
if (heroEl._newsClickHandler) {
|
|
heroEl.removeEventListener('click', heroEl._newsClickHandler);
|
|
delete heroEl._newsClickHandler;
|
|
}
|
|
}
|
|
}
|
|
|
|
function markNewsPostDirty() {
|
|
EditorConfig.dirtyPages.add('__news_post__');
|
|
updateUndoRedoButtons();
|
|
}
|
|
|
|
// ============================================================
|
|
// GLOBALES API
|
|
// ============================================================
|
|
|
|
window.InlineEditor = {
|
|
init: initEditor,
|
|
openEditor: openEditor,
|
|
closeModal: closeModal,
|
|
execCommand: execCommand,
|
|
insertLink: insertLink,
|
|
toggleHtmlMode: toggleHtmlMode,
|
|
saveSection: saveSection,
|
|
closeGroupEditor: closeGroupEditor,
|
|
saveGroupEditor: saveGroupEditor,
|
|
cleanHtml: cleanHtml,
|
|
// Add
|
|
openAddModal: openAddModal,
|
|
closeAddModal: closeAddModal,
|
|
addSection: addSection,
|
|
// Event-Funktionen
|
|
openEventEditor: openEventEditor,
|
|
closeEventModal: closeEventModal,
|
|
saveEvent: saveEvent,
|
|
deleteEvent: deleteEvent,
|
|
toggleEventVisibility: toggleEventVisibility,
|
|
// Dialog-Funktionen
|
|
closeConfirmDialog: closeConfirmDialog,
|
|
closePromptDialog: closePromptDialog,
|
|
submitPromptDialog: submitPromptDialog,
|
|
closeHtmlSourceDialog: closeHtmlSourceDialog,
|
|
submitHtmlSourceDialog: submitHtmlSourceDialog,
|
|
// News post editing
|
|
enableNewsPostEditing: enableNewsPostEditing,
|
|
disableNewsPostEditing: disableNewsPostEditing,
|
|
// Undo-Funktionen
|
|
openHistoryModal: openHistoryModal,
|
|
closeHistoryModal: closeHistoryModal,
|
|
restoreFromHistory: restoreFromHistory,
|
|
clearHistory: clearHistory,
|
|
// Edit-Mode
|
|
isEditMode: function() { return EditorConfig.editMode === true; },
|
|
enterEditMode: enterEditMode,
|
|
exitEditMode: exitEditMode,
|
|
saveAllChanges: saveAllChanges,
|
|
undo: undo,
|
|
redo: redo,
|
|
// Image manager
|
|
openImageManager: openImageManager,
|
|
openImageManagerForEvent: openImageManagerForEvent,
|
|
closeImageManager: closeImageManager,
|
|
confirmImageSelection: confirmImageSelection,
|
|
closeLightbox: closeLightbox,
|
|
closeReplaceDialog: closeReplaceDialog,
|
|
openEditorImageLightbox: openEditorImageLightbox,
|
|
// Audio-Verwaltung
|
|
openAudioManager: openAudioManager,
|
|
closeAudioManager: closeAudioManager
|
|
};
|
|
|
|
// Bei DOM-Ready initialisieren
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initEditor);
|
|
} else {
|
|
initEditor();
|
|
}
|
|
})();
|