Raiju/js/inline-editor.js
2026-10-25 06:05:42 +03:00

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, '&quot;').replace(/'/g, '&#39;');
}
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'))}">&times;</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'))}">&times;</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'))}">&times;</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'))}">&times;</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'))}">&times;</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">&lt;/&gt;</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()">&times;</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)">&times;</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">&times;</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()">&times;</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">&times;</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()">&times;</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();
}
})();