dbXapp 2.0
RAD, CMS, Module und Runtime-IDE fuer dbXapp
Loading...
Searching...
No Matches
cms.js
Go to the documentation of this file.
1/*!
2 * dbxapp cms.js
3 * Content CMS: tree, page editor, media upload.
4 */
5(function (window, document) {
6 "use strict";
7
8 if (!window.dbx || !window.dbx.feature) {
9 console.error("[dbx][cms] dbx core missing");
10 return;
11 }
12
13 const dbx = window.dbx;
14 const LIB = "cms";
15 const TREE_UI_ID = "dbxContent_admin.contentTree";
16 const PANEL_UI_ID = "dbxContent_admin.panels";
17
18 function qs(root, sel) {
19 return root ? root.querySelector(sel) : null;
20 }
21
22 function qsa(root, sel) {
23 return root ? Array.from(root.querySelectorAll(sel)) : [];
24 }
25
26 function closestElement(target, selector) {
27 if (!target) return null;
28 const el = target.nodeType === 1 ? target : target.parentElement;
29 return el && el.closest ? el.closest(selector) : null;
30 }
31
32 function status(root, msg, type = "info") {
33 const modal = state(root).mediaBrowser;
34 const el = qs(root, "[data-cms-status]") || (modal ? qs(modal, "[data-cms-status]") : null);
35 if (!el) {
36 if (msg && type === "error") {
37 dbx.warn("[cms]", msg);
38 }
39 return;
40 }
41 el.className = "dbx-cms-status dbx-cms-status-" + type;
42 el.textContent = msg || "";
43 if (msg) window.setTimeout(() => { el.textContent = ""; }, 3500);
44 }
45
46 function apiUrl(url, params) {
47 const out = new URL(String(url || window.location.href), window.location.href);
48 Object.keys(params || {}).forEach(key => out.searchParams.set(key, params[key]));
49 out.searchParams.set("_", Date.now());
50 return out.toString();
51 }
52
53 function fetchJson(url, opt) {
54 opt = opt || {};
55 if (!dbx.ajax || typeof dbx.ajax.request !== "function") {
56 return Promise.reject(new Error("ajax.js nicht geladen."));
57 }
58
59 return dbx.ajax.request({
60 url: url,
61 method: opt.method || "GET",
62 mode: "json",
63 body: (typeof opt.body !== "undefined") ? opt.body : null,
64 data: (typeof opt.data !== "undefined") ? opt.data : null,
65 headers: opt.headers || {},
66 timeout: Number(opt.timeout || 20000),
67 footerRuntime: opt.footerRuntime || ""
68 }).catch(err => {
69 if (err && err.name === "AbortError") throw new Error("Serverantwort dauert zu lange.");
70 throw err;
71 });
72 }
73
74 function fetchHtml(url, opt) {
75 opt = opt || {};
76 if (!dbx.ajax || typeof dbx.ajax.request !== "function") {
77 return Promise.reject(new Error("ajax.js nicht geladen."));
78 }
79
80 return dbx.ajax.request({
81 url: url,
82 method: opt.method || "GET",
83 mode: "html",
84 body: (typeof opt.body !== "undefined") ? opt.body : null,
85 data: (typeof opt.data !== "undefined") ? opt.data : null,
86 headers: opt.headers || {},
87 timeout: Number(opt.timeout || 20000),
88 footerRuntime: opt.footerRuntime || ""
89 })
90 .catch(err => {
91 if (err && err.name === "AbortError") throw new Error("Serverantwort dauert zu lange.");
92 throw err;
93 });
94 }
95
96 function ensureFeature(name) {
97 if (!name) return Promise.resolve(false);
98 if (dbx.feature && dbx.feature.has && dbx.feature.has(name)) {
99 return Promise.resolve(true);
100 }
101
102 return new Promise(resolve => {
103 if (!dbx.resolveFeature) {
104 resolve(false);
105 return;
106 }
107
108 dbx.resolveFeature(name, ok => resolve(ok === true));
109 });
110 }
111
112 function loadAssets(list) {
113 return new Promise(resolve => {
114 if (!Array.isArray(list) || !list.length) {
115 resolve(true);
116 return;
117 }
118
119 if (!dbx.load) {
120 resolve(false);
121 return;
122 }
123
124 dbx.load(list, () => resolve(true));
125 });
126 }
127
128 function ensureOpenWin() {
129 return ensureFeature("openWin").then(ok => {
130 return ok && dbx.openWin && typeof dbx.openWin.open === "function";
131 });
132 }
133
134 function ensureConfirm() {
135 return ensureFeature("confirm").then(ok => {
136 return ok && dbx.confirm && typeof dbx.confirm.open === "function";
137 });
138 }
139
140 function ensureJodit() {
141 if (window.Jodit && window.Jodit.make) {
142 return Promise.resolve(true);
143 }
144
145 return loadAssets([
146 ["css", "root", "vendor/jodit/jodit.fat.min.css"],
147 ["js", "root", "vendor/jodit/jodit.fat.min.js"]
148 ]).then(() => !!(window.Jodit && window.Jodit.make));
149 }
150
151 function extractProcessHtml(html) {
152 const tpl = document.createElement("template");
153 tpl.innerHTML = String(html || "").trim();
154 const proc = tpl.content.querySelector("[data-dbx-process-root='1'], .dbx-process");
155 return proc ? proc.outerHTML : String(html || "");
156 }
157
158 function escapeHtml(text) {
159 const div = document.createElement("div");
160 div.textContent = text == null ? "" : String(text);
161 return div.innerHTML;
162 }
163
164 function escapeTooltipAttr(text) {
165 return escapeHtml(String(text == null ? "" : text).replace(/"/g, "'"));
166 }
167
168 function escapeTextareaValue(text) {
169 return String(text == null ? "" : text).replace(/<\/textarea/gi, "&lt;/textarea");
170 }
171
172 function fillLngProvisionContentPreviews(modal, items) {
173 if (!modal || !Array.isArray(items)) return;
174 items.forEach((item, index) => {
175 const preview = qs(modal, `[data-lng-content-preview="${index}"]`);
176 if (!preview) return;
177 const html = String(item && item.content != null ? item.content : "").trim();
178 preview.innerHTML = html;
179 if (!html) {
180 preview.classList.add("is-empty");
181 }
182 });
183 }
184
185 function state(root) {
186 root.__dbxCms = root.__dbxCms || {
187 tree: [],
188 flat: [],
189 selectedFolder: Number(dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "selectedFolder", 0) : 0) || 0,
190 folder: null,
191 selectedPage: Number(dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "selectedPage", 0) : 0) || 0,
192 selectedType: dbx.uiGet ? String(dbx.uiGet(LIB, TREE_UI_ID, "selectedType", "page") || "page") : "page",
193 page: null,
194 mediaRows: [],
195 mediaFilter: "all",
196 collapsedFolders: null,
197 selectionRestored: false,
198 dirty: false,
199 loading: false,
200 saving: false,
201 duplicating: false,
202 pageLoadSeq: 0
203 };
204 return root.__dbxCms;
205 }
206
207 function cfgUrl(cfg, key) {
208 return cfg[key] || "";
209 }
210
211 function browserCfg(modal) {
212 return modal && modal.__dbxCmsCfg ? modal.__dbxCmsCfg : {};
213 }
214
215 function mediaBrowserProfile(cfg) {
216 const media = String(cfgUrl(cfg, "media") || "");
217 if (media.indexOf("modul_images_media") >= 0) return "mod";
218 return "cms";
219 }
220
221 function applyMediaBrowserProfile(modal, cfg) {
222 const isMod = mediaBrowserProfile(cfg) === "mod";
223 modal.dataset.dbxMediaProfile = isMod ? "mod" : "cms";
224 const folderBar = qs(modal, ".dbx-cms-media-folderbar");
225 const externalVideo = qs(modal, "[data-cms-browser-external-video]");
226 const slotSelect = qs(modal, "[data-cms-media-browser-slot]");
227 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
228 const folderSelect = qs(modal, "[data-cms-media-browser-folder]");
229 const folderTree = qs(modal, "[data-cms-media-folder-tree]");
230 const batchBtn = qs(modal, "[data-cms-media-batch-open]");
231 const rootHint = qs(modal, "[data-cms-media-root-hint]");
232 if (rootHint) {
233 rootHint.textContent = isMod ? "Speicherort: files/mod/" : "Speicherort: files/media/";
234 }
235 if (folderBar) folderBar.hidden = isMod;
236 if (externalVideo) externalVideo.hidden = isMod;
237 if (slotSelect) slotSelect.hidden = isMod;
238 if (uploadFolder) uploadFolder.hidden = isMod;
239 if (folderSelect) folderSelect.hidden = isMod;
240 if (folderTree) folderTree.hidden = isMod || !modal.classList.contains("is-folder-tree-open");
241 if (isMod) modal.classList.remove("is-folder-tree-open");
242 if (batchBtn) batchBtn.hidden = isMod;
243 }
244
245 function isMediaBrowserMulti(modal) {
246 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "";
247 if (modal && modal.__dbxCmsSinglePick) return false;
248 return mode === "pick" || (mode === "assign" && modal.__dbxCmsAssignSlot !== "hero");
249 }
250
251 function mediaBrowserUsesConfirmBar(modal) {
252 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "";
253 return mode === "pick" || mode === "assign";
254 }
255
256 function confirmPickMediaBrowser(root, modal) {
257 const rows = selectedMediaBrowserRows(modal);
258 if (!rows.length) {
259 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
260 return Promise.resolve();
261 }
262
263 let chain = Promise.resolve();
264 let keepOpen = false;
265 rows.forEach(row => {
266 chain = chain.then(() => {
267 if (typeof modal.__dbxCmsAfterAssign === "function") {
268 return Promise.resolve(modal.__dbxCmsAfterAssign(row)).then(result => {
269 if (result === false) keepOpen = true;
270 });
271 }
272 });
273 });
274 return chain.then(() => {
275 if (keepOpen) {
276 clearCmsLoading(root);
277 return;
278 }
279 modal.hidden = true;
280 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
281 dbx.openWin.close(modal.__dbxCmsWindowId);
282 }
283 clearCmsLoading(root);
284 status(root, "Auswahl uebernommen.", "success");
285 });
286 }
287
288 function isViewMode(cfg) {
289 return String((cfg && cfg.mode) || "").toLowerCase() === "view";
290 }
291
292 function syncStickyHeaderOffset(root) {
293 const header = document.getElementById("dbxHeader");
294 const headerMenus = header ? qsa(header, ".dbx-menu").filter(el => el.parentElement === header) : [];
295 const visibleBottom = headerMenus.reduce((bottom, el) => {
296 const style = window.getComputedStyle ? window.getComputedStyle(el) : null;
297 if (style && (style.display === "none" || style.visibility === "hidden")) return bottom;
298 const rect = el.getBoundingClientRect();
299 if (!rect.height || rect.bottom <= 0) return bottom;
300 return Math.max(bottom, rect.bottom);
301 }, 0);
302 const headerBottom = header ? Math.max(0, header.getBoundingClientRect().bottom || 0) : 0;
303 const fallbackBottom = Math.min(visibleBottom || 0, 160);
304 const height = header ? Math.ceil(Math.min(headerBottom || fallbackBottom || 0, 120)) : 0;
305 root.style.setProperty("--dbx-cms-sticky-top", height + "px");
306
307 const cmsHead = qs(root, ".dbx-cms-head");
308 const cmsHeadStyle = cmsHead && window.getComputedStyle ? window.getComputedStyle(cmsHead) : null;
309 let cmsHeadTop = cmsHeadStyle ? parseFloat(cmsHeadStyle.top || "0") : 0;
310 if (!Number.isFinite(cmsHeadTop)) cmsHeadTop = 0;
311 const cmsHeadHeight = cmsHead ? Math.ceil(cmsHead.getBoundingClientRect().height || cmsHead.offsetHeight || 0) : 0;
312 const editorTop = cmsHead ? Math.ceil(cmsHeadTop + cmsHeadHeight) : height;
313 root.style.setProperty("--dbx-cms-editor-toolbar-top", editorTop + "px");
314 const instance = getEditorInstance(root);
315 if (instance && instance.o) instance.o.toolbarStickyOffset = editorTop;
316 }
317
318 function bindStickyHeaderOffset(root) {
319 if (root.__dbxCmsStickyOffsetBound) return;
320 root.__dbxCmsStickyOffsetBound = true;
321 syncStickyHeaderOffset(root);
322 window.setTimeout(() => syncStickyHeaderOffset(root), 50);
323 window.setTimeout(() => syncStickyHeaderOffset(root), 300);
324 window.addEventListener("resize", () => syncStickyHeaderOffset(root), { passive: true });
325 }
326
327 function syncTreeFlyoutPosition(root) {
328 if (!root || root.classList.contains("is-tree-collapsed")) return;
329 const btn = qs(root, ".dbx-cms-head [data-cms-action='toggle-tree-panel']");
330 const panel = isViewMode(cmsConfig(root)) ? qs(root, ".dbx-cms-view-tree-panel") : qs(root, ".dbx-cms-tree-panel");
331 if (!btn || !panel) return;
332
333 const rect = btn.getBoundingClientRect();
334 const gap = 6;
335 const minEdge = 12;
336 const maxWidth = isViewMode(cmsConfig(root)) ? 720 : 600;
337 const left = Math.max(minEdge, Math.min(rect.left, window.innerWidth - minEdge - maxWidth));
338 const width = Math.min(maxWidth, window.innerWidth - left - minEdge);
339 const top = Math.min(rect.bottom + gap, window.innerHeight - 80);
340
341 root.style.setProperty("--dbx-cms-tree-left", Math.round(left) + "px");
342 root.style.setProperty("--dbx-cms-tree-top", Math.round(top) + "px");
343 root.style.setProperty("--dbx-cms-tree-width", Math.round(width) + "px");
344 }
345
346 function bindTreeFlyoutPosition(root) {
347 if (root.__dbxCmsTreeFlyoutBound) return;
348 root.__dbxCmsTreeFlyoutBound = true;
349
350 const update = () => syncTreeFlyoutPosition(root);
351 window.addEventListener("resize", update, { passive: true });
352 window.addEventListener("scroll", update, { passive: true });
353
354 const scroller = closestElement(root, ".dbx-content");
355 if (scroller) scroller.addEventListener("scroll", update, { passive: true });
356 }
357
358 function applyTreePanelState(root, collapsed) {
359 root.classList.toggle("is-tree-collapsed", !!collapsed);
360 root.classList.remove("is-tree-peek");
361 if (!collapsed) clearTreeForceCollapse(root);
362 qsa(root, "[data-cms-action='toggle-tree-panel']").forEach(btn => {
363 btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
364 btn.setAttribute("aria-label", collapsed ? "Content Tree anzeigen" : "Content Tree ausblenden");
365 btn.setAttribute("title", collapsed ? "Content Tree anzeigen" : "Content Tree ausblenden");
366 const icon = qs(btn, "i");
367 if (icon) {
368 icon.classList.toggle("bi-list", collapsed);
369 icon.classList.toggle("bi-x-lg", !collapsed);
370 icon.classList.toggle("bi-layout-sidebar-inset", false);
371 }
372 });
373 if (!collapsed) {
374 syncTreeFlyoutPosition(root);
375 window.requestAnimationFrame(() => syncTreeFlyoutPosition(root));
376 window.setTimeout(() => syncTreeFlyoutPosition(root), 0);
377 }
378 }
379
380 function clearTreeForceCollapse(root) {
381 if (root.__dbxCmsTreeForceTimer) {
382 window.clearTimeout(root.__dbxCmsTreeForceTimer);
383 root.__dbxCmsTreeForceTimer = null;
384 }
385 root.classList.remove("is-tree-force-collapsed");
386 }
387
388 function forceCollapseTreePanel(root) {
389 root.classList.add("is-tree-force-collapsed");
390 if (root.__dbxCmsTreeForceTimer) window.clearTimeout(root.__dbxCmsTreeForceTimer);
391 root.__dbxCmsTreeForceTimer = window.setTimeout(() => {
392 root.__dbxCmsTreeForceTimer = null;
393 if (root.classList.contains("is-tree-collapsed")) {
394 root.classList.remove("is-tree-force-collapsed");
395 }
396 }, 220);
397 }
398
399 function clearTreeHoverTimers(root) {
400 if (root.__dbxCmsTreeHoverEnterTimer) {
401 window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
402 root.__dbxCmsTreeHoverEnterTimer = null;
403 }
404 if (root.__dbxCmsTreeHoverLeaveTimer) {
405 window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
406 root.__dbxCmsTreeHoverLeaveTimer = null;
407 }
408 }
409
410 function setTreeHoverExpanded(root, expanded) {
411 if (!root) return;
412 root.classList.toggle("is-tree-panel-hover", !!expanded);
413 if (isViewMode(cmsConfig(root)) && root.classList.contains("is-tree-collapsed")) {
414 if (expanded && !root.classList.contains("is-tree-force-collapsed")) {
415 root.classList.add("is-tree-peek");
416 } else {
417 root.classList.remove("is-tree-peek");
418 }
419 }
420 }
421
422 function scheduleTreeHover(root, expanded) {
423 if (!root) return;
424 const delay = 700;
425 if (expanded) {
426 if (root.__dbxCmsTreeHoverLeaveTimer) {
427 window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
428 root.__dbxCmsTreeHoverLeaveTimer = null;
429 }
430 if (root.classList.contains("is-tree-panel-hover")) return;
431 if (root.__dbxCmsTreeHoverEnterTimer) window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
432 root.__dbxCmsTreeHoverEnterTimer = window.setTimeout(() => {
433 root.__dbxCmsTreeHoverEnterTimer = null;
434 setTreeHoverExpanded(root, true);
435 }, delay);
436 return;
437 }
438
439 if (root.__dbxCmsTreeHoverEnterTimer) {
440 window.clearTimeout(root.__dbxCmsTreeHoverEnterTimer);
441 root.__dbxCmsTreeHoverEnterTimer = null;
442 }
443 if (!root.classList.contains("is-tree-panel-hover") && !root.classList.contains("is-tree-peek")) return;
444 if (root.__dbxCmsTreeHoverLeaveTimer) window.clearTimeout(root.__dbxCmsTreeHoverLeaveTimer);
445 root.__dbxCmsTreeHoverLeaveTimer = window.setTimeout(() => {
446 root.__dbxCmsTreeHoverLeaveTimer = null;
447 setTreeHoverExpanded(root, false);
448 }, delay);
449 }
450
451 function initTreePanelState(root, cfg) {
452 const defaultCollapsed = isViewMode(cfg || {});
453 const collapsed = !!(dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "treeCollapsed", defaultCollapsed) : defaultCollapsed);
454 applyTreePanelState(root, collapsed);
455 }
456
457 function applyRightPanelState(root, collapsed) {
458 const panel = qs(root, "[data-cms-right-panel]");
459 if (!panel) return;
460
461 root.classList.toggle("is-right-panel-collapsed", !!collapsed);
462 qsa(root, "[data-cms-action='toggle-right-panel']").forEach(btn => {
463 btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
464 btn.setAttribute("aria-label", collapsed ? "Rechte Spalte einblenden" : "Rechte Spalte ausblenden");
465 btn.setAttribute("title", collapsed ? "Rechte Spalte einblenden" : "Rechte Spalte ausblenden");
466 const icon = qs(btn, "i");
467 if (icon) {
468 icon.classList.toggle("bi-list", !!collapsed);
469 icon.classList.toggle("bi-chevron-bar-right", !collapsed);
470 }
471 });
472 }
473
474 function initRightPanelState(root) {
475 if (!qs(root, "[data-cms-right-panel]")) return;
476 const collapsed = !!(dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "rightPanelCollapsed", false) : false);
477 applyRightPanelState(root, collapsed);
478 }
479
480 function scrollCmsToTop(root) {
481 const windowBody = closestElement(root, ".dbx-window-body");
482 const appContent = closestElement(root, ".dbx-content");
483 const scrollHost = windowBody || appContent;
484
485 if (scrollHost && typeof scrollHost.scrollTo === "function") {
486 scrollHost.scrollTo({ top: 0, behavior: "smooth" });
487 return;
488 }
489 window.scrollTo({ top: 0, behavior: "smooth" });
490 }
491
492 function toggleRightPanel(root, trigger) {
493 const collapsed = !root.classList.contains("is-right-panel-collapsed");
494 applyRightPanelState(root, collapsed);
495 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "rightPanelCollapsed", collapsed);
496 if (!collapsed && trigger && trigger.classList.contains("dbx-cms-right-panel-toggle-bottom")) {
497 window.requestAnimationFrame(() => scrollCmsToTop(root));
498 }
499 }
500
501 function toggleTreePanel(root) {
502 const collapsed = !root.classList.contains("is-tree-collapsed");
503 clearTreeHoverTimers(root);
504 setTreeHoverExpanded(root, false);
505 applyTreePanelState(root, collapsed);
506 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", collapsed);
507 }
508
509 function closeTreePanel(root) {
510 if (!root || root.classList.contains("is-tree-collapsed")) return;
511 clearTreeHoverTimers(root);
512 setTreeHoverExpanded(root, false);
513 applyTreePanelState(root, true);
514 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
515 }
516
517 function bindAdminTreeOutsideClose(root, cfg) {
518 if (!root || isViewMode(cfg || {}) || root.__dbxCmsAdminTreeOutsideCloseBound) return;
519 root.__dbxCmsAdminTreeOutsideCloseBound = true;
520
521 document.addEventListener("click", e => {
522 if (root.classList.contains("is-tree-collapsed")) return;
523 const target = e.target;
524 const panel = qs(root, ".dbx-cms-tree-panel");
525 const toggle = closestElement(target, "[data-cms-action='toggle-tree-panel']");
526 if (toggle && root.contains(toggle)) return;
527 if (panel && panel.contains(target)) return;
528 closeTreePanel(root);
529 }, true);
530 }
531
532 function bindViewTreeHover(root, cfg) {
533 if (!isViewMode(cfg) || root.__dbxCmsViewTreeHoverBound) return;
534 root.__dbxCmsViewTreeHoverBound = true;
535
536 const treePanel = qs(root, ".dbx-cms-view-tree-panel");
537 const viewPanel = qs(root, ".dbx-cms-view-panel");
538
539 if (treePanel) {
540 treePanel.addEventListener("mouseenter", () => {
541 if (root.classList.contains("is-tree-collapsed") && !root.classList.contains("is-tree-force-collapsed")) scheduleTreeHover(root, true);
542 });
543 treePanel.addEventListener("mouseleave", () => {
544 scheduleTreeHover(root, false);
545 });
546 }
547
548 if (viewPanel) {
549 viewPanel.addEventListener("mouseenter", () => {
550 clearTreeHoverTimers(root);
551 setTreeHoverExpanded(root, false);
552 applyTreePanelState(root, true);
553 clearTreeForceCollapse(root);
554 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
555 });
556 }
557
558 document.addEventListener("pointermove", e => {
559 if (!root.classList.contains("is-tree-force-collapsed")) return;
560 if (treePanel && treePanel.contains(e.target)) return;
561 clearTreeForceCollapse(root);
562 }, true);
563
564 root.addEventListener("mouseover", e => {
565 if (!root.classList.contains("is-tree-collapsed")) return;
566 if (treePanel && treePanel.contains(e.target)) {
567 if (root.classList.contains("is-tree-force-collapsed")) return;
568 scheduleTreeHover(root, true);
569 return;
570 }
571 if (viewPanel && viewPanel.contains(e.target)) {
572 clearTreeHoverTimers(root);
573 setTreeHoverExpanded(root, false);
574 applyTreePanelState(root, true);
575 clearTreeForceCollapse(root);
576 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
577 }
578 });
579 }
580
581 function getCollapsedFolders(root) {
582 const s = state(root);
583 if (s.collapsedFolders && typeof s.collapsedFolders === "object") {
584 return s.collapsedFolders;
585 }
586 const saved = dbx.uiGet ? dbx.uiGet(LIB, TREE_UI_ID, "collapsedFolders", {}) : {};
587 s.collapsedFolders = (saved && typeof saved === "object" && !Array.isArray(saved)) ? saved : {};
588 return s.collapsedFolders;
589 }
590
591 function setFolderCollapsed(root, id, collapsed) {
592 const folders = getCollapsedFolders(root);
593 const key = String(id || "");
594 if (!key) return;
595 if (collapsed) {
596 folders[key] = true;
597 } else {
598 delete folders[key];
599 }
600 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "collapsedFolders", folders);
601 }
602
603 function isFolderCollapsed(root, id, search) {
604 if (search) return false;
605 const folders = getCollapsedFolders(root);
606 return folders[String(id || "")] === true;
607 }
608
609 function setSelectedFolder(root, id) {
610 const s = state(root);
611 s.selectedFolder = Number(id || 0);
612 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedFolder", s.selectedFolder);
613 }
614
615 function setSelectedPage(root, id) {
616 const s = state(root);
617 s.selectedPage = Number(id || 0);
618 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedPage", s.selectedPage);
619 }
620
621 function setSelectedType(root, type) {
622 const s = state(root);
623 s.selectedType = type === "folder" ? "folder" : "page";
624 if (dbx.uiSet) dbx.uiSet(LIB, TREE_UI_ID, "selectedType", s.selectedType);
625 updateHeaderActionTooltips(root);
626 }
627
628 function revealTreeSelection(root) {
629 const s = state(root);
630 const type = s.selectedType === "folder" ? "folder" : "page";
631 const id = type === "folder" ? s.selectedFolder : s.selectedPage;
632 if (!id) {
633 renderTree(root);
634 return;
635 }
636
637 let node = findNode(root, type, id);
638 let parent = Number(node && node._parent || 0);
639 const seen = new Set();
640 while (parent > 0 && !seen.has(parent)) {
641 seen.add(parent);
642 setFolderCollapsed(root, parent, false);
643 node = findNode(root, "folder", parent);
644 parent = Number(node && node._parent || 0);
645 }
646
647 renderTree(root);
648 window.requestAnimationFrame(() => {
649 const row = qs(root, ".dbx-cms-tree-row.is-active");
650 if (row && row.scrollIntoView) {
651 row.scrollIntoView({ block: "nearest", inline: "nearest" });
652 }
653 });
654 }
655
656 function openPreview(root) {
657 const id = Number(getField(root, "id") || 0);
658 if (!id) {
659 status(root, "Bitte zuerst eine Seite auswaehlen.", "error");
660 return;
661 }
662
663 const title = getField(root, "title") || "Content Vorschau";
664 const url = "?dbx_modul=dbxContent&dbx_run1=show&dbx_cid=" + encodeURIComponent(id) + "&dbx_window=1";
665 const cfg = {
666 url: url,
667 title: "Vorschau: " + title,
668 width: 1280,
669 height: 820,
670 modal: 0,
671 ajax: 1,
672 scroll: 1,
673 position: "center",
674 reloadable: 1,
675 reuse: 1,
676 allowDuplicate: 0
677 };
678
679 ensureOpenWin().then(ok => {
680 if (ok) {
681 dbx.openWin.open(cfg, root);
682 } else {
683 status(root, "openWin.js nicht geladen.", "error");
684 }
685 });
686 }
687
688 function getEditorInstance(root) {
689 return root && root.__dbxCmsJodit ? root.__dbxCmsJodit : null;
690 }
691
692 function editorHeightGroup(root) {
693 return qs(root, ".dbx-cms-editor-group");
694 }
695
696 function syncEditorHeight(root) {
697 const group = editorHeightGroup(root);
698 const surface = editorSurface(root);
699 if (!group || !surface) return;
700
701 const instance = getEditorInstance(root);
702 const container = instance && instance.container ? instance.container : qs(root, ".jodit-container");
703 const workplace = qs(root, ".jodit-workplace");
704 const source = qs(root, ".jodit-source textarea, .jodit-source");
705 const sized = [container, workplace, surface, source].filter(Boolean);
706
707 if (container && container.classList && container.classList.contains("jodit_fullsize")) {
708 sized.forEach(el => {
709 if (!el.style) return;
710 el.style.removeProperty("height");
711 el.style.removeProperty("max-height");
712 el.style.removeProperty("min-height");
713 });
714 return;
715 }
716
717 sized.forEach(el => {
718 if (!el.style) return;
719 el.style.height = "auto";
720 el.style.maxHeight = "none";
721 el.style.minHeight = "0px";
722 });
723
724 const base = 430;
725 const contentHeight = Math.ceil(Math.max(
726 surface.scrollHeight || 0,
727 source ? (source.scrollHeight || 0) : 0
728 ));
729 const nextHeight = Math.max(base, contentHeight);
730 const value = nextHeight + "px";
731
732 group.style.setProperty("--dbx-cms-editor-min-height", value);
733 sized.forEach(el => {
734 if (!el.style) return;
735 el.style.minHeight = value;
736 });
737 }
738
739 function scheduleEditorHeight(root) {
740 if (!root) return;
741 if (root.__dbxCmsEditorHeightFrame) {
742 window.cancelAnimationFrame(root.__dbxCmsEditorHeightFrame);
743 }
744 root.__dbxCmsEditorHeightFrame = window.requestAnimationFrame(() => {
745 root.__dbxCmsEditorHeightFrame = null;
746 syncEditorHeight(root);
747 });
748 [80, 250, 800].forEach(delay => window.setTimeout(() => syncEditorHeight(root), delay));
749 }
750
751 function bindEditorHeight(root) {
752 if (!root || root.__dbxCmsEditorHeightBound) return;
753 root.__dbxCmsEditorHeightBound = true;
754
755 window.addEventListener("resize", () => scheduleEditorHeight(root), { passive: true });
756 root.addEventListener("input", e => {
757 const surface = editorSurface(root);
758 if (surface && (e.target === surface || surface.contains(e.target))) {
759 scheduleEditorHeight(root);
760 }
761 }, true);
762 root.addEventListener("load", e => {
763 const surface = editorSurface(root);
764 if (surface && e.target && surface.contains(e.target)) {
765 scheduleEditorHeight(root);
766 }
767 }, true);
768 }
769
770 function setDirty(root, dirty) {
771 const s = state(root);
772 s.dirty = !!dirty;
773 root.classList.toggle("is-dirty", s.dirty);
774
775 const el = qs(root, "[data-cms-dirty-state]");
776 if (!el) return;
777
778 const text = el.querySelector("span");
779 if (text) text.textContent = s.dirty ? "Ungespeichert" : "Gespeichert";
780 el.setAttribute("title", s.dirty ? "Ungespeicherte Änderungen" : "Alle Änderungen gespeichert");
781 }
782
783 function setSaving(root, saving) {
784 const s = state(root);
785 s.saving = !!saving;
786 root.classList.toggle("is-saving", s.saving);
787 root.setAttribute("aria-busy", s.saving ? "true" : "false");
788
789 qsa(root, "[data-cms-action='save'], [data-cms-action='save-settings'], [data-cms-action='save-folder']").forEach(btn => {
790 btn.disabled = s.saving;
791 btn.classList.toggle("is-saving", s.saving);
792 });
793 }
794
795 function updateHeaderActionTooltips(root) {
796 if (!root) return;
797 const isFolder = root.classList.contains("is-folder-editing");
798 const saveTitle = isFolder ? "Ordner speichern" : "Seite speichern";
799 const deleteTitle = isFolder ? "Ordner loeschen" : "Seite loeschen";
800 const saveBtn = qs(root, ".dbx-cms-head [data-cms-action='save']");
801 const deleteBtn = qs(root, ".dbx-cms-head [data-cms-action='delete']");
802 const duplicateBtn = qs(root, ".dbx-cms-head [data-cms-action='duplicate-page']");
803 const lngBtn = qs(root, ".dbx-cms-head [data-cms-action='lng-provision']");
804 const lngResetBtn = qs(root, ".dbx-cms-head [data-cms-action='lng-reset-sync']");
805
806 if (saveBtn) {
807 saveBtn.setAttribute("title", saveTitle);
808 saveBtn.setAttribute("aria-label", saveTitle);
809 }
810 if (deleteBtn) {
811 deleteBtn.setAttribute("title", deleteTitle);
812 deleteBtn.setAttribute("aria-label", deleteTitle);
813 }
814 if (duplicateBtn) {
815 const canDuplicate = !isFolder && state(root).selectedType === "page" && !state(root).duplicating;
816 duplicateBtn.disabled = !canDuplicate;
817 duplicateBtn.setAttribute("title", canDuplicate ? "Ausgewaehlte Seite duplizieren" : "Zum Duplizieren zuerst eine Seite auswaehlen");
818 duplicateBtn.setAttribute("aria-label", duplicateBtn.getAttribute("title"));
819 }
820 if (lngBtn) {
821 const cfg = cmsConfig(root);
822 const show = isMasterLngCfg(cfg, root);
823 lngBtn.hidden = !show;
824 lngBtn.style.display = show ? "" : "none";
825 }
826 if (lngResetBtn) {
827 const cfg = cmsConfig(root);
828 const show = isMasterLngCfg(cfg, root);
829 lngResetBtn.hidden = !show;
830 lngResetBtn.style.display = show ? "" : "none";
831 }
832 }
833
834 function currentCmsLng(root) {
835 const tab = qs(root, ".dbx-cms-lng-tab.is-active");
836 return tab ? String(tab.getAttribute("data-cms-lng") || "").toLowerCase() : "";
837 }
838
839 function cmsLngParams(root) {
840 const lng = currentCmsLng(root);
841 return lng ? { dbx_lng: lng } : {};
842 }
843
844 function isMasterLngCfg(cfg, root) {
845 const activeTab = qs(root, ".dbx-cms-lng-tab.is-active");
846 if (activeTab && activeTab.classList.contains("is-master")) {
847 return true;
848 }
849
850 let master = String((cfg && (cfg.masterlng || cfg.master_lng)) || "").toLowerCase().trim();
851 if (!/^[a-z]{2,3}$/.test(master)) {
852 const masterTab = qs(root, ".dbx-cms-lng-tab.is-master");
853 master = masterTab ? String(masterTab.getAttribute("data-cms-lng") || "de").toLowerCase() : "de";
854 }
855
856 const current = currentCmsLng(root);
857 return !current || current === master;
858 }
859
860 function maybeOpenLngProvisionAfterCreate(root, cfg, data) {
861 if (!data || Number(data.open_lng_provision) !== 1) {
862 return;
863 }
864 if (!isMasterLngCfg(cfg, root)) {
865 dbx.warn("[cms] provision dialog skipped: not master language tab");
866 return;
867 }
868 window.setTimeout(() => openLngProvisionDialog(root, cfg), 0);
869 }
870
871 function openLngProvisionDialog(root, cfg) {
872 const s = state(root);
873 const type = root.classList.contains("is-folder-editing") || s.selectedType === "folder" ? "folder" : "page";
874 const id = type === "folder"
875 ? Number(s.selectedFolder || getField(root, "folder") || 0)
876 : Number(s.selectedPage || getField(root, "id") || 0);
877
878 if (!id) {
879 status(root, "Bitte zuerst eine Seite oder einen Ordner waehlen.", "error");
880 return;
881 }
882 if (!isMasterLngCfg(cfg, root)) {
883 status(root, "Uebertragung nur in der Master-Sprache moeglich.", "error");
884 return;
885 }
886
887 const url = cfgUrl(cfg, "lngpreview");
888 if (!url) {
889 status(root, "Lng-Preview-URL fehlt.", "error");
890 return;
891 }
892
893 fetchJson(apiUrl(url, cmsLngParams(root)), {
894 method: "POST",
895 headers: { "Content-Type": "application/json" },
896 body: JSON.stringify({ type, id })
897 }).then(data => {
898 if (!data || !data.ok) {
899 throw new Error(data && data.msg ? data.msg : "Vorschau fehlgeschlagen");
900 }
901 showLngProvisionModal(root, cfg, type, id, data.preview || {}, data.provider || "", data.translate_warnings || []);
902 const warn = formatTranslateWarnings(data.translate_warnings);
903 if (warn) status(root, warn, "warning");
904 }).catch(err => {
905 status(root, err && err.message ? err.message : "Vorschau fehlgeschlagen.", "error");
906 });
907 }
908
909 function showLngProvisionModal(root, cfg, type, id, preview, provider, translateWarnings) {
910 const old = qs(root, "[data-cms-lng-dialog]");
911 if (old) old.remove();
912
913 const prov = String(provider || "").toLowerCase();
914 const provLabel = (!prov || prov === "undef") ? "copy" : prov;
915 const items = Array.isArray(preview.items) ? preview.items : [];
916 const warnGlobal = formatTranslateWarnings(translateWarnings);
917 const warnGlobalHtml = warnGlobal ? `<div class="dbx-cms-lng-warn">${escapeHtml(warnGlobal)}</div>` : "";
918 const treeOptionHtml = type === "folder"
919 ? `<label class="dbx-cms-lng-check mb-2"><input type="checkbox" data-lng-provision-tree> Gesamten Unterbaum inkl. Seiten uebertragen</label>`
920 : "";
921 const rows = items.map((item, index) => {
922 const lng = String(item.lng || "").toUpperCase();
923 const exists = Number(item.exists || 0) === 1;
924 const warnings = Array.isArray(item.warnings) ? item.warnings.join(" ") : "";
925 const warnHtml = warnings ? `<div class="dbx-cms-lng-warn">${escapeHtml(warnings)}</div>` : "";
926 const existsLabel = exists ? " (vorhanden)" : " (neu)";
927
928 if (type === "folder") {
929 return `<div class="dbx-cms-lng-row" data-lng-row="${index}">
930 <label class="dbx-cms-lng-check"><input type="checkbox" data-lng-enabled checked> <strong>${escapeHtml(lng)}</strong>${escapeHtml(existsLabel)}</label>
931 <label>Name<input class="form-control form-control-sm" data-lng-name value="${escapeHtml(item.name || "")}"></label>
932 ${warnHtml}
933 <input type="hidden" data-lng-code value="${escapeHtml(item.lng || "")}">
934 </div>`;
935 }
936
937 return `<div class="dbx-cms-lng-row" data-lng-row="${index}">
938 <label class="dbx-cms-lng-check"><input type="checkbox" data-lng-enabled checked> <strong>${escapeHtml(lng)}</strong>${escapeHtml(existsLabel)}</label>
939 <label>Titel<input class="form-control form-control-sm" data-lng-title value="${escapeHtml(item.title || "")}"></label>
940 <label>Permalink<input class="form-control form-control-sm" data-lng-permalink value="${escapeHtml(item.permalink || "")}"></label>
941 <label>Beschreibung<input class="form-control form-control-sm" data-lng-description value="${escapeHtml(item.description || "")}"></label>
942 <input type="hidden" data-lng-folder value="${Number(item.folder || 0)}">
943 <input type="hidden" data-lng-code value="${escapeHtml(item.lng || "")}">
944 <details class="dbx-cms-lng-content-details"><summary>Inhalt (Vorschau)</summary>
945 <div class="dbx-cms-lng-content-preview dbx-content-page" data-lng-content-preview="${index}"></div>
946 </details>
947 <textarea class="d-none" data-lng-content aria-hidden="true">${escapeTextareaValue(item.content || "")}</textarea>
948 ${warnHtml}
949 </div>`;
950 }).join("");
951
952 const modal = document.createElement("div");
953 modal.className = "dbx-cms-lng-dialog";
954 modal.setAttribute("data-cms-lng-dialog", "1");
955 modal.innerHTML = `
956 <div class="dbx-cms-lng-dialog-backdrop" data-cms-lng-close></div>
957 <div class="dbx-cms-lng-dialog-panel" role="dialog" aria-modal="true" aria-label="Uebersetzungen pruefen">
958 <div class="dbx-cms-lng-dialog-head">
959 <strong>In andere Sprachen uebertragen</strong>
960 <span class="text-muted">Provider: ${escapeHtml(provLabel)}</span>
961 <button type="button" class="btn btn-sm btn-outline-secondary" data-cms-lng-close>&times;</button>
962 </div>
963 <div class="dbx-cms-lng-dialog-body">
964 ${warnGlobalHtml}
965 ${treeOptionHtml}
966 ${rows || '<p class="text-muted">Keine Zielsprachen konfiguriert.</p>'}
967 </div>
968 <div class="dbx-cms-lng-dialog-foot">
969 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-lng-close>Abbrechen</button>
970 <button type="button" class="btn btn-primary btn-sm" data-cms-lng-submit>Uebernehmen</button>
971 </div>
972 </div>`;
973
974 root.appendChild(modal);
975 fillLngProvisionContentPreviews(modal, items);
976
977 qsa(modal, "[data-cms-lng-close]").forEach(btn => {
978 btn.addEventListener("click", () => modal.remove());
979 });
980
981 const submit = qs(modal, "[data-cms-lng-submit]");
982 if (submit) {
983 submit.addEventListener("click", () => {
984 const payloadItems = qsa(modal, "[data-lng-row]").map(row => {
985 const lng = qs(row, "[data-lng-code]");
986 const enabled = qs(row, "[data-lng-enabled]");
987 const out = {
988 lng: lng ? lng.value : "",
989 enabled: enabled && enabled.checked ? 1 : 0
990 };
991 if (type === "folder") {
992 const name = qs(row, "[data-lng-name]");
993 out.name = name ? name.value : "";
994 } else {
995 const title = qs(row, "[data-lng-title]");
996 const permalink = qs(row, "[data-lng-permalink]");
997 const description = qs(row, "[data-lng-description]");
998 const content = qs(row, "[data-lng-content]");
999 const folder = qs(row, "[data-lng-folder]");
1000 out.title = title ? title.value : "";
1001 out.permalink = permalink ? permalink.value : "";
1002 out.description = description ? description.value : "";
1003 out.content = content ? content.value : "";
1004 out.folder = folder ? Number(folder.value || 0) : 0;
1005 }
1006 return out;
1007 });
1008
1009 const treeMode = type === "folder" && !!(qs(modal, "[data-lng-provision-tree]") && qs(modal, "[data-lng-provision-tree]").checked);
1010 const provUrl = cfgUrl(cfg, treeMode ? "lngprovisiontree" : "lngprovision");
1011 const payload = treeMode
1012 ? { id, lngs: payloadItems.filter(item => Number(item.enabled) === 1).map(item => item.lng) }
1013 : { type, id, items: payloadItems };
1014
1015 fetchJson(apiUrl(provUrl, cmsLngParams(root)), {
1016 method: "POST",
1017 headers: { "Content-Type": "application/json" },
1018 body: JSON.stringify(payload)
1019 }).then(data => {
1020 if (!data || !data.ok) {
1021 const errs = data && data.result && Array.isArray(data.result.errors) ? data.result.errors.join(" ") : "";
1022 const transWarn = formatTranslateWarnings(data && data.result ? data.result.translate_warnings : null);
1023 throw new Error([data && data.msg, errs, transWarn].filter(Boolean).join(" ") || "Uebertragung fehlgeschlagen");
1024 }
1025 modal.remove();
1026 let msg = treeMode ? "Unterbaum in andere Sprachen uebernommen." : "Sprachvarianten uebernommen.";
1027 const mediaCopied = Number((data.result && data.result.media_copied) || 0);
1028 if (mediaCopied > 0) msg += " " + mediaCopied + " Medien-Verknuepfung(en) kopiert.";
1029 const transWarn = formatTranslateWarnings(data.result && data.result.translate_warnings);
1030 if (transWarn) msg += " " + transWarn;
1031 status(root, msg, transWarn ? "warning" : "success");
1032 return loadTree(root, cfg);
1033 }).catch(err => {
1034 status(root, err && err.message ? err.message : "Uebertragung fehlgeschlagen.", "error");
1035 });
1036 });
1037 }
1038 }
1039
1040 function formatTranslateWarnings(warnings) {
1041 if (!Array.isArray(warnings) || !warnings.length) return "";
1042 const msgs = warnings.map(w => w && w.message ? String(w.message) : "").filter(Boolean);
1043 if (!msgs.length) return "";
1044 return "Uebersetzung: " + [...new Set(msgs)].join(" ");
1045 }
1046
1047 function formatLngSaveStatus(base, data) {
1048 const parts = [base];
1049 const synced = Number(data.lng_synced || 0);
1050 if (synced > 0) parts.push(synced + " Sprachversion(en) synchronisiert.");
1051 const media = Number(data.lng_media_copied || 0);
1052 if (media > 0) parts.push(media + " Medien-Verknuepfung(en) uebernommen.");
1053 const inlineAdded = Number(data.inline_media_sync?.added || 0);
1054 const inlineRemoved = Number(data.inline_media_sync?.removed || 0);
1055 if (inlineAdded > 0) parts.push(inlineAdded + " Inline-Medium/Medien verknuepft.");
1056 if (inlineRemoved > 0) parts.push(inlineRemoved + " Inline-Medium/Medien aus der Liste entfernt.");
1057 if (Number(data.lng_forked || 0) === 1) parts.push("Sprachverknuepfung getrennt (manuell).");
1058 if (Number(data.open_lng_provision || 0) === 1) parts.push("Fehlende Sprachen — Dialog folgt.");
1059 const warn = formatTranslateWarnings(data.translate_warnings);
1060 if (warn) parts.push(warn);
1061 const text = parts.join(" ");
1062 const hasWarn = Number(data.lng_forked || 0) === 1 || !!warn;
1063 return { text, type: hasWarn ? "warning" : "success" };
1064 }
1065
1066 function resetLngSync(root, cfg) {
1067 const s = state(root);
1068 const type = root.classList.contains("is-folder-editing") || s.selectedType === "folder" ? "folder" : "page";
1069 const id = type === "folder"
1070 ? Number(s.selectedFolder || getFolderField(root, "id") || 0)
1071 : Number(s.selectedPage || getField(root, "id") || 0);
1072 const url = cfgUrl(cfg, "lngresetsync");
1073
1074 if (!id || !url) {
1075 status(root, "Bitte zuerst eine Seite oder einen Ordner waehlen.", "error");
1076 return Promise.resolve();
1077 }
1078 if (!isMasterLngCfg(cfg, root)) {
1079 status(root, "Auto-Sync nur in der Master-Sprache setzbar.", "error");
1080 return Promise.resolve();
1081 }
1082
1083 return ensureConfirm().then(ok => {
1084 if (!ok) return null;
1085 return dbx.confirm.open({
1086 id: "cms-lng-reset-sync-" + id,
1087 root,
1088 title: "<i class=\"bi bi-link-45deg\"></i> Auto-Sync aktivieren",
1089 question: "Verknuepfte Sprachversionen wieder auf <strong>Auto-Sync</strong> stellen?",
1090 hint: "Manuelle Aenderungen in Slave-Sprachen bleiben erhalten, werden aber kuenftig wieder vom Master ueberschrieben.",
1091 buttons: "yesno",
1092 labelyes: "Ja, Auto-Sync",
1093 labelno: "Abbrechen",
1094 closable: true,
1095 backdropclose: false,
1096 escclose: true
1097 });
1098 }).then(result => {
1099 if (!result || result.action !== "yes") return null;
1100 return fetchJson(apiUrl(url), {
1101 method: "POST",
1102 headers: { "Content-Type": "application/json" },
1103 body: JSON.stringify({ type, id })
1104 });
1105 }).then(data => {
1106 if (!data) return;
1107 if (!data.ok) throw new Error(data && data.msg ? data.msg : "Auto-Sync konnte nicht gesetzt werden.");
1108 const count = Array.isArray(data.result && data.result.updated) ? data.result.updated.length : 0;
1109 status(root, count > 0 ? "Auto-Sync fuer " + count + " Sprache(n) aktiviert." : "Keine Sprachversion zum Aktualisieren gefunden.", count > 0 ? "success" : "warning");
1110 return loadTree(root, cfg);
1111 }).catch(err => {
1112 status(root, err && err.message ? err.message : "Auto-Sync konnte nicht gesetzt werden.", "error");
1113 });
1114 }
1115
1116 function syncStatusLabel(sync, isMaster) {
1117 if (Number(isMaster) === 1) return "Master";
1118 const s = String(sync || "auto").toLowerCase();
1119 return s === "manual" ? "manuell" : "auto";
1120 }
1121
1122 function runSimpleDeleteConfirm(root, title, question, hint) {
1123 return ensureConfirm().then(ok => {
1124 if (!ok) {
1125 status(root, "Confirm-Lib ist nicht geladen.", "error");
1126 return null;
1127 }
1128 return dbx.confirm.open({
1129 id: "cms-delete-simple-" + Date.now(),
1130 root,
1131 title,
1132 question,
1133 hint,
1134 buttons: "yesno",
1135 labelyes: "<i class=\"bi bi-trash\"></i> Loeschen",
1136 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
1137 closable: true,
1138 backdropclose: false,
1139 escclose: true
1140 });
1141 });
1142 }
1143
1144 function executeLngDelete(root, cfg, type, id, deleteLngs, deleteUrl) {
1145 return fetchJson(apiUrl(deleteUrl), {
1146 method: "POST",
1147 headers: { "Content-Type": "application/json" },
1148 body: JSON.stringify({ id, delete_lngs: deleteLngs })
1149 }).then(data => {
1150 if (!data || !data.ok) {
1151 throw new Error(data && data.msg ? data.msg : "delete failed");
1152 }
1153 const count = Array.isArray(data.deleted) ? data.deleted.length : 0;
1154 const warnings = Array.isArray(data.warnings) ? data.warnings.filter(Boolean) : [];
1155 let msg = count > 1
1156 ? (type === "folder" ? "Ordner" : "Seite") + " in " + count + " Sprachen geloescht."
1157 : (type === "folder" ? "Ordner geloescht." : "Seite geloescht.");
1158 if (warnings.length) {
1159 msg += " Hinweis: " + warnings.join(" ");
1160 }
1161 status(root, msg, warnings.length ? "warning" : "success");
1162 return data;
1163 });
1164 }
1165
1166 function showLngDeleteModal(root, cfg, type, id, label, preview) {
1167 const old = qs(root, "[data-cms-lng-delete-dialog]");
1168 if (old) old.remove();
1169
1170 const items = Array.isArray(preview.items) ? preview.items : [];
1171 const rows = items.map((item, index) => {
1172 const lng = String(item.lng || "").toUpperCase();
1173 const checked = Number(item.checked || 0) === 1;
1174 const deletable = Number(item.deletable || 0) === 1;
1175 const syncLabel = syncStatusLabel(item.lng_sync, item.is_master);
1176 const syncClass = Number(item.is_master) === 1 ? "master" : String(item.lng_sync || "auto").toLowerCase();
1177 const blockReason = String(item.block_reason || "").trim();
1178 const disabled = deletable ? "" : " disabled";
1179 const checkedAttr = checked && deletable ? " checked" : "";
1180 const blockHtml = blockReason
1181 ? `<div class="dbx-cms-lng-warn">${escapeHtml(blockReason)}</div>`
1182 : "";
1183
1184 return `<div class="dbx-cms-lng-row dbx-cms-lng-delete-row" data-lng-delete-row="${index}">
1185 <label class="dbx-cms-lng-check">
1186 <input type="checkbox" data-lng-delete-enabled${disabled}${checkedAttr}>
1187 <strong>${escapeHtml(lng)}</strong>
1188 <span class="dbx-cms-lng-badge is-${escapeHtml(syncClass)}">${escapeHtml(syncLabel)}</span>
1189 <span class="text-muted">${escapeHtml(item.label || "")}</span>
1190 </label>
1191 ${blockHtml}
1192 <input type="hidden" data-lng-delete-code value="${escapeHtml(item.lng || "")}">
1193 </div>`;
1194 }).join("");
1195
1196 const entityLabel = type === "folder" ? "Ordner" : "Seite";
1197 const modal = document.createElement("div");
1198 modal.className = "dbx-cms-lng-dialog";
1199 modal.setAttribute("data-cms-lng-delete-dialog", "1");
1200 modal.innerHTML = `
1201 <div class="dbx-cms-lng-dialog-backdrop" data-cms-lng-delete-close></div>
1202 <div class="dbx-cms-lng-dialog-panel" role="dialog" aria-modal="true" aria-label="Sprachversionen loeschen">
1203 <div class="dbx-cms-lng-dialog-head">
1204 <strong>${escapeHtml(entityLabel)} in mehreren Sprachen loeschen</strong>
1205 <button type="button" class="btn btn-sm btn-outline-secondary" data-cms-lng-delete-close>&times;</button>
1206 </div>
1207 <div class="dbx-cms-lng-dialog-body">
1208 <p class="mb-2">Welche Sprachversionen von <strong>${escapeHtml(label || entityLabel)}</strong> sollen geloescht werden?</p>
1209 <p class="text-muted small mb-3">Auto-Sync-Versionen sind vorausgewaehlt. Manuelle Versionen nur bei Bedarf aktivieren.</p>
1210 ${rows || '<p class="text-muted">Keine verknuepften Sprachversionen gefunden.</p>'}
1211 </div>
1212 <div class="dbx-cms-lng-dialog-foot">
1213 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-lng-delete-close>Abbrechen</button>
1214 <button type="button" class="btn btn-danger btn-sm" data-cms-lng-delete-submit><i class="bi bi-trash"></i> Loeschen</button>
1215 </div>
1216 </div>`;
1217
1218 root.appendChild(modal);
1219
1220 qsa(modal, "[data-cms-lng-delete-close]").forEach(btn => {
1221 btn.addEventListener("click", () => modal.remove());
1222 });
1223
1224 const submit = qs(modal, "[data-cms-lng-delete-submit]");
1225 if (submit) {
1226 submit.addEventListener("click", () => {
1227 const deleteLngs = qsa(modal, "[data-lng-delete-row]").map(row => {
1228 const enabled = qs(row, "[data-lng-delete-enabled]");
1229 const code = qs(row, "[data-lng-delete-code]");
1230 if (!enabled || !enabled.checked || enabled.disabled) return "";
1231 return code ? code.value : "";
1232 }).filter(Boolean);
1233
1234 if (!deleteLngs.length) {
1235 status(root, "Bitte mindestens eine loeschbare Sprachversion auswaehlen.", "error");
1236 return;
1237 }
1238
1239 const deleteUrl = cfgUrl(cfg, type === "folder" ? "deletefolder" : "deletepage");
1240 executeLngDelete(root, cfg, type, id, deleteLngs, deleteUrl).then(data => {
1241 modal.remove();
1242 if (type === "folder") {
1243 hideFolderEditor(root);
1244 const s = state(root);
1245 if (Number(s.selectedFolder || 0) === id) {
1246 setSelectedFolder(root, 0);
1247 setSelectedType(root, "page");
1248 }
1249 } else {
1250 setSelectedPage(root, 0);
1251 setSelectedType(root, "page");
1252 setEditorHtml(root, "");
1253 renderMedia(root, []);
1254 }
1255 return loadTree(root, cfg);
1256 }).catch(err => {
1257 status(root, err && err.message ? err.message : "Loeschen fehlgeschlagen.", "error");
1258 });
1259 });
1260 }
1261 }
1262
1263 function openLngDeleteDialog(root, cfg, type, id, label) {
1264 const previewUrl = cfgUrl(cfg, "lngdeletepreview");
1265 const deleteUrl = cfgUrl(cfg, type === "folder" ? "deletefolder" : "deletepage");
1266 const entityLabel = type === "folder" ? "Ordner" : "Seite";
1267 const title = `<i class="bi bi-trash"></i> ${entityLabel} loeschen`;
1268 const question = `${entityLabel} <strong>${escapeHtml(label || entityLabel)}</strong> wirklich loeschen?`;
1269 const hint = type === "folder"
1270 ? "Der Ordner wird nur geloescht, wenn keine Seiten und keine Unterordner enthalten sind."
1271 : "Die Medien werden aus der Seite geloest, Dateien bleiben im Medienbestand.";
1272
1273 if (!isMasterLngCfg(cfg, root) || !previewUrl || !deleteUrl) {
1274 return runSimpleDeleteConfirm(root, title, question, hint).then(result => {
1275 if (!result || result.action !== "yes") return null;
1276 return executeLngDelete(root, cfg, type, id, [], deleteUrl).then(data => {
1277 if (type === "folder") {
1278 hideFolderEditor(root);
1279 const s = state(root);
1280 if (Number(s.selectedFolder || 0) === id) {
1281 setSelectedFolder(root, 0);
1282 setSelectedType(root, "page");
1283 }
1284 } else {
1285 setSelectedPage(root, 0);
1286 setSelectedType(root, "page");
1287 setEditorHtml(root, "");
1288 renderMedia(root, []);
1289 }
1290 return loadTree(root, cfg);
1291 });
1292 });
1293 }
1294
1295 return fetchJson(apiUrl(previewUrl), {
1296 method: "POST",
1297 headers: { "Content-Type": "application/json" },
1298 body: JSON.stringify({ type, id })
1299 }).then(data => {
1300 if (!data || !data.ok) {
1301 throw new Error(data && data.msg ? data.msg : "Vorschau fehlgeschlagen");
1302 }
1303 const items = Array.isArray(data.preview && data.preview.items) ? data.preview.items : [];
1304 if (items.length <= 1) {
1305 return runSimpleDeleteConfirm(root, title, question, hint).then(result => {
1306 if (!result || result.action !== "yes") return null;
1307 return executeLngDelete(root, cfg, type, id, [], deleteUrl).then(data => {
1308 if (type === "folder") {
1309 hideFolderEditor(root);
1310 const s = state(root);
1311 if (Number(s.selectedFolder || 0) === id) {
1312 setSelectedFolder(root, 0);
1313 setSelectedType(root, "page");
1314 }
1315 } else {
1316 setSelectedPage(root, 0);
1317 setSelectedType(root, "page");
1318 setEditorHtml(root, "");
1319 renderMedia(root, []);
1320 }
1321 return loadTree(root, cfg);
1322 });
1323 });
1324 }
1325 showLngDeleteModal(root, cfg, type, id, label, data.preview || {});
1326 return null;
1327 }).catch(err => {
1328 status(root, err && err.message ? err.message : "Vorschau fehlgeschlagen.", "error");
1329 });
1330 }
1331
1332 function clearCmsLoading(root) {
1333 const anchors = [root, document.body, document.documentElement].filter(Boolean);
1334 anchors.forEach(anchor => {
1335 anchor.classList.remove("dbx-ajax-loading", "is-loading");
1336 anchor.removeAttribute("aria-busy");
1337 anchor.style.removeProperty("filter");
1338 anchor.style.removeProperty("opacity");
1339 });
1340
1341 let el = root;
1342 while (el) {
1343 el.classList.remove("dbx-ajax-loading", "is-loading");
1344 if (el === document.body) break;
1345 el = el.parentElement;
1346 }
1347
1348 qsa(document, ".dbx-ajax-loading, .is-loading, [data-cms-action]").forEach(el => {
1349 el.classList.remove("dbx-ajax-loading", "is-loading");
1350 });
1351
1352 qsa(document, ".jodit-dialog__overlay, .jodit-dialog_overlay, .jodit-ui-modal-overlay, .jodit-popup__overlay, .jodit-icon_loader, .jodit-icon-loader, .jodit-loader, .jodit-ui-loader, .jodit_spinner, .jodit-wait").forEach(el => {
1353 el.style.display = "none";
1354 el.style.background = "transparent";
1355 el.style.boxShadow = "none";
1356 el.style.backdropFilter = "none";
1357 });
1358
1359 qsa(document, ".dbx-loader, .dbx-loading, .dbx-wait, .dbx-backdrop, .modal-backdrop").forEach(el => {
1360 el.classList.remove("show", "active", "dbx-ajax-loading", "is-loading");
1361 el.style.display = "none";
1362 el.style.background = "transparent";
1363 el.style.boxShadow = "none";
1364 el.style.backdropFilter = "none";
1365 });
1366 }
1367
1368 function currentSelectionLabel(id, title, fallback) {
1369 const numericId = Number(id || 0);
1370 const text = String(title || fallback || "").trim();
1371 if (numericId > 0 && text) return "(" + numericId + ") " + text;
1372 if (numericId > 0) return "(" + numericId + ")";
1373 return text || fallback || "";
1374 }
1375
1376 function updateCurrentSelectionTitle(root, type, id, title) {
1377 const el = qs(root, "[data-cms-current-title]");
1378 if (!el) return;
1379 const fallback = type === "folder" ? "Neuer Ordner" : "Keine Seite ausgewaehlt";
1380 el.textContent = currentSelectionLabel(id, title, fallback);
1381 }
1382
1383 function updateViewPageTitle(root, title) {
1384 const el = qs(root, "[data-cms-page-title]");
1385 if (!el) return;
1386 const text = String(title || "").trim();
1387 el.textContent = text !== "" ? text : "Keine Seite ausgewaehlt";
1388 }
1389
1390 function openContentAdmin(root) {
1391 const s = state(root);
1392 const pageId = Number((s.page && (s.page.id || s.page)) || s.selectedPage || 0);
1393 if (!pageId) {
1394 status(root, "Bitte zuerst eine Seite waehlen.", "info");
1395 return;
1396 }
1397
1398 const btn = qs(root, "[data-cms-action='open-admin']");
1399 const base = btn ? String(btn.getAttribute("data-cms-admin-base") || "").trim() : "";
1400 const url = (base || "?dbx_modul=dbxContent_admin&dbx_run1=cms") + "&cid=" + encodeURIComponent(pageId);
1401 const winCfg = {
1402 url: url,
1403 title: "Content bearbeiten",
1404 width: "1320",
1405 height: "860",
1406 modal: "1",
1407 scroll: "1"
1408 };
1409
1410 if (window.dbx && dbx.openWin && typeof dbx.openWin.open === "function") {
1411 dbx.openWin.open(winCfg);
1412 return;
1413 }
1414
1415 window.open(url, "_blank", "noopener");
1416 }
1417
1418 function markDirty(root) {
1419 const s = state(root);
1420 if (s.loading || s.saving || s.suppressDirty) return;
1421 s.dirtyVersion = (Number(s.dirtyVersion || 0) + 1);
1422 setDirty(root, true);
1423
1424 if (root.classList.contains("is-folder-editing")) {
1425 updateCurrentSelectionTitle(root, "folder", getFolderField(root, "id"), getFolderField(root, "name"));
1426 return;
1427 }
1428 updateCurrentSelectionTitle(root, "page", getField(root, "id"), getField(root, "title"));
1429 }
1430
1431 function clearDirtyAfterSave(root) {
1432 const s = state(root);
1433 const version = Number(s.dirtyVersion || 0);
1434 setDirty(root, false);
1435 [80, 300].forEach(delay => {
1436 window.setTimeout(() => {
1437 const current = state(root);
1438 if (!current.loading && !current.saving && Number(current.dirtyVersion || 0) === version) {
1439 setDirty(root, false);
1440 }
1441 }, delay);
1442 });
1443 }
1444
1445 function suppressDirtyFor(root, delay) {
1446 const s = state(root);
1447 const token = Date.now() + Math.random();
1448 s.suppressDirty = true;
1449 s.suppressDirtyToken = token;
1450 window.setTimeout(() => {
1451 const current = state(root);
1452 if (current.suppressDirtyToken === token) {
1453 current.suppressDirty = false;
1454 current.suppressDirtyToken = null;
1455 }
1456 }, Number(delay || 0));
1457 }
1458
1459 function mediaHtml(row) {
1460 row = row || {};
1461 const url = row.url || "";
1462 if (!url) return "";
1463
1464 const mime = String(row.mime || "");
1465 const fileName = String(row.file_name || url);
1466 const title = escapeHtml(row.title || row.alt || fileName || "Medium");
1467 const alt = escapeHtml(row.alt || row.title || fileName || "");
1468
1469 const id = Number(row.id || row.media_id || 0);
1470 const videoUrl = row.embed_url || row.external_url || row.url || "";
1471 const videoDataAttr = isVideoRow(row)
1472 ? ` data-cms-video-url="${escapeHtml(videoUrl)}" data-cms-video-type="${escapeHtml(row.media_type || "")}" data-cms-video-mime="${escapeHtml(row.mime || "")}" data-cms-video-muted="0"`
1473 : "";
1474 const mediaAttr = id > 0 ? ` data-cms-media-id="${id}" data-cms-media-slot="inline"${videoDataAttr}` : ` data-cms-media-slot="inline"${videoDataAttr}`;
1475
1476 if (mime.startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(fileName)) {
1477 return `<p class="dbx-cms-inline-media" data-cms-media-slot="inline"><img class="dbx-cms-inline-image" src="${escapeHtml(url)}" alt="${alt}" title="${title}"${mediaAttr}></p><p></p>`;
1478 }
1479
1480 if (isExternalVideoRow(row)) {
1481 return `<figure class="dbx-cms-inline-media dbx-cms-inline-video-block"${mediaAttr}>${mediaPlayerInnerHtml(row, id)}</figure><p></p>`;
1482 }
1483
1484 if (mime.startsWith("video/") || /\.(mp4|webm|ogv|ogg|mov|m4v)$/i.test(fileName)) {
1485 return `<figure class="dbx-cms-inline-media dbx-cms-inline-video-block"${mediaAttr}>${mediaPlayerInnerHtml(row, id)}</figure><p></p>`;
1486 }
1487
1488 return `<p><a href="${escapeHtml(url)}" target="_blank" rel="noopener">${title}</a></p>`;
1489 }
1490
1491 function cmsMarkerName(marker) {
1492 return String(marker || "").replace(/^dbx:/, "") || "marker";
1493 }
1494
1495 function cmsMarkerClassName(marker) {
1496 return cmsMarkerName(marker).replace(/[^a-z0-9_-]+/gi, "-") || "marker";
1497 }
1498
1499 function cmsMarkerLabel(marker, label) {
1500 const labels = {
1501 "dbx:hero": "Hero-Text",
1502 "dbx:split": "col-2 Trenner",
1503 "dbx:col2": "col-2 Trenner",
1504 "dbx:col3a": "col-3a Trenner",
1505 "dbx:col3b": "col-3b Trenner",
1506 "dbx:header": "Header",
1507 "dbx:teaser": "Header",
1508 "dbx:footer": "Footer",
1509 "dbx:pagebreak": "Druck Seitenumbruch"
1510 };
1511 return label || labels[marker] || marker || "dbx:marker";
1512 }
1513
1514 function cmsMarkerHtml(marker, label) {
1515 if (marker === "dbx:split") marker = "dbx:col2";
1516 const name = cmsMarkerName(marker);
1517 const className = cmsMarkerClassName(marker);
1518 return `<hr class="dbx-cms-marker dbx-cms-marker-${escapeHtml(className)}" contenteditable="false" draggable="false" tabindex="0" data-dbx-marker="dbx:${escapeHtml(name)}" data-label="${escapeHtml(cmsMarkerLabel(marker, label))}">`;
1519 }
1520
1521 function cmsMarkerElement(marker, label, doc) {
1522 const tpl = (doc || document).createElement("template");
1523 tpl.innerHTML = cmsMarkerHtml(marker, label);
1524 return tpl.content.firstElementChild;
1525 }
1526
1527 function markerNameFromElement(node) {
1528 if (!node || node.nodeType !== 1 || !node.getAttribute) return "";
1529 const raw = node.getAttribute("data-dbx-marker") || node.getAttribute("data-dbx-marker-comment") || "";
1530 return raw ? cmsMarkerName(raw) : "";
1531 }
1532
1533 function ignorableMarkerSibling(node) {
1534 if (!node) return false;
1535 if (node.nodeType === 3) return String(node.nodeValue || "").replace(/\uFEFF/g, "").trim() === "";
1536 if (node.nodeType !== 1) return false;
1537 const tag = node.tagName || "";
1538 if (tag === "BR") return true;
1539 if (!/^(P|DIV|SPAN)$/i.test(tag)) return false;
1540 if (node.querySelector && node.querySelector(".dbx-cms-marker,[data-dbx-marker],img,video,iframe,table,hr")) return false;
1541 return String(node.textContent || "").replace(/\uFEFF/g, "").trim() === "";
1542 }
1543
1544 function nearbyMarkerSibling(node, dir) {
1545 let cur = dir < 0 ? node?.previousSibling : node?.nextSibling;
1546 while (cur && ignorableMarkerSibling(cur)) cur = dir < 0 ? cur.previousSibling : cur.nextSibling;
1547 return cur && cur.nodeType === 1 ? cur : null;
1548 }
1549
1550 function hasSameMarkerNeighbor(node, name) {
1551 return markerNameFromElement(nearbyMarkerSibling(node, -1)) === name ||
1552 markerNameFromElement(nearbyMarkerSibling(node, 1)) === name;
1553 }
1554
1555 function dedupeAdjacentMarkers(container) {
1556 if (!container) return;
1557 qsa(container, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
1558 if (!marker.parentNode) return;
1559 const name = markerNameFromElement(marker);
1560 if (!name) return;
1561 const prev = nearbyMarkerSibling(marker, -1);
1562 if (markerNameFromElement(prev) === name) marker.remove();
1563 });
1564 }
1565
1566 function normalizeCommentMarkers(container) {
1567 if (!container) return;
1568 const doc = container.ownerDocument || document;
1569 const comments = [];
1570 (function collect(node) {
1571 Array.from(node && node.childNodes || []).forEach(child => {
1572 if (child.nodeType === 8) comments.push(child);
1573 else collect(child);
1574 });
1575 })(container);
1576 comments.forEach(node => {
1577 const text = String(node.nodeValue || "").trim();
1578 const match = text.match(/^dbx:([a-z0-9_-]+)/i);
1579 if (match) {
1580 const name = cmsMarkerName("dbx:" + match[1].toLowerCase());
1581 if (node.parentNode && hasSameMarkerNeighbor(node, name)) {
1582 node.parentNode.removeChild(node);
1583 return;
1584 }
1585 const marker = cmsMarkerElement("dbx:" + name, null, doc);
1586 if (marker && node.parentNode) node.parentNode.replaceChild(marker, node);
1587 return;
1588 }
1589 if (node.parentNode) node.parentNode.removeChild(node);
1590 });
1591 dedupeAdjacentMarkers(container);
1592 }
1593
1594 function cmsMarkerMenuIcon() {
1595 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M4 4h12.5a1 1 0 0 1 .8 1.6L14.9 9l2.4 3.4a1 1 0 0 1-.8 1.6H6v6H4V4Zm2 2v6h8.55l-1.7-2.4a1 1 0 0 1 0-1.16L14.55 6H6Z"/></svg>';
1596 }
1597
1598 function cmsHrIcon() {
1599 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M4 11h16v2H4v-2Zm2-5h12v2H6V6Zm0 10h12v2H6v-2Z"/></svg>';
1600 }
1601
1602 function cmsSaveIcon() {
1603 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5 3h13l1 1v17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm0 2v14h12V5h-2v5H7V5H5Zm4 0v3h4V5H9Zm-1 8h8v2H8v-2Zm0 3h8v2H8v-2Z"/></svg>';
1604 }
1605
1606 function cmsTextStyleIcon() {
1607 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5 4h7.2c2.7 0 4.5 1.5 4.5 3.8 0 1.25-.57 2.25-1.57 2.9 1.42.57 2.27 1.83 2.27 3.55 0 2.55-1.95 4.25-4.9 4.25H5V4Zm3 2.6v3.1h3.8c1.18 0 1.85-.57 1.85-1.55 0-1-.67-1.55-1.85-1.55H8Zm0 5.55v3.75h4.22c1.35 0 2.1-.68 2.1-1.88 0-1.18-.75-1.87-2.1-1.87H8Z"/><path d="M19 5h2v14h-2V5Z"/></svg>';
1608 }
1609
1610 function cmsModBrowserIcon() {
1611 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect x="3" y="4" width="8" height="6" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="13" y="4" width="8" height="6" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3" y="14" width="18" height="6" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M6 7h2M15 7h2M6 17h12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
1612 }
1613
1614 function modPlaceholderFromItem(item) {
1615 return {
1616 id: item?.id || "",
1617 url: item?.url || "",
1618 label: item?.label || "",
1619 default_modul: item?.default_modul || "",
1620 default_params: item?.default_params || "",
1621 default_alt: item?.default_alt || item?.label || "Modul"
1622 };
1623 }
1624
1625 function modPlaceholderHtml(item, insertId) {
1626 const row = modPlaceholderFromItem(item);
1627 const url = row.url || "";
1628 const alt = row.default_alt || "Modul";
1629 const modul = row.default_modul || "";
1630 const params = row.default_params || "";
1631 const modAttr = modul ? ` data-cms-mod-modul="${escapeHtml(modul)}"` : "";
1632 const parAttr = params ? ` data-cms-mod-params="${escapeHtml(params)}"` : "";
1633 const insertAttr = insertId ? ` data-cms-mod-insert-id="${escapeHtml(insertId)}"` : "";
1634 return `<p class="dbx-cms-mod-placeholder dbx-cms-inline-media" contenteditable="false" draggable="true"${insertAttr}${modAttr}${parAttr}><img class="dbx-cms-mod-image" src="${escapeHtml(url)}" alt="${escapeHtml(alt)}"${modAttr}${parAttr} style="width:100%;max-width:100%;height:auto;display:block;" contenteditable="false"></p><p></p>`;
1635 }
1636
1637 function inlineModTarget(root, target) {
1638 const surface = editorSurface(root);
1639 const mod = closestElement(target, ".dbx-cms-mod-placeholder");
1640 return mod && surface && surface.contains(mod) ? mod : null;
1641 }
1642
1643 function normalizeModPlaceholders(container) {
1644 if (!container) return;
1645 qsa(container, ".dbx-cms-mod-placeholder").forEach(wrap => {
1646 wrap.setAttribute("contenteditable", "false");
1647 wrap.classList.add("dbx-cms-inline-media");
1648 const img = qs(wrap, "img") || qs(wrap, ".dbx-cms-mod-image");
1649 if (!img) return;
1650 img.classList.add("dbx-cms-mod-image");
1651 img.style.width = "100%";
1652 img.style.maxWidth = "100%";
1653 img.style.height = "auto";
1654 img.style.display = "block";
1655 img.setAttribute("contenteditable", "false");
1656 img.removeAttribute("data-dbx");
1657 const modul = wrap.getAttribute("data-cms-mod-modul") || "";
1658 const params = wrap.getAttribute("data-cms-mod-params") || "";
1659 if (modul) {
1660 img.setAttribute("data-cms-mod-modul", modul);
1661 wrap.setAttribute("data-cms-mod-modul", modul);
1662 }
1663 if (params) {
1664 img.setAttribute("data-cms-mod-params", params);
1665 wrap.setAttribute("data-cms-mod-params", params);
1666 }
1667 const alt = img.getAttribute("alt") || "";
1668 if (alt) wrap.setAttribute("title", alt);
1669 });
1670 }
1671
1672 function modPlaceholderValues(wrapper) {
1673 const img = wrapper ? (qs(wrapper, "img") || qs(wrapper, ".dbx-cms-mod-image")) : null;
1674 return {
1675 modul: wrapper?.getAttribute("data-cms-mod-modul") || img?.getAttribute("data-cms-mod-modul") || "",
1676 params: wrapper?.getAttribute("data-cms-mod-params") || img?.getAttribute("data-cms-mod-params") || "",
1677 alt: img?.getAttribute("alt") || wrapper?.getAttribute("title") || ""
1678 };
1679 }
1680
1681 function selectEditorModPlaceholder(root, wrap) {
1682 const surface = editorSurface(root);
1683 qsa(surface, ".dbx-cms-mod-placeholder.is-selected").forEach(el => {
1684 el.classList.remove("is-selected");
1685 el.removeAttribute("aria-selected");
1686 });
1687 if (wrap && surface && surface.contains(wrap)) {
1688 selectEditorMarker(root, null);
1689 selectEditorMissingMedia(root, null);
1690 wrap.classList.add("is-selected");
1691 wrap.setAttribute("aria-selected", "true");
1692 state(root).selectedModPlaceholder = wrap;
1693 hideEditorCaretHint(root);
1694 selectEditorNode(root, wrap);
1695 } else {
1696 state(root).selectedModPlaceholder = null;
1697 }
1698 }
1699
1700 function isEmptyEditorParagraph(el) {
1701 return !!(el && el.nodeType === 1 && /^P$/i.test(el.tagName || "") && !nodeHasEditorContent(el));
1702 }
1703
1704 function removeEditorModPlaceholder(root, wrap) {
1705 wrap = wrap || state(root).selectedModPlaceholder;
1706 const surface = editorSurface(root);
1707 if (!wrap || !wrap.parentNode || !surface || !surface.contains(wrap)) return false;
1708 const next = wrap.nextElementSibling;
1709 selectEditorModPlaceholder(root, null);
1710 wrap.remove();
1711 if (isEmptyEditorParagraph(next)) next.remove();
1712 syncEditorDom(root);
1713 markDirty(root);
1714 return true;
1715 }
1716
1717 function insertModPlaceholder(root, item, cfg) {
1718 const insertId = "mod_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8);
1719 const html = modPlaceholderHtml(item, insertId);
1720 if (!html) return false;
1721 insertEditorHtml(root, html);
1722 const surface = editorSurface(root);
1723 normalizeModPlaceholders(surface);
1724 setField(root, "content", getEditorHtml(root));
1725 markDirty(root);
1726 scheduleEditorHeight(root);
1727 const inserted = qs(surface, `.dbx-cms-mod-placeholder[data-cms-mod-insert-id="${insertId}"]`);
1728 if (inserted) {
1729 inserted.removeAttribute("data-cms-mod-insert-id");
1730 selectEditorModPlaceholder(root, inserted);
1731 if (inserted.scrollIntoView) inserted.scrollIntoView({ block: "center", inline: "nearest" });
1732 window.setTimeout(() => openModPlaceholderOptions(root, inserted, cfg || cmsConfig(root) || {}), 0);
1733 }
1734 return true;
1735 }
1736
1737 function slashCommandHit(root, token) {
1738 const surface = editorSurface(root);
1739 const doc = surface ? (surface.ownerDocument || document) : document;
1740 const sel = doc.getSelection ? doc.getSelection() : null;
1741 if (!surface || !sel || !sel.rangeCount || !sel.isCollapsed) return null;
1742 const range = sel.getRangeAt(0);
1743 if (!rangeInsideSurface(surface, range)) return null;
1744 const node = range.startContainer;
1745 const offset = range.startOffset || 0;
1746 if (!node || node.nodeType !== 3 || offset < token.length) return null;
1747 const before = String(node.nodeValue || "").slice(offset - token.length, offset);
1748 if (before.toLowerCase() !== token.toLowerCase()) return null;
1749 return { node, start: offset - token.length, end: offset };
1750 }
1751
1752 function removeSlashCommandHit(root, hit) {
1753 if (!hit || !hit.node) return false;
1754 const text = String(hit.node.nodeValue || "");
1755 hit.node.nodeValue = text.slice(0, hit.start) + text.slice(hit.end);
1756 const surface = editorSurface(root);
1757 const doc = surface ? (surface.ownerDocument || document) : document;
1758 const sel = doc.getSelection ? doc.getSelection() : null;
1759 if (sel && doc.createRange) {
1760 const range = doc.createRange();
1761 range.setStart(hit.node, hit.start);
1762 range.collapse(true);
1763 sel.removeAllRanges();
1764 sel.addRange(range);
1765 state(root).editorRange = range.cloneRange();
1766 }
1767 syncEditorAfterContextAction(root);
1768 setField(root, "content", getEditorHtml(root));
1769 return true;
1770 }
1771
1772 function handleEditorSlashCommands(root, cfg) {
1773 if (!root || root.__dbxCmsSlashModOpening) return;
1774 const hit = slashCommandHit(root, "/mod");
1775 if (!hit) return;
1776 root.__dbxCmsSlashModOpening = true;
1777 removeSlashCommandHit(root, hit);
1778 saveEditorSelection(root);
1779 openModBrowser(root, cfg || cmsConfig(root) || {});
1780 window.setTimeout(() => {
1781 root.__dbxCmsSlashModOpening = false;
1782 }, 500);
1783 }
1784
1785 function bindEditorSlashCommands(root, cfg) {
1786 const surface = editorSurface(root);
1787 if (!surface || surface.__dbxCmsSlashCommandsBound) return;
1788 surface.__dbxCmsSlashCommandsBound = true;
1789 const run = () => window.setTimeout(() => handleEditorSlashCommands(root, cfg || {}), 0);
1790 surface.addEventListener("input", run);
1791 surface.addEventListener("keyup", e => {
1792 if (e.ctrlKey || e.metaKey || e.altKey) return;
1793 if (e.key === "d" || e.key === "D" || e.key === "Enter" || e.key === " ") run();
1794 });
1795 }
1796
1797 function mediaRowFromItem(item) {
1798 return {
1799 url: item?.getAttribute("data-url") || "",
1800 mime: item?.getAttribute("data-mime") || "",
1801 file_name: item?.getAttribute("data-file-name") || "",
1802 file_path: item?.getAttribute("data-file-path") || "",
1803 thumb_url: item?.getAttribute("data-thumb-url") || "",
1804 media_type: item?.getAttribute("data-media-type") || "",
1805 width: item?.getAttribute("data-width") || "",
1806 height: item?.getAttribute("data-height") || "",
1807 title: item?.getAttribute("data-title") || "",
1808 alt: item?.getAttribute("data-alt") || "",
1809 slot: item?.getAttribute("data-slot") || "",
1810 media_folder: item?.getAttribute("data-media-folder") || "",
1811 id: item?.getAttribute("data-media-id") || ""
1812 };
1813 }
1814
1815 function currentMediaSlot(root) {
1816 const filter = qs(root, "[data-cms-media-filter]");
1817 const value = filter ? filter.value : state(root).mediaFilter;
1818 return value && value !== "all" ? value : "gallery";
1819 }
1820
1821 function syncUploadSlot(root) {
1822 const input = qs(root, "[data-cms-upload-slot]");
1823 if (input) input.value = currentMediaSlot(root);
1824 const externalInput = qs(root, "[data-cms-external-video-slot]");
1825 if (externalInput) externalInput.value = currentMediaSlot(root);
1826 }
1827
1828 function insertMediaRow(root, row) {
1829 const html = mediaHtml(row);
1830 if (!html) return;
1831 leaveInlineMediaCaption(root);
1832 if (!insertEditorFragment(root, html)) insertEditorHtml(root, html);
1833 }
1834
1835 function applyInlineMediaAssignment(root, row) {
1836 if (!row || !row.url) return;
1837 insertMediaRow(root, row);
1838 setLocalMediaSlot(root, row.id, "inline");
1839 markDirty(root);
1840 }
1841
1842 function mediaRowWithUsage(row, usage, fallbackSlot) {
1843 const out = Object.assign({}, row || {});
1844 usage = usage || {};
1845 if (usage.id) out.usage_id = usage.id;
1846 if (usage.id) out.current_usage_id = usage.id;
1847 out.slot = usage.slot || out.slot || fallbackSlot || "gallery";
1848 if (usage.sorter) out.sorter = usage.sorter;
1849 if (usage.template) out.template = usage.template;
1850 if (usage.caption) out.caption = usage.caption;
1851 return out;
1852 }
1853
1854 function upsertLocalMediaRow(root, row) {
1855 const id = Number(row && row.id || 0);
1856 if (!id) return;
1857 const s = state(root);
1858 const rows = Array.isArray(s.mediaRows) ? s.mediaRows.slice() : [];
1859 const pos = rows.findIndex(item => Number(item.id || 0) === id);
1860 if (pos >= 0) rows[pos] = Object.assign({}, rows[pos], row);
1861 else rows.push(row);
1862 renderMedia(root, rows);
1863 }
1864
1865 function setLocalMediaSlot(root, id, slot) {
1866 id = Number(id || 0);
1867 if (!id) return;
1868 const s = state(root);
1869 const row = (s.mediaRows || []).find(item => Number(item.id || 0) === id);
1870 if (!row) return;
1871 row.slot = slot;
1872 row.usage = slot;
1873 renderMedia(root);
1874 }
1875
1876 function inlineVideoDataAttributes(row, id, options) {
1877 row = row || {};
1878 options = options || {};
1879 const videoUrl = row.embed_url || row.external_url || row.url || "";
1880 const attrs = [
1881 `data-cms-media-id="${id}"`,
1882 'data-cms-media-slot="inline"',
1883 `data-cms-video-url="${escapeHtml(videoUrl)}"`,
1884 `data-cms-video-type="${escapeHtml(row.media_type || "")}"`,
1885 `data-cms-video-mime="${escapeHtml(row.mime || "")}"`,
1886 `data-cms-video-autoplay="${options.autoplay ? "1" : "0"}"`,
1887 `data-cms-video-loop="${options.loop ? "1" : "0"}"`,
1888 `data-cms-video-muted="${options.muted ? "1" : "0"}"`
1889 ];
1890 if (options.width) attrs.push(`data-cms-video-width="${escapeHtml(options.width)}"`);
1891 if (options.height) attrs.push(`data-cms-video-height="${escapeHtml(options.height)}"`);
1892 return " " + attrs.join(" ");
1893 }
1894
1895 function inlineVideoOptionsFromElement(el) {
1896 const media = el ? qs(el, ".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty,video,iframe,img") : null;
1897 const attr = name => String((el && el.getAttribute && el.getAttribute(name)) || (media && media.getAttribute && media.getAttribute(name)) || "");
1898 return {
1899 width: cssSizeValue(attr("data-cms-video-width") || el?.style?.width || media?.style?.width || ""),
1900 height: cssSizeValue(attr("data-cms-video-height") || el?.style?.height || media?.style?.height || ""),
1901 autoplay: attr("data-cms-video-autoplay") === "1",
1902 loop: attr("data-cms-video-loop") === "1",
1903 muted: attr("data-cms-video-muted") === "1"
1904 };
1905 }
1906
1907 function syncInlineVideoOptionsToMedia(wrapper, options) {
1908 if (!wrapper) return;
1909 options = options || {};
1910 const targets = [wrapper].concat(qsa(wrapper, ".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty,.dbx-cms-inline-video-player,video,iframe,img"));
1911 targets.forEach(el => {
1912 if (!el || !el.setAttribute) return;
1913 if (options.width) el.setAttribute("data-cms-video-width", options.width);
1914 else el.removeAttribute("data-cms-video-width");
1915 if (options.height) el.setAttribute("data-cms-video-height", options.height);
1916 else el.removeAttribute("data-cms-video-height");
1917 el.setAttribute("data-cms-video-autoplay", options.autoplay ? "1" : "0");
1918 el.setAttribute("data-cms-video-loop", options.loop ? "1" : "0");
1919 el.setAttribute("data-cms-video-muted", options.muted ? "1" : "0");
1920 });
1921 }
1922
1923 function inlineVideoOptionsButtonHtml() {
1924 return '<button type="button" class="dbx-cms-inline-video-options-btn" data-cms-inline-video-options-open contenteditable="false" tabindex="-1" title="Video Optionen" aria-label="Video Optionen"><i class="bi bi-sliders"></i></button>';
1925 }
1926
1927 function mediaPlayerInnerHtml(row, id, options) {
1928 row = row || {};
1929 id = Number(id || row.id || row.media_id || 0);
1930 if (id <= 0) return "";
1931 const title = escapeHtml(row.title || row.alt || row.file_name || "Video");
1932 const thumb = row.thumb_url || "";
1933 const mediaAttr = inlineVideoDataAttributes(row, id, options || {});
1934 const img = thumb
1935 ? `<img class="dbx-cms-inline-video-thumb" src="${escapeHtml(thumb)}" alt="${title}" title="${title}"${mediaAttr}>`
1936 : `<span class="dbx-cms-inline-video-empty"${mediaAttr}>${title}</span>`;
1937 return `${img}<span class="dbx-cms-inline-video-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>${inlineVideoOptionsButtonHtml()}`;
1938 }
1939
1940 function inlineVideoTarget(root, target) {
1941 const surface = editorSurface(root);
1942 if (!surface || !target) return null;
1943 const direct = closestElement(target, ".dbx-cms-inline-video-block");
1944 if (direct && surface.contains(direct)) return direct;
1945 const media = closestElement(target, ".dbx-cms-inline-video-player,iframe,video");
1946 return media && surface && surface.contains(media) ? media : null;
1947 }
1948
1949 function inlineVideoTargetAtPoint(root, e) {
1950 const surface = editorSurface(root);
1951 if (!surface || !e || !Number.isFinite(Number(e.clientX)) || !Number.isFinite(Number(e.clientY))) return null;
1952 const x = Number(e.clientX);
1953 const y = Number(e.clientY);
1954 const selectors = ".dbx-cms-inline-video-block,.dbx-cms-inline-video-player,iframe,video";
1955 return qsa(surface, selectors).find(el => {
1956 const target = closestElement(el, ".dbx-cms-inline-video-block") || el;
1957 const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : null;
1958 return rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
1959 }) || null;
1960 }
1961
1962 function inlineVideoEventTarget(root, e) {
1963 return inlineVideoTarget(root, e && e.target) || inlineVideoTargetAtPoint(root, e);
1964 }
1965
1966 function cmsRootFromEditorElement(el) {
1967 if (!el) return null;
1968 const direct = closestElement(el, ".dbx-cms");
1969 if (direct) return direct;
1970 return qsa(document, ".dbx-cms[data-dbx]").find(root => {
1971 const surface = editorSurface(root);
1972 return root.contains(el) || (surface && surface.contains(el));
1973 }) || null;
1974 }
1975
1976 function isInlineVideoResizeHandleEvent(wrapper, e) {
1977 if (!wrapper || !e || !wrapper.getBoundingClientRect) return false;
1978 const rect = wrapper.getBoundingClientRect();
1979 const grip = 22;
1980 return e.clientX >= rect.right - grip && e.clientY >= rect.bottom - grip;
1981 }
1982
1983 function cssSizeValue(value) {
1984 value = String(value || "").trim();
1985 if (!value) return "";
1986 if (/^\d+(\.\d+)?$/.test(value)) return value + "px";
1987 if (/^\d+(\.\d+)?(px|%|vw|vh|rem|em)$/i.test(value)) return value;
1988 if (/^auto$/i.test(value)) return "auto";
1989 return "";
1990 }
1991
1992 function openInlineVideoOptions(root, media) {
1993 if (!media) return false;
1994 const modal = ensureInlineVideoOptionsDialog(root);
1995 modal.__dbxCmsInlineVideo = media;
1996 const options = inlineVideoOptionsFromElement(media);
1997 const width = qs(modal, "[data-cms-video-options-width]");
1998 const height = qs(modal, "[data-cms-video-options-height]");
1999 const autoplay = qs(modal, "[data-cms-video-options-autoplay]");
2000 const loop = qs(modal, "[data-cms-video-options-loop]");
2001 const muted = qs(modal, "[data-cms-video-options-muted]");
2002 if (width) width.value = options.width || "";
2003 if (height) height.value = options.height || "";
2004 if (autoplay) autoplay.checked = !!options.autoplay;
2005 if (loop) loop.checked = !!options.loop;
2006 if (muted) muted.value = options.muted ? "1" : "0";
2007 modal.hidden = false;
2008 openInlineVideoOptionsWindow(root, modal);
2009 return true;
2010 }
2011
2012 function applyInlineVideoOptions(root, modal) {
2013 const media = modal && modal.__dbxCmsInlineVideo;
2014 if (!media) return false;
2015 const width = cssSizeValue(qs(modal, "[data-cms-video-options-width]")?.value || "");
2016 const height = cssSizeValue(qs(modal, "[data-cms-video-options-height]")?.value || "");
2017 const autoplay = qs(modal, "[data-cms-video-options-autoplay]")?.checked;
2018 const loop = qs(modal, "[data-cms-video-options-loop]")?.checked;
2019 let muted = qs(modal, "[data-cms-video-options-muted]")?.value === "1";
2020 if (autoplay) muted = true;
2021 const options = {
2022 width,
2023 height,
2024 autoplay: !!autoplay,
2025 loop: !!loop,
2026 muted: !!muted
2027 };
2028 if (width) {
2029 media.style.width = width;
2030 media.setAttribute("data-cms-video-width", width);
2031 } else {
2032 media.style.width = "";
2033 media.removeAttribute("data-cms-video-width");
2034 }
2035 if (height) {
2036 media.style.height = height;
2037 media.setAttribute("data-cms-video-height", height);
2038 } else {
2039 media.style.height = "";
2040 media.removeAttribute("data-cms-video-height");
2041 }
2042 media.setAttribute("data-cms-video-autoplay", autoplay ? "1" : "0");
2043 media.setAttribute("data-cms-video-loop", loop ? "1" : "0");
2044 media.setAttribute("data-cms-video-muted", muted ? "1" : "0");
2045 syncInlineVideoOptionsToMedia(media, options);
2046 syncEditorAfterContextAction(root);
2047 markDirty(root);
2048 closeInlineVideoOptionsWindow(modal);
2049 return true;
2050 }
2051
2052 function closeInlineVideoOptionsWindow(modal) {
2053 if (!modal) return;
2054 const winId = modal.__dbxCmsWindowId || "";
2055 modal.__dbxCmsInlineVideo = null;
2056 modal.hidden = true;
2057 modal.classList.remove("is-open");
2058 modal.__dbxCmsWindowId = null;
2059 modal.removeAttribute("role");
2060 modal.removeAttribute("aria-modal");
2061 modal.removeAttribute("aria-label");
2062 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
2063 dbx.openWin.close(winId);
2064 return;
2065 }
2066 }
2067
2068 function ensureInlineVideoOptionsDialog(root) {
2069 const s = state(root);
2070 let modal = s.videoOptionsModal || qs(root, "[data-cms-video-options]");
2071 if (modal) {
2072 modal.__dbxCmsRoot = root;
2073 s.videoOptionsModal = modal;
2074 if (!qs(modal, "[data-cms-video-options-loop]")) {
2075 const autoplayLabel = qs(modal, "[data-cms-video-options-autoplay]")?.closest("label");
2076 if (autoplayLabel && autoplayLabel.parentNode) {
2077 const loopLabel = document.createElement("label");
2078 loopLabel.className = "dbx-cms-video-options-check";
2079 loopLabel.innerHTML = '<input type="checkbox" data-cms-video-options-loop> Auto Loop';
2080 autoplayLabel.insertAdjacentElement("afterend", loopLabel);
2081 }
2082 }
2083 return modal;
2084 }
2085 modal = document.createElement("div");
2086 modal.className = "dbx-cms-video-options";
2087 modal.setAttribute("data-cms-video-options", "1");
2088 modal.__dbxCmsRoot = root;
2089 modal.hidden = true;
2090 modal.innerHTML = `
2091 <div class="dbx-cms-video-options-body">
2092 <label>Breite <input type="text" class="form-control form-control-sm" data-cms-video-options-width placeholder="z.B. 640px, 80%, leer = Standard"></label>
2093 <label>Hoehe <input type="text" class="form-control form-control-sm" data-cms-video-options-height placeholder="z.B. 240px, leer = 16:9"></label>
2094 <label class="dbx-cms-video-options-check"><input type="checkbox" data-cms-video-options-autoplay> Autoplay</label>
2095 <label class="dbx-cms-video-options-check"><input type="checkbox" data-cms-video-options-loop> Auto Loop</label>
2096 <label>Ton <select class="form-select form-select-sm" data-cms-video-options-muted><option value="0">Ton an</option><option value="1">Ton aus</option></select></label>
2097 </div>
2098 <div class="dbx-cms-video-options-actions">
2099 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-video-options-close><i class="bi bi-x-lg"></i><span>Abbrechen</span></button>
2100 <button type="button" class="btn btn-primary btn-sm" data-cms-video-options-apply><i class="bi bi-check2"></i><span>Uebernehmen</span></button>
2101 </div>`;
2102 modal.addEventListener("click", e => {
2103 if (closestElement(e.target, "[data-cms-video-options-close]")) {
2104 e.preventDefault();
2105 e.stopPropagation();
2106 closeInlineVideoOptionsWindow(modal);
2107 return;
2108 }
2109 if (closestElement(e.target, "[data-cms-video-options-apply]")) {
2110 e.preventDefault();
2111 e.stopPropagation();
2112 applyInlineVideoOptions(root, modal);
2113 }
2114 });
2115 root.appendChild(modal);
2116 s.videoOptionsModal = modal;
2117 return modal;
2118 }
2119
2120 function bindInlineVideoOptionsDocumentEvents() {
2121 if (document.__dbxCmsVideoOptionsEventsBound) return;
2122 document.__dbxCmsVideoOptionsEventsBound = true;
2123 const openOptionsFromButton = e => {
2124 if (e.button !== undefined && e.button !== 0) return false;
2125 const openBtn = closestElement(e.target, "[data-cms-inline-video-options-open]");
2126 if (!openBtn) return false;
2127 const root = cmsRootFromEditorElement(openBtn);
2128 const video = closestElement(openBtn, ".dbx-cms-inline-video-block");
2129 const surface = root ? editorSurface(root) : null;
2130 if (!root || !video || (surface && !surface.contains(video))) return false;
2131 e.preventDefault();
2132 e.stopPropagation();
2133 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
2134 const now = Date.now();
2135 if (!openBtn.__dbxCmsVideoOptionsOpenedAt || now - openBtn.__dbxCmsVideoOptionsOpenedAt > 250) {
2136 openBtn.__dbxCmsVideoOptionsOpenedAt = now;
2137 openInlineVideoOptions(root, video);
2138 }
2139 return true;
2140 };
2141 document.addEventListener("pointerdown", openOptionsFromButton, true);
2142 document.addEventListener("mousedown", openOptionsFromButton, true);
2143 document.addEventListener("click", e => {
2144 if (openOptionsFromButton(e)) return;
2145 const closeBtn = closestElement(e.target, "[data-cms-video-options-close]");
2146 const applyBtn = closestElement(e.target, "[data-cms-video-options-apply]");
2147 if (!closeBtn && !applyBtn) return;
2148 const modal = closestElement(e.target, "[data-cms-video-options]");
2149 if (!modal) return;
2150 e.preventDefault();
2151 e.stopPropagation();
2152 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
2153 if (closeBtn) {
2154 closeInlineVideoOptionsWindow(modal);
2155 return;
2156 }
2157 const root = modal.__dbxCmsRoot || closestElement(modal, ".dbx-cms");
2158 if (root) applyInlineVideoOptions(root, modal);
2159 else closeInlineVideoOptionsWindow(modal);
2160 }, true);
2161 }
2162
2163 function openInlineVideoOptionsWindow(root, modal) {
2164 if (!modal) return false;
2165 const currentWindow = closestElement(modal, ".dbx-window");
2166 const oldOptionsWindowId = modal.__dbxCmsWindowId || "";
2167 if (currentWindow && currentWindow.parentNode) {
2168 document.body.appendChild(modal);
2169 if (oldOptionsWindowId && currentWindow.id === oldOptionsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
2170 dbx.openWin.close(oldOptionsWindowId);
2171 }
2172 } else if (modal.parentNode !== document.body) {
2173 document.body.appendChild(modal);
2174 }
2175 modal.__dbxCmsRoot = root;
2176 modal.__dbxCmsWindowId = null;
2177 modal.hidden = false;
2178 modal.classList.add("is-open");
2179 modal.setAttribute("role", "dialog");
2180 modal.setAttribute("aria-modal", "true");
2181 modal.setAttribute("aria-label", "Video Optionen");
2182 const first = qs(modal, "[data-cms-video-options-width]") || qs(modal, "input,select,button");
2183 if (first && first.focus) window.setTimeout(() => first.focus({ preventScroll: true }), 0);
2184 return true;
2185 }
2186
2187 function repairInlineVideoPlayers(root, container) {
2188 if (!container) return;
2189 const rows = state(root).mediaRows || [];
2190 qsa(container, ".dbx-cms-inline-media[data-cms-media-id]").forEach(wrapper => {
2191 const id = Number(wrapper.getAttribute("data-cms-media-id") || 0);
2192 if (!id) return;
2193 const row = rows.find(item => Number(item.id || item.media_id || 0) === id) || null;
2194 const needsVideo = wrapper.classList.contains("dbx-cms-inline-video-block") || (row && isVideoRow(row));
2195 if (!needsVideo || wrapper.querySelector(".dbx-cms-inline-video-thumb,.dbx-cms-inline-video-empty")) return;
2196 wrapper.classList.add("dbx-cms-inline-video-block");
2197 wrapper.removeAttribute("contenteditable");
2198 const options = inlineVideoOptionsFromElement(wrapper);
2199 wrapper.innerHTML = mediaPlayerInnerHtml(row, id, options);
2200 syncInlineVideoOptionsToMedia(wrapper, options);
2201 });
2202 }
2203
2204 function repairInlineVideoHtml(root, html) {
2205 const wrap = document.createElement("div");
2206 wrap.innerHTML = String(html || "");
2207 repairInlineVideoPlayers(root, wrap);
2208 return wrap.innerHTML;
2209 }
2210
2211 function mediaRowById(root, id) {
2212 id = Number(id || 0);
2213 if (!id) return null;
2214 return (state(root).mediaRows || []).find(row => Number(row.id || row.media_id || 0) === id) || null;
2215 }
2216
2217 function youtubeEmbedFromThumb(src) {
2218 const match = String(src || "").match(/img\.youtube\.com\/vi\/([^/]+)/i);
2219 return match ? "https://www.youtube.com/embed/" + encodeURIComponent(match[1]) : "";
2220 }
2221
2222 function inlineVideoRowFromWrapper(root, wrapper, id) {
2223 const row = mediaRowById(root, id);
2224 if (row) return row;
2225 const media = qs(wrapper, "[data-cms-media-id]") || wrapper;
2226 const thumb = qs(wrapper, ".dbx-cms-inline-video-thumb, img");
2227 const youtubeUrl = youtubeEmbedFromThumb(thumb?.getAttribute("src") || "");
2228 const url = wrapper.getAttribute("data-cms-video-url") || media?.getAttribute("data-cms-video-url") || youtubeUrl || (id ? `index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=${id}` : "");
2229 if (!url) return null;
2230 return {
2231 id,
2232 media_type: wrapper.getAttribute("data-cms-video-type") || media?.getAttribute("data-cms-video-type") || (youtubeUrl ? "external_video" : "video"),
2233 mime: wrapper.getAttribute("data-cms-video-mime") || media?.getAttribute("data-cms-video-mime") || "",
2234 url,
2235 embed_url: youtubeUrl ? url : "",
2236 thumb_url: thumb?.getAttribute("src") || "",
2237 title: thumb?.getAttribute("title") || thumb?.getAttribute("alt") || "Video"
2238 };
2239 }
2240
2241 function urlWithParams(url, params) {
2242 try {
2243 const out = new URL(String(url || ""), window.location.href);
2244 Object.keys(params || {}).forEach(key => out.searchParams.set(key, params[key]));
2245 return out.toString();
2246 } catch (e) {
2247 return String(url || "");
2248 }
2249 }
2250
2251 function youtubeVideoIdFromUrl(url) {
2252 const match = String(url || "").match(/(?:embed\/|v=|youtu\.be\/)([A-Za-z0-9_-]{11})/i);
2253 return match ? match[1] : "";
2254 }
2255
2256 function externalVideoPlayerParams(url, options) {
2257 options = options || {};
2258 const params = { playsinline: "1", rel: "0" };
2259 if (options.autoplay) params.autoplay = "1";
2260 params.mute = options.muted ? "1" : "0";
2261 if (options.loop) {
2262 params.loop = "1";
2263 const videoId = youtubeVideoIdFromUrl(url);
2264 if (videoId) params.playlist = videoId;
2265 }
2266 return params;
2267 }
2268
2269 function inlineVideoEditorPlayerHtml(row, id, options) {
2270 row = row || {};
2271 options = options || {};
2272 id = Number(id || row.id || row.media_id || 0);
2273 const title = escapeHtml(row.title || row.alt || row.file_name || "Video");
2274 const mediaAttr = inlineVideoDataAttributes(row, id, options);
2275 if (isExternalVideoRow(row)) {
2276 let url = row.embed_url || row.external_url || row.url || "";
2277 if (!url) return "";
2278 url = urlWithParams(url, externalVideoPlayerParams(url, options));
2279 return `<iframe class="dbx-cms-inline-video-player" src="${escapeHtml(url)}" title="${title}" allowfullscreen${mediaAttr}></iframe>${inlineVideoOptionsButtonHtml()}`;
2280 }
2281 const url = row.url || (id ? `index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=${id}` : "");
2282 if (!url) return "";
2283 const mime = row.mime ? ` type="${escapeHtml(row.mime)}"` : "";
2284 const poster = row.thumb_url ? ` poster="${escapeHtml(row.thumb_url)}"` : "";
2285 const autoplayAttr = options.autoplay ? " autoplay" : "";
2286 const loopAttr = options.loop ? " loop" : "";
2287 const mutedAttr = options.muted ? " muted" : "";
2288 return `<video class="dbx-cms-inline-video-player" controls${autoplayAttr}${loopAttr}${mutedAttr} playsinline preload="metadata"${poster}${mediaAttr}><source src="${escapeHtml(url)}"${mime}></video>${inlineVideoOptionsButtonHtml()}`;
2289 }
2290
2291 function playInlineVideoBlock(root, wrapper) {
2292 const surface = editorSurface(root);
2293 if (!wrapper || !surface || !surface.contains(wrapper)) return false;
2294 if (wrapper.querySelector(".dbx-cms-inline-video-player, iframe, video")) return false;
2295 const id = Number(wrapper.getAttribute("data-cms-media-id") || qs(wrapper, "[data-cms-media-id]")?.getAttribute("data-cms-media-id") || 0);
2296 const row = inlineVideoRowFromWrapper(root, wrapper, id);
2297 if (!row || !isVideoRow(row)) {
2298 status(root, "Video konnte im Editor nicht geladen werden.", "error");
2299 return false;
2300 }
2301 syncInlineVideoBlockSizes(surface);
2302 const html = inlineVideoEditorPlayerHtml(row, id, {
2303 autoplay: wrapper.getAttribute("data-cms-video-autoplay") === "1",
2304 loop: wrapper.getAttribute("data-cms-video-loop") === "1",
2305 muted: wrapper.getAttribute("data-cms-video-muted") === "1"
2306 });
2307 if (!html) return false;
2308 wrapper.innerHTML = html;
2309 wrapper.classList.add("is-playing");
2310 syncEditorDom(root);
2311 return true;
2312 }
2313
2314 function renderHeroPreview(root) {
2315 const preview = qs(root, "[data-cms-hero-preview]");
2316 if (!preview) return;
2317
2318 const templateValue = String(getField(root, "hero_template") || "").toLowerCase();
2319 if (templateValue === "none" || templateValue === "no-hero") {
2320 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Kein Hero ausgewaehlt.</div>';
2321 return;
2322 }
2323
2324 const idValue = String(getField(root, "hero_image_id") || "");
2325 const id = Number(idValue);
2326 const s = state(root);
2327 const rows = s.mediaRows || [];
2328 const row = rows.find(item => Number(item.id || 0) === id)
2329 || (s.heroPreviewRow && Number(s.heroPreviewRow.id || 0) === id ? s.heroPreviewRow : null);
2330
2331 if (idValue === "parent") {
2332 const parentRow = s.heroParentPreviewRow || null;
2333 if (parentRow && parentRow.url) {
2334 const folderName = parentRow.parent_folder_name || parentRow.folder_name || "Parent";
2335 preview.innerHTML = `<img src="${escapeHtml(parentRow.thumb_url || parentRow.url)}" alt="${escapeHtml(parentRow.alt || parentRow.title || "Hero-Bild")}"><figcaption class="dbx-cms-hero-preview-origin">${escapeHtml(folderName)}</figcaption>`;
2336 return;
2337 }
2338 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Kein Hero im parent Ordner.</div>';
2339 return;
2340 }
2341
2342 if (!id) {
2343 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Kein Hero-Bild ausgewaehlt.</div>';
2344 return;
2345 }
2346
2347 if (!row || !row.url) {
2348 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Hero-Bild wird geladen.</div>';
2349 return;
2350 }
2351
2352 const isImage = String(row.mime || "").startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(row.file_name || row.url || "");
2353 if (!isImage) {
2354 preview.innerHTML = '<div class="dbx-cms-hero-preview-empty">Das gewaehlte Medium ist kein Bild.</div>';
2355 return;
2356 }
2357
2358 preview.innerHTML = `<img src="${escapeHtml(row.thumb_url || row.url)}" alt="${escapeHtml(row.alt || row.title || "Hero-Bild")}">`;
2359 }
2360
2361 function applyHeroTemplateChoice(root, source) {
2362 const isFolder = source && source.hasAttribute && source.hasAttribute("data-cms-folder-field");
2363 const value = String(isFolder ? getFolderField(root, "hero_template") : getField(root, "hero_template")).toLowerCase();
2364 const folderPanel = qs(root, "[data-cms-folder-editor]");
2365 if (value) {
2366 setField(root, "hero_template", value);
2367 if (folderPanel && !folderPanel.hidden) setFolderField(root, "hero_template", value);
2368 }
2369 if (value === "parent") {
2370 setField(root, "hero_image_id", "parent");
2371 if (folderPanel && !folderPanel.hidden) setFolderField(root, "hero_image_id", "parent");
2372 renderHeroPreview(root);
2373 return;
2374 }
2375 if (value !== "none" && value !== "no-hero") {
2376 renderHeroPreview(root);
2377 return;
2378 }
2379 state(root).heroPreviewRow = null;
2380 setField(root, "hero_template", "none");
2381 setField(root, "hero_image_id", "0");
2382 if (folderPanel && !folderPanel.hidden) {
2383 setFolderField(root, "hero_template", "none");
2384 setFolderField(root, "hero_image_id", "0");
2385 }
2386 renderHeroPreview(root);
2387 renderMedia(root);
2388 }
2389
2390 function renderSeoPreview(root) {
2391 const preview = qs(root, "[data-cms-seo-preview]");
2392 if (!preview) return;
2393
2394 const id = Number(getField(root, "seo_image_id") || 0);
2395 const s = state(root);
2396 const rows = s.mediaRows || [];
2397 const row = rows.find(item => Number(item.id || 0) === id)
2398 || (s.seoPreviewRow && Number(s.seoPreviewRow.id || 0) === id ? s.seoPreviewRow : null);
2399
2400 if (!id) {
2401 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">Kein OG-Bild ausgewaehlt (Hero-Fallback).</div>';
2402 return;
2403 }
2404
2405 if (!row || !row.url) {
2406 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">OG-Bild wird geladen.</div>';
2407 return;
2408 }
2409
2410 const isImage = String(row.mime || "").startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(row.file_name || row.url || "");
2411 if (!isImage) {
2412 preview.innerHTML = '<div class="dbx-cms-seo-preview-empty">Das gewaehlte Medium ist kein Bild.</div>';
2413 return;
2414 }
2415
2416 preview.innerHTML = `<img src="${escapeHtml(row.thumb_url || row.url)}" alt="${escapeHtml(row.alt || row.title || "OG-Bild")}">`;
2417 }
2418
2419 function mediaBrowserRows(modal) {
2420 return qsa(modal, "[data-cms-media-browser-item]").map(mediaRowFromItem);
2421 }
2422
2423 function updateMediaBrowserConfirm(modal) {
2424 const bar = qs(modal, "[data-cms-media-browser-confirmbar]");
2425 const count = qs(modal, "[data-cms-media-browser-count]");
2426 const selected = modal.__dbxCmsSelectedIds || new Set();
2427 const needsConfirm = mediaBrowserUsesConfirmBar(modal);
2428 if (bar) bar.hidden = !needsConfirm;
2429 if (count) count.textContent = String(selected.size);
2430 updateMediaBrowserBatchUi(modal);
2431 }
2432
2433 function selectedMediaBrowserImageRows(modal) {
2434 const selected = modal && modal.__dbxCmsSelectedIds ? modal.__dbxCmsSelectedIds : new Set();
2435 return mediaBrowserAllRows(modal).filter(row => selected.has(Number(row.id || 0)) && canEditImage(row));
2436 }
2437
2438 function mediaBrowserAllRows(modal) {
2439 return Array.isArray(modal && modal.__dbxCmsRows) ? modal.__dbxCmsRows : mediaBrowserRows(modal);
2440 }
2441
2442 function batchControlHost(browserModal) {
2443 if (!browserModal) return null;
2444 return browserModal.__dbxCmsBatchPanel || browserModal;
2445 }
2446
2447 function updateMediaBrowserBatchUi(modal) {
2448 const selectedImages = selectedMediaBrowserImageRows(modal).length;
2449 [modal, modal && modal.__dbxCmsBatchPanel].forEach(host => {
2450 if (!host) return;
2451 const count = qs(host, "[data-cms-media-browser-resize-count]");
2452 if (count) count.textContent = String(selectedImages);
2453 });
2454 }
2455
2456 function firstMediaFolderOption(select, prefix) {
2457 prefix = String(prefix || "");
2458 return Array.from(select?.options || []).find(option => {
2459 const value = String(option.value || "");
2460 return value && value !== "all" && (!prefix || value.indexOf(prefix) === 0);
2461 })?.value || "";
2462 }
2463
2464 function mediaFolderLabel(folder) {
2465 return String(folder || "").trim();
2466 }
2467
2468 function mediaOriginLabel(row) {
2469 row = row || {};
2470 let folder = String(row.media_folder || "").trim().replace(/\\/g, "/");
2471 if (!folder) {
2472 let path = String(row.file_path || row.path || "").trim().replace(/\\/g, "/");
2473 path = path.replace(/^files\/media\//i, "").replace(/^media\//i, "");
2474 const slash = path.lastIndexOf("/");
2475 folder = slash >= 0 ? path.slice(0, slash) : "";
2476 }
2477 folder = folder.replace(/^files\/media\//i, "").replace(/^media\//i, "").replace(/^\/+|\/+$/g, "");
2478 return folder || "Root";
2479 }
2480
2481 function mediaSlotLabel(slot) {
2482 const slotLabels = { inline: "Im Text", hero: "Hero", gallery: "Galerie", shop: "Shop" };
2483 slot = String(slot || "").trim();
2484 return slotLabels[slot] || slot || "Verwendung";
2485 }
2486
2487 function mediaUsagePages(row) {
2488 return Array.isArray(row && row.usage_pages) ? row.usage_pages : [];
2489 }
2490
2491 function mediaUsageLabel(row) {
2492 row = row || {};
2493 const slot = String(row.slot || "").trim();
2494 const pages = mediaUsagePages(row);
2495 const ids = pages.map(page => Number(page.content_id || page.id || 0)).filter(Boolean);
2496 const shownIds = ids.slice(0, 3).map(id => "#" + id).join(", ");
2497 const suffix = ids.length > 3 ? ", ..." : "";
2498 const currentUsageId = Number(row.current_usage_id || row.usage_id || 0);
2499 if (currentUsageId > 0 && slot) {
2500 return mediaSlotLabel(slot) + (shownIds ? ": " + shownIds + suffix : "");
2501 }
2502 const count = Number(row.used_count || 0);
2503 if (ids.length > 0) return "Verwendet: " + shownIds + suffix;
2504 if (count > 0) return count === 1 ? "Verwendet 1x" : "Verwendet " + count + "x";
2505 return "Nicht verwendet";
2506 }
2507
2508 function mediaUsageTooltip(row) {
2509 row = row || {};
2510 const pages = mediaUsagePages(row);
2511 if (pages.length) {
2512 return pages.map(page => {
2513 const id = Number(page.content_id || page.id || 0);
2514 const folderId = Number(page.folder_id || 0);
2515 const folder = String(page.folder_title || "").trim();
2516 const title = String(page.title || "").trim();
2517 const slots = Array.isArray(page.slots) ? page.slots.map(mediaSlotLabel).filter(Boolean).join(", ") : mediaSlotLabel(row.slot || "");
2518 return "#" + id
2519 + (folderId > 0 || folder ? " | Ordner: " + (folder || ("#" + folderId)) : "")
2520 + (title ? " | " + title : "")
2521 + (slots ? " | " + slots : "");
2522 }).join("\n");
2523 }
2524 return mediaUsageLabel(row);
2525 }
2526
2527 function mediaUsageTooltipRows(row) {
2528 row = row || {};
2529 const pages = mediaUsagePages(row);
2530 return pages.map(page => {
2531 const id = Number(page.content_id || page.id || 0);
2532 const folderId = Number(page.folder_id || 0);
2533 const folder = String(page.folder_title || "").trim();
2534 const title = String(page.title || "").trim();
2535 return {
2536 id: id > 0 ? "#" + id : "",
2537 folder: folder || (folderId > 0 ? "#" + folderId : "-"),
2538 title: title || "-"
2539 };
2540 });
2541 }
2542
2543 function mediaUsageTooltipHtml(row) {
2544 const rows = mediaUsageTooltipRows(row);
2545 const tooltip = window.dbx && dbx.utilities && dbx.utilities.tooltip;
2546 if (tooltip && typeof tooltip.htmlList === "function") {
2547 return tooltip.htmlList(rows, {
2548 title: rows.length ? "Verwendet von" : "",
2549 empty: mediaUsageLabel(row)
2550 });
2551 }
2552 if (!rows.length) {
2553 return `<div>${escapeHtml(mediaUsageLabel(row))}</div>`;
2554 }
2555 return `<strong>Verwendet von</strong><br>` + rows.map(item => {
2556 return `${escapeHtml(item.id)} ${escapeHtml(item.folder)} ${escapeHtml(item.title)}`;
2557 }).join("<br>");
2558 }
2559
2560 function mediaBrowserItemHtml(row, selected, needsConfirm) {
2561 row = row || {};
2562 selected = selected || new Set();
2563 return `<div class="dbx-cms-media-browser-item${selected.has(Number(row.id || 0)) ? " is-selected" : ""}"
2564 data-cms-media-browser-item
2565 data-media-id="${escapeHtml(row.id || "")}"
2566 data-url="${escapeHtml(row.url || "")}"
2567 data-thumb-url="${escapeHtml(row.thumb_url || "")}"
2568 data-mime="${escapeHtml(row.mime || "")}"
2569 data-media-type="${escapeHtml(row.media_type || "")}"
2570 data-width="${escapeHtml(row.width || "")}"
2571 data-height="${escapeHtml(row.height || "")}"
2572 data-file-name="${escapeHtml(row.file_name || "")}"
2573 data-file-path="${escapeHtml(row.file_path || "")}"
2574 data-title="${escapeHtml(row.title || "")}"
2575 data-alt="${escapeHtml(row.alt || "")}"
2576 data-media-folder="${escapeHtml(row.media_folder || "")}"
2577 data-slot="${escapeHtml(row.slot || "")}"
2578 draggable="${isExternalVideoRow(row) ? "false" : "true"}">
2579 <button type="button" class="dbx-cms-media-browser-pickarea" data-cms-media-browser-pick draggable="${isExternalVideoRow(row) ? "false" : "true"}" title="${needsConfirm ? "Medium fuer Auswahl markieren" : "Medium in den Editor einfuegen"}">
2580 <span>${mediaPreviewHtml(row)}</span>
2581 <strong>${escapeHtml(row.title || row.file_name || "Medium")}</strong>
2582 ${canEditImage(row) || needsConfirm ? '<em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>' : ''}
2583 </button>
2584 <div class="dbx-cms-media-browser-actions">
2585 <span class="dbx-cms-media-browser-meta">
2586 <span class="dbx-cms-media-browser-origin">${escapeHtml(mediaOriginLabel(row))}</span>
2587 <span class="dbx-cms-media-browser-usage" tabindex="0" data-dbx-tooltip="${escapeTooltipAttr(mediaUsageTooltipHtml(row))}">${escapeHtml(mediaUsageLabel(row))}</span>
2588 </span>
2589 ${canEditImage(row) ? '<button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-browser-select title="Bild fuer Batch Resize auswaehlen"><i class="bi bi-check2-square"></i></button>' : ''}
2590 ${canEditImage(row) ? '<button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-browser-edit title="Bild zuschneiden oder resizen"><i class="bi bi-crop"></i></button>' : ''}
2591 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-media-browser-delete title="Mediendatei wirklich loeschen">
2592 <i class="bi bi-trash"></i>
2593 </button>
2594 </div>
2595 </div>`;
2596 }
2597
2598 function mediaBrowserExplorerItemHtml(row, selected) {
2599 row = row || {};
2600 selected = selected || new Set();
2601 const id = Number(row.id || 0);
2602 const title = row.title || row.file_name || "Medium";
2603 const draggable = isExternalVideoRow(row) ? "false" : "true";
2604 const originLabel = mediaOriginLabel(row);
2605 const usageLabel = mediaUsageLabel(row);
2606 const usageTooltip = mediaUsageTooltipHtml(row);
2607 return `<div class="dbx-cms-media-explorer-item dbx-cms-media-browser-item${selected.has(id) ? " is-selected" : ""}"
2608 data-cms-media-browser-item
2609 data-cms-media-tree-media
2610 data-media-id="${escapeHtml(row.id || "")}"
2611 data-url="${escapeHtml(row.url || "")}"
2612 data-thumb-url="${escapeHtml(row.thumb_url || "")}"
2613 data-mime="${escapeHtml(row.mime || "")}"
2614 data-media-type="${escapeHtml(row.media_type || "")}"
2615 data-width="${escapeHtml(row.width || "")}"
2616 data-height="${escapeHtml(row.height || "")}"
2617 data-file-name="${escapeHtml(row.file_name || "")}"
2618 data-file-path="${escapeHtml(row.file_path || "")}"
2619 data-title="${escapeHtml(row.title || "")}"
2620 data-alt="${escapeHtml(row.alt || "")}"
2621 data-media-folder="${escapeHtml(row.media_folder || "")}"
2622 data-slot="${escapeHtml(row.slot || "")}"
2623 draggable="${draggable}">
2624 <button type="button" class="dbx-cms-media-explorer-pick dbx-cms-media-browser-pickarea" data-cms-media-browser-pick draggable="${draggable}" title="Medium auswaehlen">
2625 <span class="dbx-cms-media-explorer-thumb">${mediaPreviewHtml(row)}</span>
2626 <strong>${escapeHtml(title)}</strong>
2627 <small>${escapeHtml(row.file_name || "")}</small>
2628 <em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>
2629 </button>
2630 <div class="dbx-cms-media-explorer-actions">
2631 <span class="dbx-cms-media-browser-meta">
2632 <span class="dbx-cms-media-browser-origin">${escapeHtml(originLabel)}</span>
2633 <span class="dbx-cms-media-browser-usage" tabindex="0" data-dbx-tooltip="${escapeTooltipAttr(usageTooltip)}">${escapeHtml(usageLabel)}</span>
2634 </span>
2635 ${canEditImage(row) ? '<button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-browser-select title="Bild fuer Batch Resize auswaehlen"><i class="bi bi-check2-square"></i></button>' : ''}
2636 ${canEditImage(row) ? '<button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-browser-edit title="Bild zuschneiden oder resizen"><i class="bi bi-crop"></i></button>' : ''}
2637 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-media-browser-delete title="Mediendatei wirklich loeschen"><i class="bi bi-trash"></i></button>
2638 </div>
2639 </div>`;
2640 }
2641
2642 function renderMediaRowsChunked(host, rows, rowHtml, afterDone) {
2643 if (!host) return;
2644 rows = Array.isArray(rows) ? rows : [];
2645 const token = ((host.__dbxMediaRenderToken || 0) + 1);
2646 host.__dbxMediaRenderToken = token;
2647 if (host.__dbxMediaRenderScrollHandler) {
2648 host.removeEventListener("scroll", host.__dbxMediaRenderScrollHandler);
2649 host.__dbxMediaRenderScrollHandler = null;
2650 }
2651 host.innerHTML = "";
2652 if (!rows.length) {
2653 if (afterDone) afterDone();
2654 return;
2655 }
2656 const batchSize = 18;
2657 let offset = 0;
2658 let rendering = false;
2659 const appendBatch = () => {
2660 if (host.__dbxMediaRenderToken !== token || rendering) return;
2661 rendering = true;
2662 const html = rows.slice(offset, offset + batchSize).map(rowHtml).join("");
2663 if (html) {
2664 host.insertAdjacentHTML("beforeend", html);
2665 setupMediaLazyImages(host);
2666 }
2667 offset += batchSize;
2668 rendering = false;
2669 if (afterDone) {
2670 afterDone();
2671 }
2672 };
2673
2674 const appendWhenNeeded = () => {
2675 if (host.__dbxMediaRenderToken !== token || offset >= rows.length) return;
2676 const distance = host.scrollHeight - host.scrollTop - host.clientHeight;
2677 if (distance < 220) appendBatch();
2678 if (host.scrollHeight <= host.clientHeight + 40 && offset < rows.length) {
2679 window.requestAnimationFrame(appendWhenNeeded);
2680 }
2681 };
2682
2683 host.__dbxMediaRenderScrollHandler = () => {
2684 window.requestAnimationFrame(appendWhenNeeded);
2685 scheduleMediaLazyLoad(host);
2686 };
2687 host.addEventListener("scroll", host.__dbxMediaRenderScrollHandler, { passive: true });
2688 appendBatch();
2689 window.requestAnimationFrame(appendWhenNeeded);
2690 }
2691
2692 function mediaBrowserSkeletonHtml(count) {
2693 count = Math.max(6, Math.min(36, Number(count || 18)));
2694 let html = "";
2695 for (let i = 0; i < count; i++) {
2696 html += `<div class="dbx-cms-media-browser-item dbx-cms-media-browser-skeleton" aria-hidden="true">
2697 <span class="dbx-cms-media-browser-skeleton-thumb"></span>
2698 <span class="dbx-cms-media-browser-skeleton-line"></span>
2699 <span class="dbx-cms-media-browser-skeleton-meta"></span>
2700 </div>`;
2701 }
2702 return html;
2703 }
2704
2705 function mediaFolderDiskPath(folder) {
2706 folder = String(folder || "").trim();
2707 if (!folder) return "";
2708 if (folder === "mod") return "files/mod/";
2709 return "files/media/" + folder.replace(/\\/g, "/") + "/";
2710 }
2711
2712 function mediaBrowserQueryParams(folder) {
2713 const params = { sync: 0 };
2714 folder = String(folder || "all");
2715 if (folder === "all") {
2716 params.media_folder = "all";
2717 } else {
2718 params.media_folder = folder;
2719 if (folder === "youtube" || folder.indexOf("youtube/") === 0) {
2720 params.media_type = "external_video";
2721 } else if (folder.indexOf("videos/") === 0 || folder === "videos" || folder.indexOf("video/") === 0 || folder === "video" || folder === "img/video" || folder.indexOf("img/video/") === 0) {
2722 params.media_type = "video";
2723 } else if (folder.indexOf("file/") === 0 || folder === "file") {
2724 params.media_type = "file";
2725 } else if (folder === "module" || folder === "mod") {
2726 params.images = 1;
2727 params.media_type = "image";
2728 } else {
2729 params.images = 1;
2730 params.media_type = "image";
2731 }
2732 }
2733 return params;
2734 }
2735
2736 function mediaTypeFromFolder(folder) {
2737 folder = String(folder || "");
2738 if (folder.indexOf("videos/") === 0 || folder === "videos" || folder.indexOf("video/") === 0 || folder === "video") return "video";
2739 if (folder.indexOf("file/") === 0 || folder === "file") return "file";
2740 return "image";
2741 }
2742
2743 function buildNewMediaFolderPath(parentFolder, name) {
2744 parentFolder = String(parentFolder || "").trim();
2745 name = String(name || "").trim().replace(/\\/g, "/");
2746 if (!name) return "";
2747 if (name.indexOf("/") >= 0) return name;
2748 if (!parentFolder) return "img/" + name;
2749 const base = parentFolder.split("/")[0] || "img";
2750 if (base === "img" || base === "video" || base === "file" || base === "module") {
2751 return base + "/" + name;
2752 }
2753 return parentFolder + "/" + name;
2754 }
2755
2756 function ensureMediaBrowserFolderUi(modal) {
2757 if (!modal) return;
2758 const uploadForm = qs(modal, "[data-cms-browser-upload]");
2759 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
2760 if (uploadForm && uploadFolder && !uploadFolder.closest(".dbx-cms-upload-folder-label")) {
2761 const label = document.createElement("label");
2762 label.className = "dbx-cms-upload-folder-label";
2763 label.innerHTML = '<span class="form-label small mb-1">Zielordner</span>';
2764 uploadFolder.parentNode.insertBefore(label, uploadFolder);
2765 label.appendChild(uploadFolder);
2766 }
2767 const typeSelect = qs(modal, "[data-cms-folder-type]");
2768 if (typeSelect) {
2769 const folderBar = typeSelect.closest(".dbx-cms-media-folderbar");
2770 const parent = document.createElement("select");
2771 parent.className = typeSelect.className;
2772 parent.setAttribute("data-cms-folder-parent", "1");
2773 parent.title = "Vorhandenen Ordner als Basis waehlen";
2774 typeSelect.replaceWith(parent);
2775 if (folderBar && !qs(folderBar, ".dbx-cms-media-folderbar-title")) {
2776 const title = document.createElement("span");
2777 title.className = "small text-muted dbx-cms-media-folderbar-title";
2778 title.textContent = "Neuer Unterordner:";
2779 folderBar.insertBefore(title, parent);
2780 }
2781 }
2782 if (!modal.__dbxCmsUploadFolderSyncBound) {
2783 modal.__dbxCmsUploadFolderSyncBound = true;
2784 modal.addEventListener("change", e => {
2785 const uploadSelect = closestElement(e.target, "[data-cms-upload-folder]");
2786 if (!uploadSelect || !modal.contains(uploadSelect)) return;
2787 uploadSelect.__dbxCmsTouched = true;
2788 const parentSelect = qs(modal, "[data-cms-folder-parent]");
2789 if (parentSelect && Array.from(parentSelect.options).some(option => option.value === uploadSelect.value)) {
2790 parentSelect.value = uploadSelect.value;
2791 }
2792 });
2793 }
2794 }
2795
2796 function disposeMediaBrowserModal(root, modal) {
2797 if (!modal) return;
2798 if (modal.__dbxCmsBatchWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
2799 dbx.openWin.close(modal.__dbxCmsBatchWindowId);
2800 }
2801 if (modal.__dbxCmsBatchPanel) {
2802 modal.__dbxCmsBatchPanel.remove();
2803 modal.__dbxCmsBatchPanel = null;
2804 }
2805 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
2806 dbx.openWin.close(modal.__dbxCmsWindowId);
2807 }
2808 modal.remove();
2809 if (state(root).mediaBrowser === modal) state(root).mediaBrowser = null;
2810 }
2811
2812 function mediaBatchWindowMarkup() {
2813 return `
2814 <div class="dbx-cms-media-batch-toolbar">
2815 <span class="dbx-cms-media-browser-resize-count"><strong data-cms-media-browser-resize-count>0</strong> Bilder ausgewaehlt</span>
2816 <select class="form-select form-select-sm" data-cms-media-browser-resize-preset title="Resize-Groesse">
2817 <option value="">Groesse waehlen</option>
2818 <option value="800x600">800 x 600</option>
2819 <option value="1024x768">1024 x 768</option>
2820 <option value="1280x720">1280 x 720 HD</option>
2821 <option value="1600x900">1600 x 900</option>
2822 <option value="1920x1080">1920 x 1080 Full HD</option>
2823 <option value="2560x1440">2560 x 1440 QHD</option>
2824 </select>
2825 <input type="number" class="form-control form-control-sm" data-cms-bulk-resize-width placeholder="Breite">
2826 <input type="number" class="form-control form-control-sm" data-cms-bulk-resize-height placeholder="Hoehe">
2827 <label class="dbx-cms-media-resize-ratio" title="Seitenverhaeltnis beim Resize behalten">
2828 <input type="checkbox" data-cms-bulk-resize-ratio checked>
2829 <span>Ratio</span>
2830 </label>
2831 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-action="bulk-resize-media" data-cms-resize-scope="selected">
2832 <i class="bi bi-check2-square"></i>
2833 <span>Auswahl resizen</span>
2834 </button>
2835 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-action="bulk-resize-media" data-cms-resize-scope="all">
2836 <i class="bi bi-images"></i>
2837 <span>Alle resizen</span>
2838 </button>
2839 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-batch-close>
2840 <i class="bi bi-arrow-left"></i>
2841 <span>Zurueck</span>
2842 </button>
2843 </div>
2844 <div class="dbx-cms-media-batch-list" data-cms-media-batch-list>
2845 <div class="dbx-cms-empty">Bilder zum Batch-Resize auswaehlen.</div>
2846 </div>`;
2847 }
2848
2849 function bindMediaBatchWindowEvents(root, cfg, browserModal, batchPanel) {
2850 if (!batchPanel || batchPanel.__dbxCmsBatchEventsBound) return;
2851 batchPanel.__dbxCmsBatchEventsBound = true;
2852 batchPanel.__dbxCmsBrowserModal = browserModal;
2853
2854 batchPanel.addEventListener("click", e => {
2855 e.stopPropagation();
2856 const cfg = browserCfg(browserModal);
2857
2858 const batchClose = closestElement(e.target, "[data-cms-media-batch-close]");
2859 if (batchClose && batchPanel.contains(batchClose)) {
2860 e.preventDefault();
2861 batchPanel.hidden = true;
2862 if (browserModal) browserModal.classList.remove("is-batch-open");
2863 return;
2864 }
2865
2866 const batchItem = closestElement(e.target, "[data-cms-batch-item]");
2867 if (batchItem && batchPanel.contains(batchItem)) {
2868 e.preventDefault();
2869 toggleMediaBrowserSelection(browserModal, batchItem);
2870 return;
2871 }
2872
2873 const bulkResize = closestElement(e.target, "[data-cms-action='bulk-resize-media']");
2874 if (bulkResize && batchPanel.contains(bulkResize)) {
2875 e.preventDefault();
2876 bulkResizeMedia(root, cfg, bulkResize.getAttribute("data-cms-resize-scope") || "selected", browserModal);
2877 return;
2878 }
2879
2880 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
2881 if (processClose && batchPanel.contains(processClose)) {
2882 e.preventDefault();
2883 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
2884 if (panel) {
2885 panel.hidden = true;
2886 panel.innerHTML = "";
2887 }
2888 if (browserModal) browserModal.classList.remove("is-process-open");
2889 clearCmsLoading(root);
2890 }
2891 });
2892
2893 batchPanel.addEventListener("change", e => {
2894 e.stopPropagation();
2895 const preset = closestElement(e.target, "[data-cms-media-browser-resize-preset]");
2896 if (preset && batchPanel.contains(preset)) {
2897 mediaBrowserPreset(batchPanel, preset.value);
2898 return;
2899 }
2900 const ratioInput = closestElement(e.target, "[data-cms-bulk-resize-ratio]");
2901 if (ratioInput && batchPanel.contains(ratioInput)) {
2902 status(root, ratioInput.checked ? "Resize behaelt das Seitenverhaeltnis." : "Resize nutzt exakte Breite und Hoehe.", "info");
2903 }
2904 });
2905 }
2906
2907 function mediaBatchWindowIsOpen(browserModal) {
2908 const panel = browserModal && browserModal.__dbxCmsBatchPanel;
2909 if (!panel || !browserModal.__dbxCmsBatchWindowId) return false;
2910 return document.documentElement.contains(panel) && !!closestElement(panel, ".dbx-window");
2911 }
2912
2913 function elevateMediaBatchWindow(browserModal) {
2914 const panel = browserModal && browserModal.__dbxCmsBatchPanel;
2915 const win = (browserModal && browserModal.__dbxCmsBatchWindowId ? document.getElementById(browserModal.__dbxCmsBatchWindowId) : null)
2916 || (panel ? closestElement(panel, ".dbx-window") : null);
2917 if (!win) return;
2918 win.classList.add("dbx-cms-media-batch-window-frame");
2919 }
2920
2921 function openMediaBatchWindow(root, cfg, browserModal) {
2922 if (!browserModal) return;
2923 let batchPanel = browserModal.__dbxCmsBatchPanel;
2924 if (!batchPanel || !document.documentElement.contains(batchPanel) || !qs(batchPanel, "[data-cms-media-batch-list]")) {
2925 if (batchPanel) batchPanel.remove();
2926 batchPanel = document.createElement("div");
2927 batchPanel.className = "dbx-cms-media-batch-window";
2928 batchPanel.setAttribute("data-cms-media-batch-window", "1");
2929 batchPanel.innerHTML = mediaBatchWindowMarkup();
2930 const processPanel = qs(browserModal, "[data-cms-media-process-panel]");
2931 if (processPanel && processPanel.parentNode) {
2932 processPanel.parentNode.insertBefore(batchPanel, processPanel);
2933 } else {
2934 browserModal.appendChild(batchPanel);
2935 }
2936 browserModal.__dbxCmsBatchPanel = batchPanel;
2937 bindMediaBatchWindowEvents(root, cfg, browserModal, batchPanel);
2938 }
2939 browserModal.__dbxCmsBatchWindowId = null;
2940 browserModal.classList.remove("is-folder-tree-open", "is-process-open");
2941 browserModal.classList.add("is-batch-open");
2942 const processPanel = qs(browserModal, "[data-cms-media-process-panel]");
2943 if (processPanel) {
2944 processPanel.hidden = true;
2945 processPanel.innerHTML = "";
2946 }
2947 batchPanel.hidden = false;
2948 batchPanel.__dbxCmsBrowserModal = browserModal;
2949 batchPanel.__dbxCmsCfg = cfg || browserCfg(browserModal);
2950 updateMediaBrowserBatchUi(browserModal);
2951 renderMediaBatchList(browserModal);
2952 }
2953
2954 function mediaBrowserNeedsRebuild(modal) {
2955 if (!modal) return false;
2956 return !!qs(modal, "[data-cms-folder-type]")
2957 || !qs(modal, ".dbx-cms-upload-target")
2958 || !qs(modal, "[data-cms-media-folder-toggle]")
2959 || !qs(modal, "[data-cms-media-folder-tree]")
2960 || !qs(modal, "[data-cms-media-process-panel]")
2961 || !qs(modal, "[data-cms-folder-rename]")
2962 || !!qs(modal, "[data-cms-media-browser-slot] option[value='header']")
2963 || !!qs(modal, "[data-cms-media-tools-toggle]")
2964 || !qs(modal, "[data-cms-media-batch-open]");
2965 }
2966
2967 function uploadFolderOptions(folders) {
2968 return (folders || []).filter(folder => {
2969 const value = String(folder || "");
2970 if (!value || value === "all") return false;
2971 if (value === "youtube" || value.indexOf("youtube/") === 0) return false;
2972 if (value === "module" || value.indexOf("module/") === 0) return false;
2973 if (value === "img/video" || value.indexOf("img/video/") === 0) return false;
2974 return true;
2975 });
2976 }
2977
2978 function syncUploadFolderSelect(modal, uploadFolders, preferred) {
2979 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
2980 if (!uploadFolder || uploadFolder.__dbxCmsTouched) return;
2981 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
2982 const browse = String(filterFolder?.value || "");
2983 const candidates = [
2984 String(preferred || ""),
2985 browse !== "all" ? browse : "",
2986 String(modal.__dbxCmsMediaFolder || "")
2987 ];
2988 for (let i = 0; i < candidates.length; i++) {
2989 const cand = candidates[i];
2990 if (cand && uploadFolders.includes(cand)) {
2991 uploadFolder.value = cand;
2992 return;
2993 }
2994 }
2995 const imageFolder = uploadFolders.find(folder => folder.indexOf("img/") === 0);
2996 if (imageFolder) uploadFolder.value = imageFolder;
2997 else if (uploadFolders.length) uploadFolder.value = uploadFolders[0];
2998 }
2999
3000 function setSelectOptions(select, folders, includeAll) {
3001 if (!select) return;
3002 const current = select.value || "";
3003 const options = [];
3004 if (includeAll) options.push('<option value="all">Alle Verzeichnisse</option>');
3005 (folders || []).forEach(folder => {
3006 const label = mediaFolderLabel(folder);
3007 const hint = mediaFolderDiskPath(folder);
3008 options.push(`<option value="${escapeHtml(folder)}" title="${escapeHtml(hint)}">${escapeHtml(label)}</option>`);
3009 });
3010 select.innerHTML = options.join("");
3011 if (current && Array.from(select.options).some(option => option.value === current)) {
3012 select.value = current;
3013 }
3014 }
3015
3016 function mediaFolderParent(folder) {
3017 folder = String(folder || "").replace(/\/+$/g, "");
3018 const idx = folder.lastIndexOf("/");
3019 return idx > 0 ? folder.slice(0, idx) : "";
3020 }
3021
3022 function mediaFolderName(folder) {
3023 folder = String(folder || "").replace(/\/+$/g, "");
3024 return folder.split("/").pop() || folder;
3025 }
3026
3027 function mediaFolderDepth(folder) {
3028 folder = String(folder || "").replace(/^\/+|\/+$/g, "");
3029 return folder ? Math.max(0, folder.split("/").length - 1) : 0;
3030 }
3031
3032 function mediaFolderTreeViewSize(modal) {
3033 const value = String(modal && modal.__dbxCmsMediaTreeSize || "medium");
3034 return ["small", "medium", "large"].includes(value) ? value : "medium";
3035 }
3036
3037 function renderMediaFolderTree(modal, folders) {
3038 const tree = qs(modal, "[data-cms-media-folder-tree]");
3039 if (!tree) return;
3040 const uploadFolders = uploadFolderOptions(folders || modal.__dbxCmsFolders || []);
3041 const rows = Array.isArray(modal.__dbxCmsRows) ? modal.__dbxCmsRows : [];
3042 const selected = modal.__dbxCmsSelectedIds || new Set();
3043 const counts = rows.reduce((out, row) => {
3044 const folder = String(row.media_folder || "");
3045 if (folder) out[folder] = (out[folder] || 0) + 1;
3046 return out;
3047 }, {});
3048 if (!uploadFolders.length) {
3049 tree.innerHTML = `
3050 <div class="dbx-cms-media-folder-tree-head">
3051 <div>
3052 <strong><i class="bi bi-folder2-open"></i> Medienordner</strong>
3053 <span>Keine Medienordner vorhanden.</span>
3054 </div>
3055 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-folder-tree-close>
3056 <i class="bi bi-arrow-left"></i>
3057 <span>Zurueck</span>
3058 </button>
3059 </div>`;
3060 return;
3061 }
3062 const folderSet = new Set(uploadFolders);
3063 let currentFolder = String(modal.__dbxCmsMediaTreeFolder || modal.__dbxCmsMediaFolder || "").trim();
3064 const selectFilter = String(qs(modal, "[data-cms-media-browser-folder]")?.value || "");
3065 if ((!currentFolder || !folderSet.has(currentFolder)) && selectFilter && selectFilter !== "all" && folderSet.has(selectFilter)) {
3066 currentFolder = selectFilter;
3067 }
3068 if (!currentFolder || !folderSet.has(currentFolder)) {
3069 currentFolder = uploadFolders.find(folder => (counts[folder] || 0) > 0) || uploadFolders[0];
3070 }
3071 modal.__dbxCmsMediaTreeFolder = currentFolder;
3072 const viewSize = mediaFolderTreeViewSize(modal);
3073 const folderRows = rows.filter(row => String(row.media_folder || "") === currentFolder);
3074 const folderOptions = uploadFolders.map(folder => {
3075 const depth = mediaFolderDepth(folder);
3076 const label = mediaFolderName(folder);
3077 const count = counts[folder] || 0;
3078 const active = folder === currentFolder;
3079 return `<button type="button"
3080 class="dbx-cms-media-explorer-folder${active ? " is-active" : ""}"
3081 data-cms-media-folder-drop
3082 data-cms-media-tree-folder-select
3083 data-folder="${escapeHtml(folder)}"
3084 draggable="true"
3085 style="--dbx-folder-depth:${depth}"
3086 title="${escapeHtml(folder)}">
3087 <span class="dbx-cms-media-explorer-indent"></span>
3088 <i class="bi ${active ? "bi-folder2-open" : "bi-folder"}"></i>
3089 <span class="dbx-cms-media-explorer-folder-text">
3090 <strong>${escapeHtml(label)}</strong>
3091 <small>${escapeHtml(folder)}</small>
3092 </span>
3093 <em>${count}</em>
3094 </button>`;
3095 }).join("");
3096 const sizeButtons = ["small", "medium", "large"].map(size => {
3097 const labels = { small: "Klein", medium: "Mittel", large: "Gross" };
3098 const icons = { small: "bi-grid-3x3-gap", medium: "bi-grid", large: "bi-grid-1x2" };
3099 return `<button type="button" class="btn btn-outline-primary btn-sm${viewSize === size ? " active" : ""}" data-cms-media-tree-size="${size}" title="Ansicht ${labels[size]}">
3100 <i class="bi ${icons[size]}"></i>
3101 <span>${labels[size]}</span>
3102 </button>`;
3103 }).join("");
3104 tree.innerHTML = `
3105 <div class="dbx-cms-media-folder-tree-head">
3106 <div>
3107 <strong><i class="bi bi-folder2-open"></i> Medienordner</strong>
3108 <span>Ordner links waehlen, Medien rechts markieren oder per Drag verschieben.</span>
3109 </div>
3110 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-folder-tree-close>
3111 <i class="bi bi-arrow-left"></i>
3112 <span>Zurueck</span>
3113 </button>
3114 </div>
3115 <div class="dbx-cms-media-explorer" data-cms-media-tree data-view-size="${escapeHtml(viewSize)}">
3116 <div class="dbx-cms-media-explorer-sidebar">
3117 <div class="dbx-cms-media-explorer-sidebar-title">
3118 <i class="bi bi-folder2"></i>
3119 <span>Ordner</span>
3120 </div>
3121 <div class="dbx-cms-media-explorer-folders">
3122 ${folderOptions}
3123 </div>
3124 </div>
3125 <div class="dbx-cms-media-explorer-main">
3126 <div class="dbx-cms-media-explorer-toolbar">
3127 <div>
3128 <strong>${escapeHtml(currentFolder)}</strong>
3129 <span>${folderRows.length} Medien</span>
3130 </div>
3131 <div class="btn-group btn-group-sm" role="group" aria-label="Ansicht">
3132 ${sizeButtons}
3133 </div>
3134 </div>
3135 <div class="dbx-cms-media-explorer-grid">
3136 ${folderRows.length ? '' : '<div class="dbx-cms-media-tree-empty">Keine Medien in diesem Ordner.</div>'}
3137 </div>
3138 </div>
3139 </div>`;
3140 const grid = qs(tree, ".dbx-cms-media-explorer-grid");
3141 if (folderRows.length && grid) {
3142 renderMediaRowsChunked(
3143 grid,
3144 folderRows,
3145 row => mediaBrowserExplorerItemHtml(row, selected),
3146 () => updateMediaBrowserBatchUi(modal)
3147 );
3148 }
3149 }
3150
3151 function setMediaBrowserFolderTreeMode(modal, open) {
3152 if (!modal) return;
3153 const tree = qs(modal, "[data-cms-media-folder-tree]");
3154 const toggle = qs(modal, "[data-cms-media-folder-toggle]");
3155 if (!tree) return;
3156 open = !!open;
3157 modal.classList.toggle("is-folder-tree-open", open);
3158 if (open) {
3159 modal.classList.remove("is-batch-open", "is-process-open");
3160 const batchPanel = modal.__dbxCmsBatchPanel || null;
3161 if (batchPanel) batchPanel.hidden = true;
3162 const processPanel = qs(modal, "[data-cms-media-process-panel]");
3163 if (processPanel) {
3164 processPanel.hidden = true;
3165 processPanel.innerHTML = "";
3166 }
3167 }
3168 tree.classList.toggle("is-open", open);
3169 tree.hidden = !open;
3170 if (toggle) {
3171 toggle.classList.toggle("is-active", open);
3172 toggle.setAttribute("aria-pressed", open ? "true" : "false");
3173 }
3174 if (open) renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
3175 }
3176
3177 function refreshMediaFolderControls(root, cfg, modal) {
3178 const url = cfgUrl(cfg, "mediafolders");
3179 if (!url || !modal) return Promise.resolve([]);
3180 const profile = mediaBrowserProfile(cfg);
3181 return fetchJson(apiUrl(url), { timeout: 20000 })
3182 .then(data => {
3183 let folders = Array.isArray(data && data.folders) ? data.folders : [];
3184 if (profile === "mod") {
3185 folders = folders.filter(folder => String(folder || "") === "mod");
3186 if (!folders.length) folders = ["mod"];
3187 } else {
3188 folders = folders.filter(folder => {
3189 const value = String(folder || "");
3190 return value && value !== "module" && value.indexOf("module/") !== 0;
3191 });
3192 }
3193 const uploadFolders = uploadFolderOptions(folders);
3194 modal.__dbxCmsFolders = folders;
3195 modal.__dbxCmsUploadFolders = uploadFolders;
3196 if (data && data.root) {
3197 modal.dataset.dbxMediaRoot = String(data.root);
3198 const rootHint = qs(modal, "[data-cms-media-root-hint]");
3199 if (rootHint) rootHint.textContent = "Speicherort: " + String(data.root);
3200 }
3201 setSelectOptions(qs(modal, "[data-cms-media-browser-folder]"), folders, true);
3202 setSelectOptions(qs(modal, "[data-cms-upload-folder]"), uploadFolders, false);
3203 renderMediaFolderTree(modal, folders);
3204 const parentSelect = qs(modal, "[data-cms-folder-parent]");
3205 if (parentSelect) {
3206 setSelectOptions(parentSelect, uploadFolders, false);
3207 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3208 if (uploadFolder && uploadFolder.value && uploadFolders.includes(uploadFolder.value)) {
3209 parentSelect.value = uploadFolder.value;
3210 }
3211 }
3212 syncUploadFolderSelect(modal, uploadFolders, modal.__dbxCmsMediaFolder || "");
3213 return folders;
3214 })
3215 .catch(err => {
3216 dbx.warn("[cms] media folders load failed", err);
3217 return modal.__dbxCmsFolders || [];
3218 });
3219 }
3220
3221 function createMediaFolder(root, cfg, modal) {
3222 const url = cfgUrl(cfg, "mediafoldercreate");
3223 const input = qs(modal, "[data-cms-folder-name]");
3224 const parent = qs(modal, "[data-cms-folder-parent]");
3225 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3226 const name = String(input?.value || "").trim();
3227 const parentVal = String(parent?.value || uploadFolder?.value || "").trim();
3228 const folderPath = buildNewMediaFolderPath(parentVal, name);
3229 if (!url || !name || !folderPath) {
3230 status(root, "Bitte Basis-Ordner und Ordnernamen eintragen.", "error");
3231 return Promise.resolve();
3232 }
3233 return fetchJson(apiUrl(url), {
3234 method: "POST",
3235 headers: { "Content-Type": "application/json" },
3236 body: JSON.stringify({ media_type: mediaTypeFromFolder(folderPath), media_folder: folderPath })
3237 }).then(data => {
3238 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder create failed");
3239 if (input) input.value = "";
3240 status(root, "Medienordner angelegt.", "success");
3241 return refreshMediaFolderControls(root, cfg, modal).then(() => {
3242 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3243 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
3244 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
3245 if (data.folder) {
3246 if (filterFolder && (data.folder === "all" || (modal.__dbxCmsFolders || []).includes(data.folder))) {
3247 filterFolder.value = data.folder;
3248 }
3249 if (uploadFolder && uploadFolders.includes(data.folder)) uploadFolder.value = data.folder;
3250 }
3251 syncUploadFolderSelect(modal, uploadFolders, data.folder || "");
3252 });
3253 }).catch(err => {
3254 dbx.error("[cms] media folder create failed", err);
3255 status(root, err && err.message ? err.message : "Medienordner konnte nicht angelegt werden.", "error");
3256 });
3257 }
3258
3259 function deleteSelectedMediaFolder(root, cfg, modal) {
3260 const url = cfgUrl(cfg, "mediafolderdelete");
3261 const select = qs(modal, "[data-cms-upload-folder]");
3262 const folder = String(select?.value || "");
3263 if (!url || !folder) return Promise.resolve();
3264
3265 return ensureConfirm().then(ok => {
3266 if (!ok) {
3267 status(root, "Confirm-Lib ist nicht geladen.", "error");
3268 return null;
3269 }
3270
3271 return dbx.confirm.open({
3272 id: "cms-delete-media-folder-" + folder,
3273 root,
3274 title: "<i class=\"bi bi-trash\"></i> Medienordner loeschen",
3275 question: "Medienordner <strong>" + escapeHtml(folder) + "</strong> wirklich loeschen?",
3276 hint: "Der Ordner kann nur geloescht werden, wenn er leer ist.",
3277 buttons: "yesno",
3278 labelyes: "<i class=\"bi bi-trash\"></i> Loeschen",
3279 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
3280 closable: true,
3281 backdropclose: false,
3282 escclose: true
3283 });
3284 }).then(result => {
3285 if (!result || result.action !== "yes") return null;
3286
3287 return fetchJson(apiUrl(url), {
3288 method: "POST",
3289 headers: { "Content-Type": "application/json" },
3290 body: JSON.stringify({ media_folder: folder })
3291 });
3292 }).then(data => {
3293 if (!data) return null;
3294 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder delete failed");
3295 status(root, "Medienordner geloescht.", "success");
3296 return refreshMediaFolderControls(root, cfg, modal);
3297 }).catch(err => {
3298 dbx.error("[cms] media folder delete failed", err);
3299 status(root, err && err.message ? err.message : "Medienordner konnte nicht geloescht werden.", "error");
3300 });
3301 }
3302
3303 function renameSelectedMediaFolder(root, cfg, modal) {
3304 const url = cfgUrl(cfg, "mediafolderrename");
3305 const select = qs(modal, "[data-cms-upload-folder]");
3306 const input = qs(modal, "[data-cms-folder-rename-name]");
3307 const from = String(select?.value || "").trim();
3308 const newName = String(input?.value || "").trim();
3309 if (!url || !from || !newName) {
3310 status(root, "Bitte Ordner und neuen Namen eintragen.", "error");
3311 return Promise.resolve();
3312 }
3313 const segments = from.split("/");
3314 const toFolder = newName.indexOf("/") >= 0
3315 ? newName
3316 : (segments.length > 1 ? segments.slice(0, -1).concat(newName) : [segments[0] || "img", newName]).join("/");
3317 if (toFolder === from) {
3318 status(root, "Der neue Name ist identisch.", "error");
3319 return Promise.resolve();
3320 }
3321 return fetchJson(apiUrl(url), {
3322 method: "POST",
3323 headers: { "Content-Type": "application/json" },
3324 body: JSON.stringify({ from_folder: from, to_folder: toFolder })
3325 }).then(data => {
3326 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder rename failed");
3327 if (input) input.value = "";
3328 status(root, data.msg || "Medienordner umbenannt.", "success");
3329 return refreshMediaFolderControls(root, cfg, modal).then(() => {
3330 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
3331 const filterFolder = qs(modal, "[data-cms-media-browser-folder]");
3332 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
3333 if (data.to_folder) {
3334 if (filterFolder && (modal.__dbxCmsFolders || []).includes(data.to_folder)) {
3335 filterFolder.value = data.to_folder;
3336 modal.__dbxCmsMediaFolder = data.to_folder;
3337 }
3338 if (uploadFolder && uploadFolders.includes(data.to_folder)) uploadFolder.value = data.to_folder;
3339 }
3340 syncUploadFolderSelect(modal, uploadFolders, data.to_folder || "");
3341 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3342 mode: modal.__dbxCmsMediaMode || "editor",
3343 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3344 mediaFolder: data.to_folder || modal.__dbxCmsMediaFolder || "",
3345 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3346 afterAssign: modal.__dbxCmsAfterAssign
3347 });
3348 });
3349 }).catch(err => {
3350 dbx.error("[cms] media folder rename failed", err);
3351 status(root, err && err.message ? err.message : "Medienordner konnte nicht umbenannt werden.", "error");
3352 });
3353 }
3354
3355 function moveMediaFolderToFolder(root, cfg, modal, source, target) {
3356 const url = cfgUrl(cfg, "mediafolderrename");
3357 source = String(source || "").trim();
3358 target = String(target || "").trim();
3359 if (!url) {
3360 status(root, "Ordner verschieben ist nicht konfiguriert.", "error");
3361 return Promise.resolve();
3362 }
3363 if (!source || !target) {
3364 status(root, "Quell- und Zielordner fehlen.", "error");
3365 return Promise.resolve();
3366 }
3367 if (source === target || target.indexOf(source + "/") === 0) {
3368 status(root, "Ordner kann nicht in sich selbst verschoben werden.", "error");
3369 return Promise.resolve();
3370 }
3371 const currentParent = mediaFolderParent(source);
3372 if (currentParent === target) {
3373 status(root, "Ordner liegt bereits dort.", "warning");
3374 return Promise.resolve();
3375 }
3376 const toFolder = target.replace(/\/+$/g, "") + "/" + mediaFolderName(source);
3377 if ((modal.__dbxCmsFolders || []).includes(toFolder)) {
3378 status(root, "Zielordner existiert bereits.", "error");
3379 return Promise.resolve();
3380 }
3381 return fetchJson(apiUrl(url), {
3382 method: "POST",
3383 headers: { "Content-Type": "application/json" },
3384 body: JSON.stringify({ from_folder: source, to_folder: toFolder })
3385 }).then(data => {
3386 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "folder move failed");
3387 modal.__dbxCmsMediaTreeFolder = data.to_folder || toFolder;
3388 status(root, data.msg || "Medienordner verschoben.", "success");
3389 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3390 mode: modal.__dbxCmsMediaMode || "editor",
3391 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3392 mediaFolder: data.to_folder || toFolder,
3393 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3394 afterAssign: modal.__dbxCmsAfterAssign
3395 });
3396 }).catch(err => {
3397 dbx.error("[cms] media folder move failed", err);
3398 status(root, err && err.message ? err.message : "Medienordner konnte nicht verschoben werden.", "error");
3399 });
3400 }
3401
3402 function moveSelectedMedia(root, cfg, modal) {
3403 const url = cfgUrl(cfg, "mediamove");
3404 const targetSelect = qs(modal, "[data-cms-media-move-folder]");
3405 const target = String(targetSelect?.value || "").trim();
3406 const rows = selectedMediaBrowserRows(modal);
3407 if (!url) {
3408 status(root, "Verschieben ist nicht konfiguriert.", "error");
3409 return Promise.resolve();
3410 }
3411 if (!target) {
3412 status(root, "Bitte Zielordner waehlen.", "error");
3413 return Promise.resolve();
3414 }
3415 if (!rows.length) {
3416 status(root, "Bitte mindestens ein Medium auswaehlen.", "error");
3417 return Promise.resolve();
3418 }
3419 const movable = rows.filter(row => !isExternalVideoRow(row));
3420 if (!movable.length) {
3421 status(root, "YouTube-Eintraege koennen nicht verschoben werden.", "error");
3422 return Promise.resolve();
3423 }
3424 let chain = Promise.resolve();
3425 let moved = 0;
3426 movable.forEach(row => {
3427 chain = chain.then(() => fetchJson(apiUrl(url), {
3428 method: "POST",
3429 headers: { "Content-Type": "application/json" },
3430 body: JSON.stringify({ media_id: row.id, media_folder: target })
3431 }).then(data => {
3432 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "media move failed");
3433 moved++;
3434 }));
3435 });
3436 return chain.then(() => {
3437 modal.__dbxCmsSelectedIds = new Set();
3438 status(root, moved === 1 ? "Medium verschoben." : moved + " Medien verschoben.", "success");
3439 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3440 mode: modal.__dbxCmsMediaMode || "editor",
3441 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3442 mediaFolder: modal.__dbxCmsMediaFolder || "",
3443 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3444 afterAssign: modal.__dbxCmsAfterAssign
3445 });
3446 }).catch(err => {
3447 dbx.error("[cms] media move failed", err);
3448 status(root, err && err.message ? err.message : "Medium konnte nicht verschoben werden.", "error");
3449 });
3450 }
3451
3452 function draggedMediaRows(modal, mediaId) {
3453 mediaId = Number(mediaId || 0);
3454 const selected = modal.__dbxCmsSelectedIds || new Set();
3455 if (mediaId > 0 && selected.has(mediaId)) {
3456 const rows = selectedMediaBrowserRows(modal);
3457 if (rows.length) return rows;
3458 }
3459 const item = mediaId > 0 ? qs(modal, `[data-cms-media-browser-item][data-media-id="${mediaId}"]`) : null;
3460 return item ? [mediaRowFromItem(item)] : [];
3461 }
3462
3463 function mediaFolderAcceptsRow(folder, row) {
3464 folder = String(folder || "");
3465 const type = String(row && row.media_type || "").toLowerCase();
3466 const mime = String(row && row.mime || "").toLowerCase();
3467 if (type === "video" || mime.indexOf("video/") === 0) return folder === "videos" || folder.indexOf("videos/") === 0 || folder === "video" || folder.indexOf("video/") === 0;
3468 if (type === "file") return folder === "file" || folder.indexOf("file/") === 0;
3469 return folder.indexOf("img/") === 0 && folder !== "img/video" && folder.indexOf("img/video/") !== 0;
3470 }
3471
3472 function moveMediaRowsToFolder(root, cfg, modal, rows, target) {
3473 const url = cfgUrl(cfg, "mediamove");
3474 target = String(target || "").trim();
3475 if (!url) {
3476 status(root, "Verschieben ist nicht konfiguriert.", "error");
3477 return Promise.resolve();
3478 }
3479 if (!target) {
3480 status(root, "Bitte Zielordner waehlen.", "error");
3481 return Promise.resolve();
3482 }
3483 const incompatible = (rows || []).filter(row => row && !isExternalVideoRow(row) && !mediaFolderAcceptsRow(target, row));
3484 if (incompatible.length) {
3485 status(root, "Zielordner passt nicht zum Medientyp.", "error");
3486 return Promise.resolve();
3487 }
3488 const movable = (rows || []).filter(row => row && !isExternalVideoRow(row) && String(row.media_folder || "") !== target);
3489 if (!movable.length) {
3490 status(root, "Keine verschiebbaren Medien fuer diesen Ordner.", "warning");
3491 return Promise.resolve();
3492 }
3493 let moved = 0;
3494 return movable.reduce((chain, row) => {
3495 return chain.then(() => fetchJson(apiUrl(url), {
3496 method: "POST",
3497 headers: { "Content-Type": "application/json" },
3498 body: JSON.stringify({ media_id: row.id, media_folder: target })
3499 }).then(data => {
3500 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "media move failed");
3501 moved++;
3502 if (data.row) patchMediaBrowserRow(modal, data.row);
3503 }));
3504 }, Promise.resolve()).then(() => {
3505 modal.__dbxCmsSelectedIds = new Set();
3506 modal.__dbxCmsMediaTreeFolder = target;
3507 status(root, moved === 1 ? "Medium verschoben." : moved + " Medien verschoben.", "success");
3508 return openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
3509 mode: modal.__dbxCmsMediaMode || "editor",
3510 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
3511 mediaFolder: modal.__dbxCmsMediaFolder || "",
3512 formDataExtra: modal.__dbxCmsFormDataExtra || null,
3513 afterAssign: modal.__dbxCmsAfterAssign
3514 });
3515 }).catch(err => {
3516 dbx.error("[cms] media tree move failed", err);
3517 status(root, err && err.message ? err.message : "Medium konnte nicht verschoben werden.", "error");
3518 });
3519 }
3520
3521 function setMediaBrowserSelection(modal, mediaId, selected) {
3522 mediaId = Number(mediaId || 0);
3523 if (!modal || mediaId <= 0) return;
3524 if (!modal.__dbxCmsSelectedIds) modal.__dbxCmsSelectedIds = new Set();
3525 if (selected && !isMediaBrowserMulti(modal)) {
3526 modal.__dbxCmsSelectedIds.clear();
3527 qsa(modal, "[data-cms-media-browser-item].is-selected").forEach(el => {
3528 el.classList.remove("is-selected");
3529 });
3530 const batchPanel = modal.__dbxCmsBatchPanel;
3531 if (batchPanel) {
3532 qsa(batchPanel, "[data-cms-batch-item].is-selected").forEach(el => {
3533 el.classList.remove("is-selected");
3534 });
3535 }
3536 }
3537 if (selected) modal.__dbxCmsSelectedIds.add(mediaId);
3538 else modal.__dbxCmsSelectedIds.delete(mediaId);
3539 qsa(modal, `[data-cms-media-browser-item][data-media-id="${mediaId}"]`).forEach(el => {
3540 el.classList.toggle("is-selected", selected);
3541 });
3542 const batchPanel = modal.__dbxCmsBatchPanel;
3543 if (batchPanel) {
3544 qsa(batchPanel, `[data-cms-batch-item][data-media-id="${mediaId}"]`).forEach(el => {
3545 el.classList.toggle("is-selected", selected);
3546 });
3547 }
3548 updateMediaBrowserConfirm(modal);
3549 updateMediaBrowserBatchUi(modal);
3550 }
3551
3552 function toggleMediaBrowserSelection(modal, item) {
3553 const id = Number(item?.getAttribute("data-media-id") || 0);
3554 if (!id) return;
3555 const selected = !(modal.__dbxCmsSelectedIds || new Set()).has(id);
3556 setMediaBrowserSelection(modal, id, selected);
3557 }
3558
3559 function renderMediaBatchList(browserModal) {
3560 const batchPanel = browserModal && browserModal.__dbxCmsBatchPanel;
3561 const list = batchPanel && qs(batchPanel, "[data-cms-media-batch-list]");
3562 if (!list || !browserModal) return;
3563 const rows = (browserModal.__dbxCmsRows || []).filter(canEditImage);
3564 const selected = browserModal.__dbxCmsSelectedIds || new Set();
3565 if (!rows.length) {
3566 list.innerHTML = '<div class="dbx-cms-empty">Keine bearbeitbaren Bilder im aktuellen Medienbrowser-Filter.</div>';
3567 return;
3568 }
3569 list.innerHTML = rows.map(row => `
3570 <button type="button"
3571 class="dbx-cms-media-batch-item${selected.has(Number(row.id || 0)) ? " is-selected" : ""}"
3572 data-cms-batch-item
3573 data-media-id="${escapeHtml(row.id || "")}"
3574 title="${escapeHtml(row.title || row.file_name || "Bild")}">
3575 <span class="dbx-cms-media-batch-thumb">${mediaPreviewHtml(row)}</span>
3576 <span class="dbx-cms-media-batch-name">${escapeHtml(row.title || row.file_name || "Bild")}</span>
3577 <em class="dbx-cms-media-browser-check"><i class="bi bi-check2"></i></em>
3578 </button>`).join("");
3579 setupMediaLazyImages(list);
3580 }
3581
3582 function patchMediaBrowserRow(browserModal, row) {
3583 if (!browserModal || !row) return;
3584 const id = Number(row.id || 0);
3585 if (id <= 0) return;
3586 const rows = browserModal.__dbxCmsRows;
3587 if (Array.isArray(rows)) {
3588 const idx = rows.findIndex(item => Number(item.id || 0) === id);
3589 if (idx >= 0) rows[idx] = Object.assign({}, rows[idx], row);
3590 else rows.push(row);
3591 }
3592 const thumb = row.thumb_url || row.url || "";
3593 qsa(browserModal, `[data-cms-media-browser-item][data-media-id="${id}"]`).forEach(el => {
3594 if (row.url) el.setAttribute("data-url", row.url);
3595 if (row.thumb_url) el.setAttribute("data-thumb-url", row.thumb_url);
3596 if (row.width !== undefined && row.width !== null) el.setAttribute("data-width", row.width);
3597 if (row.height !== undefined && row.height !== null) el.setAttribute("data-height", row.height);
3598 if (row.file_name) el.setAttribute("data-file-name", row.file_name);
3599 if (row.file_path) el.setAttribute("data-file-path", row.file_path);
3600 if (row.title) el.setAttribute("data-title", row.title);
3601 if (row.alt) el.setAttribute("data-alt", row.alt);
3602 if (row.media_folder) el.setAttribute("data-media-folder", row.media_folder);
3603 const img = qs(el, ".dbx-cms-media-browser-pickarea img");
3604 if (img && thumb) {
3605 img.src = thumb;
3606 img.removeAttribute("data-dbx-media-src");
3607 }
3608 });
3609 renderMediaBatchList(browserModal);
3610 }
3611
3612 function selectedMediaBrowserRows(modal) {
3613 const selected = modal.__dbxCmsSelectedIds || new Set();
3614 return mediaBrowserAllRows(modal).filter(row => selected.has(Number(row.id || 0)));
3615 }
3616
3617 function closeMediaBrowserModal(root, modal) {
3618 if (!modal) return;
3619 modal.hidden = true;
3620 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
3621 dbx.openWin.close(modal.__dbxCmsWindowId);
3622 }
3623 clearCmsLoading(root);
3624 }
3625
3626 function leaveInlineMediaCaption(root) {
3627 const sel = window.getSelection ? window.getSelection() : null;
3628 if (!sel || !sel.rangeCount) return;
3629 let node = sel.anchorNode;
3630 if (node && node.nodeType !== 1) node = node.parentElement;
3631 const figure = node && node.closest ? node.closest("figure, .dbx-cms-inline-media, .dbx-cms-inline-image, .dbx-cms-inline-video") : null;
3632 const editor = qs(root, "[data-cms-editor]");
3633 if (!figure || !editor || !editor.contains(figure)) return;
3634 const range = document.createRange();
3635 range.setStartAfter(figure);
3636 range.collapse(true);
3637 sel.removeAllRanges();
3638 sel.addRange(range);
3639 }
3640
3641 function selectedUploadFiles(form) {
3642 const input = qs(form, 'input[type="file"][name="file"]');
3643 return input && input.files && input.files.length ? Array.from(input.files) : [];
3644 }
3645
3646 function selectedUploadFile(form) {
3647 const files = selectedUploadFiles(form);
3648 return files.length ? files[0] : null;
3649 }
3650
3651 function setUploadFiles(form, files) {
3652 const input = qs(form, 'input[type="file"][name="file"]');
3653 files = Array.from(files || []).filter(Boolean);
3654 if (!input || !files.length || !window.DataTransfer) return false;
3655 const dt = new DataTransfer();
3656 files.forEach(file => dt.items.add(file));
3657 input.files = dt.files;
3658 updateUploadLabel(form);
3659 return true;
3660 }
3661
3662 function setUploadFile(form, file) {
3663 return setUploadFiles(form, file ? [file] : []);
3664 }
3665
3666 function updateUploadLabel(form) {
3667 const label = qs(form, "[data-cms-upload-label]");
3668 const files = selectedUploadFiles(form);
3669 if (label) {
3670 if (files.length > 1) {
3671 label.textContent = files.length + " Dateien ausgewaehlt";
3672 } else {
3673 label.textContent = files.length ? files[0].name : "Datei auswaehlen oder hier ablegen";
3674 }
3675 }
3676 form.classList.toggle("has-file", files.length > 0);
3677 }
3678
3679 function formatBytes(bytes) {
3680 bytes = Number(bytes || 0);
3681 if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(1).replace(/\.0$/, "") + " GB";
3682 if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1).replace(/\.0$/, "") + " MB";
3683 if (bytes >= 1024) return (bytes / 1024).toFixed(1).replace(/\.0$/, "") + " KB";
3684 return bytes + " B";
3685 }
3686
3687 function isExternalVideoRow(row) {
3688 const type = String(row && row.media_type || "").toLowerCase();
3689 const storageType = String(row && row.storage_type || "").toLowerCase();
3690 return type === "external_video" || (type === "video" && storageType === "external");
3691 }
3692
3693 function isVideoRow(row) {
3694 return isExternalVideoRow(row) || String(row.media_type || "").toLowerCase() === "video" || String(row.mime || "").startsWith("video/") || /\.(mp4|webm|ogv|ogg|mov|m4v)$/i.test(row.file_name || row.url || "");
3695 }
3696
3697 function isImageRow(row) {
3698 return String(row.media_type || "").toLowerCase() === "image" || String(row.mime || "").startsWith("image/") || /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(row.file_name || row.url || "");
3699 }
3700
3701 function canEditImage(row) {
3702 return isImageRow(row) && !/\.svg$/i.test(row.file_name || row.url || "");
3703 }
3704
3705 const mediaLazyPlaceholder = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'%3E%3Crect width='320' height='220' rx='12' fill='%23dbeafe'/%3E%3Crect x='24' y='24' width='272' height='172' rx='10' fill='%23eff6ff' stroke='%2393c5fd' stroke-width='3'/%3E%3Ccircle cx='118' cy='90' r='22' fill='%23bfdbfe'/%3E%3Cpath d='M64 166l62-54 42 34 28-24 60 44z' fill='%2360a5fa' opacity='.55'/%3E%3C/svg%3E";
3706
3707 function mediaLazyImageHtml(src, label, extraHtml) {
3708 src = String(src || "");
3709 if (!src) return "";
3710 return `<img class="dbx-cms-media-lazy-image" src="${mediaLazyPlaceholder}" data-dbx-media-src="${escapeHtml(src)}" alt="${label}" loading="lazy" decoding="async">${extraHtml || ""}`;
3711 }
3712
3713 function mediaLazyLoadVisible(scope) {
3714 if (!scope) return;
3715 const rootRect = scope.getBoundingClientRect();
3716 const margin = 48;
3717 qsa(scope, "img[data-dbx-media-src]").forEach(img => {
3718 const rect = img.getBoundingClientRect();
3719 if (
3720 rect.bottom < rootRect.top - margin ||
3721 rect.top > rootRect.bottom + margin ||
3722 rect.right < rootRect.left - margin ||
3723 rect.left > rootRect.right + margin
3724 ) {
3725 return;
3726 }
3727 const src = img.getAttribute("data-dbx-media-src");
3728 if (!src || img.__dbxMediaLazyLoading) return;
3729 img.__dbxMediaLazyLoading = true;
3730 const finish = () => {
3731 img.removeEventListener("load", finish);
3732 img.removeEventListener("error", finish);
3733 img.removeAttribute("data-dbx-media-src");
3734 img.__dbxMediaLazyLoading = false;
3735 };
3736 img.addEventListener("load", finish);
3737 img.addEventListener("error", finish);
3738 img.src = src;
3739 });
3740 }
3741
3742 function scheduleMediaLazyLoad(scope) {
3743 if (!scope) return;
3744 if (scope.__dbxMediaLazyFrame) return;
3745 scope.__dbxMediaLazyFrame = window.requestAnimationFrame(() => {
3746 scope.__dbxMediaLazyFrame = 0;
3747 mediaLazyLoadVisible(scope);
3748 });
3749 }
3750
3751 function setupMediaLazyImages(scope) {
3752 if (!scope) return;
3753 const images = qsa(scope, "img[data-dbx-media-src]");
3754 if (!images.length) return;
3755 if (!scope.__dbxMediaLazyBound) {
3756 scope.__dbxMediaLazyBound = true;
3757 scope.addEventListener("scroll", () => scheduleMediaLazyLoad(scope), { passive: true });
3758 window.addEventListener("resize", () => scheduleMediaLazyLoad(scope), { passive: true });
3759 }
3760 scheduleMediaLazyLoad(scope);
3761 }
3762
3763 function mediaPreviewHtml(row) {
3764 const src = row.thumb_url || row.url || "";
3765 const label = escapeHtml(row.alt || row.title || "");
3766 if (isImageRow(row) && src) return mediaLazyImageHtml(src, label);
3767 if (isExternalVideoRow(row)) {
3768 return row.thumb_url
3769 ? mediaLazyImageHtml(row.thumb_url, label, '<i class="bi bi-play-fill dbx-cms-media-video-icon"></i>')
3770 : '<i class="bi bi-camera-video"></i>';
3771 }
3772 if (isVideoRow(row)) {
3773 return row.thumb_url
3774 ? mediaLazyImageHtml(row.thumb_url, label, '<i class="bi bi-play-fill dbx-cms-media-video-icon"></i>')
3775 : '<i class="bi bi-camera-video"></i>';
3776 }
3777 return '<i class="bi bi-file-earmark"></i>';
3778 }
3779
3780 function mediaProcessHeadMarkup() {
3781 return '<div class="dbx-cms-media-process-head"><strong><i class="bi bi-tools"></i> Medienwartung</strong><button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-process-close title="Zurueck zum Medienbrowser"><i class="bi bi-arrow-left"></i><span>Zurueck</span></button></div>';
3782 }
3783
3784 function mediaMaintenanceFolderOptions(modal, includeAll, preferred) {
3785 const folders = uploadFolderOptions(modal && modal.__dbxCmsFolders || [])
3786 .filter(folder => String(folder || "").indexOf("img/") === 0);
3787 const current = String(preferred || "");
3788 const options = [];
3789 if (includeAll) options.push('<option value="all">Alle Ordner</option>');
3790 folders.forEach(folder => {
3791 options.push(`<option value="${escapeHtml(folder)}"${folder === current ? " selected" : ""}>${escapeHtml(mediaFolderLabel(folder))}</option>`);
3792 });
3793 return options.join("");
3794 }
3795
3796 function renderMediaMaintenanceHome(root, cfg, browserModal, batchPanel) {
3797 const panel = (browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null)
3798 || (batchPanel ? qs(batchPanel, "[data-cms-media-process-panel]") : null);
3799 if (!panel) return;
3800 if (browserModal) {
3801 browserModal.classList.remove("is-folder-tree-open", "is-batch-open");
3802 browserModal.classList.add("is-process-open");
3803 const batch = browserModal.__dbxCmsBatchPanel || null;
3804 if (batch) batch.hidden = true;
3805 }
3806 const folderSelect = browserModal ? qs(browserModal, "[data-cms-media-browser-folder]") : null;
3807 const selectedFolder = String(folderSelect?.value || browserModal?.__dbxCmsMediaFolder || "");
3808 const preferredSource = selectedFolder && selectedFolder !== "all" ? selectedFolder : "all";
3809 panel.hidden = false;
3810 panel.innerHTML = mediaProcessHeadMarkup() + `
3811 <div class="dbx-cms-media-maintenance-grid">
3812 <section class="dbx-cms-media-maintenance-card">
3813 <strong><i class="bi bi-arrow-repeat"></i> Medien pruefen</strong>
3814 <p>Synchronisiert Mediendateien und Vorschaubilder mit der Datenbank.</p>
3815 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-process-start>
3816 <i class="bi bi-play-fill"></i>
3817 <span>Wartung starten</span>
3818 </button>
3819 </section>
3820 <section class="dbx-cms-media-maintenance-card">
3821 <strong><i class="bi bi-image"></i> Nicht verwendete Bilder</strong>
3822 <label>
3823 <span>Quelle</span>
3824 <select class="form-select form-select-sm" data-cms-media-unused-source>
3825 ${mediaMaintenanceFolderOptions(browserModal, true, preferredSource)}
3826 </select>
3827 </label>
3828 <label>
3829 <span>Zielordner fuer Verschieben</span>
3830 <select class="form-select form-select-sm" data-cms-media-unused-target>
3831 ${mediaMaintenanceFolderOptions(browserModal, false, "")}
3832 </select>
3833 </label>
3834 <div class="dbx-cms-media-maintenance-actions">
3835 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-media-unused-action="delete">
3836 <i class="bi bi-trash"></i>
3837 <span>Unbenutzte loeschen</span>
3838 </button>
3839 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-unused-action="move">
3840 <i class="bi bi-folder-symlink"></i>
3841 <span>Unbenutzte verschieben</span>
3842 </button>
3843 </div>
3844 <p class="dbx-cms-media-maintenance-note" data-cms-media-unused-result>Es werden nur Bilder ohne erkannte Verwendung verarbeitet.</p>
3845 </section>
3846 </div>`;
3847 if (dbx.scan) dbx.scan(panel);
3848 }
3849
3850 function startMediaMaintenance(root, cfg, browserModal, batchPanel) {
3851 const url = cfgUrl(cfg || {}, "mediaprocess");
3852 const panel = (browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null)
3853 || (batchPanel ? qs(batchPanel, "[data-cms-media-process-panel]") : null);
3854 if (!url || !panel) {
3855 status(root, "Medienwartung ist nicht konfiguriert.", "error");
3856 return;
3857 }
3858 if (browserModal) {
3859 browserModal.classList.remove("is-folder-tree-open", "is-batch-open");
3860 browserModal.classList.add("is-process-open");
3861 const batch = browserModal.__dbxCmsBatchPanel || null;
3862 if (batch) batch.hidden = true;
3863 }
3864
3865 const token = "media-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
3866 panel.hidden = false;
3867 const processHead = mediaProcessHeadMarkup();
3868 panel.innerHTML = processHead + '<div class="dbx-cms-empty">Medienwartung wird vorbereitet...</div>';
3869
3870 fetchHtml(apiUrl(url, { reset: 1, proc_key: token }), { timeout: 20000 })
3871 .then(html => {
3872 panel.innerHTML = processHead + (html ? extractProcessHtml(html) : '<div class="dbx-cms-empty">Medienwartung konnte nicht gestartet werden.</div>');
3873 if (dbx.scan) dbx.scan(panel);
3874 const proc = qs(panel, ".dbx-process");
3875 if (proc && proc.getAttribute("data-process-status") === "finished" && browserModal) {
3876 status(root, "Medienwartung abgeschlossen.", "success");
3877 openMediaBrowser(root, cfg, {
3878 mode: browserModal.__dbxCmsMediaMode || "editor",
3879 slot: browserModal.__dbxCmsAssignSlot || currentMediaSlot(root),
3880 mediaFolder: browserModal.__dbxCmsMediaFolder || "",
3881 formDataExtra: browserModal.__dbxCmsFormDataExtra || null,
3882 afterAssign: browserModal.__dbxCmsAfterAssign
3883 });
3884 }
3885 })
3886 .catch(err => {
3887 dbx.error("[cms] media maintenance failed", err);
3888 panel.innerHTML = '<div class="dbx-cms-empty">Medienwartung konnte nicht gestartet werden.</div>';
3889 status(root, err && err.message ? err.message : "Medienwartung konnte nicht gestartet werden.", "error");
3890 })
3891 .finally(() => clearCmsLoading(root));
3892 }
3893
3894 function renderUnusedMediaProcess(panel, state) {
3895 state = state || {};
3896 const percent = Math.max(0, Math.min(100, Number(state.percent || 0)));
3897 const title = state.title || "Unbenutzte Bilder";
3898 const message = state.message || "Wartung wird vorbereitet...";
3899 const detail = state.detail || "";
3900 const done = state.done === true;
3901 panel.innerHTML = mediaProcessHeadMarkup() + `
3902 <div class="dbx-cms-media-unused-process" data-cms-media-unused-process>
3903 <div class="dbx-cms-media-unused-process-title">
3904 <strong><i class="bi ${done ? "bi-check2-circle" : "bi-arrow-repeat"}"></i> ${escapeHtml(title)}</strong>
3905 <span>${percent}%</span>
3906 </div>
3907 <div class="dbx-cms-media-unused-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${percent}">
3908 <span style="width:${percent}%"></span>
3909 </div>
3910 <p>${escapeHtml(message)}</p>
3911 ${detail ? `<pre>${escapeHtml(detail)}</pre>` : ""}
3912 </div>`;
3913 if (dbx.scan) dbx.scan(panel);
3914 }
3915
3916 function executeUnusedMediaMaintenance(root, cfg, browserModal, action) {
3917 const url = cfgUrl(cfg || {}, "mediaunused");
3918 const panel = browserModal ? qs(browserModal, "[data-cms-media-process-panel]") : null;
3919 if (!url || !panel) {
3920 status(root, "Wartung fuer unbenutzte Bilder ist nicht konfiguriert.", "error");
3921 return;
3922 }
3923 action = action === "move" ? "move" : "delete";
3924 const source = String(qs(panel, "[data-cms-media-unused-source]")?.value || "all");
3925 const target = String(qs(panel, "[data-cms-media-unused-target]")?.value || "");
3926 if (action === "move" && !target) {
3927 status(root, "Bitte Zielordner waehlen.", "error");
3928 return;
3929 }
3930 const sourceLabel = source === "all" ? "allen Ordnern" : "Ordner " + source;
3931 const question = action === "delete"
3932 ? "Alle nicht verwendeten Bilder aus " + sourceLabel + " wirklich loeschen?"
3933 : "Alle nicht verwendeten Bilder aus " + sourceLabel + " nach " + target + " verschieben?";
3934 ensureConfirm().then(ok => {
3935 if (!ok) {
3936 status(root, "Confirm-Lib ist nicht geladen.", "error");
3937 return null;
3938 }
3939 return dbx.confirm.open({
3940 id: "cms-media-unused-" + action + "-" + Date.now(),
3941 root,
3942 title: "<i class=\"bi bi-tools\"></i> Medienwartung",
3943 question: escapeHtml(question),
3944 hint: "Verwendete Bilder werden serverseitig erneut geprueft und uebersprungen.",
3945 buttons: "yesno",
3946 labelyes: action === "delete" ? "<i class=\"bi bi-trash\"></i> Loeschen" : "<i class=\"bi bi-folder-symlink\"></i> Verschieben",
3947 labelno: "<i class=\"bi bi-x-lg\"></i> Abbrechen",
3948 closable: true,
3949 backdropclose: false,
3950 escclose: true
3951 });
3952 }).then(result => {
3953 if (!result || result.action !== "yes") return null;
3954 if (browserModal) browserModal.classList.add("is-process-open");
3955 panel.hidden = false;
3956 renderUnusedMediaProcess(panel, {
3957 percent: 15,
3958 title: action === "delete" ? "Unbenutzte Bilder loeschen" : "Unbenutzte Bilder verschieben",
3959 message: "Server prueft Verwendungen und verarbeitet die Bilder..."
3960 });
3961 return fetchJson(apiUrl(url), {
3962 method: "POST",
3963 headers: { "Content-Type": "application/json" },
3964 body: JSON.stringify({ action, source_folder: source, target_folder: target }),
3965 timeout: 120000
3966 });
3967 }).then(data => {
3968 if (!data) return;
3969 if (!data.ok) throw new Error(data && data.msg ? data.msg : "Wartung konnte nicht ausgefuehrt werden.");
3970 const detail = "Geprueft: " + Number(data.checked || 0)
3971 + "\nVerarbeitet: " + Number(data.affected || 0)
3972 + "\nUebersprungen, weil verwendet: " + Number(data.skipped_used || 0)
3973 + (Array.isArray(data.errors) && data.errors.length ? "\nFehler:\n" + data.errors.join("\n") : "");
3974 renderUnusedMediaProcess(panel, {
3975 percent: 100,
3976 done: true,
3977 title: action === "delete" ? "Loeschen abgeschlossen" : "Verschieben abgeschlossen",
3978 message: data.msg || "Wartung abgeschlossen.",
3979 detail
3980 });
3981 status(root, data.msg || "Wartung abgeschlossen.", Number(data.affected || 0) > 0 ? "success" : "warning");
3982 if (browserModal) {
3983 window.setTimeout(() => {
3984 browserModal.classList.remove("is-process-open");
3985 openMediaBrowser(browserModal.__dbxCmsRoot || root, browserCfg(browserModal), {
3986 mode: browserModal.__dbxCmsMediaMode || "editor",
3987 slot: browserModal.__dbxCmsAssignSlot || currentMediaSlot(root),
3988 mediaFolder: source !== "all" ? source : (browserModal.__dbxCmsMediaFolder || ""),
3989 formDataExtra: browserModal.__dbxCmsFormDataExtra || null,
3990 afterAssign: browserModal.__dbxCmsAfterAssign
3991 });
3992 }, 900);
3993 }
3994 }).catch(err => {
3995 dbx.error("[cms] unused media maintenance failed", err);
3996 renderUnusedMediaProcess(panel, {
3997 percent: 100,
3998 done: true,
3999 title: "Wartung fehlgeschlagen",
4000 message: err && err.message ? err.message : "Wartung konnte nicht ausgefuehrt werden."
4001 });
4002 status(root, err && err.message ? err.message : "Wartung konnte nicht ausgefuehrt werden.", "error");
4003 });
4004 }
4005
4006 function openMediaBrowserWindow(root, modal) {
4007 if (!modal) return false;
4008 modal.__dbxCmsCallerRoot = root || null;
4009 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
4010 ensureOpenWin().then(ok => {
4011 if (ok) openMediaBrowserWindow(root, modal);
4012 else status(root, "openWin.js nicht geladen.", "error");
4013 });
4014 return false;
4015 }
4016 const currentWindow = closestElement(modal, ".dbx-window");
4017 const currentIsBrowserWindow = currentWindow && currentWindow.classList.contains("dbx-cms-media-browser-window");
4018 if (currentIsBrowserWindow) {
4019 modal.hidden = false;
4020 raiseMediaBrowserWindow(currentWindow, modal);
4021 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
4022 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
4023 }
4024 raiseMediaBrowserWindow(currentWindow, modal);
4025 return true;
4026 }
4027 const id = dbx.openWin.open({
4028 title: '<i class="bi bi-images"></i> Medienbrowser',
4029 content: modal,
4030 width: "1024",
4031 height: "82%",
4032 minWidth: "860",
4033 minHeight: "460",
4034 maxWidth: "96%",
4035 maxHeight: "94%",
4036 position: "center",
4037 scroll: 0,
4038 resizable: 1,
4039 minimizable: 1,
4040 maximizable: 1,
4041 reloadable: 0,
4042 persist: 0,
4043 reuse: 0
4044 }, root);
4045 if (id) {
4046 modal.__dbxCmsWindowId = id;
4047 modal.hidden = false;
4048 const win = document.getElementById(id) || closestElement(modal, ".dbx-window");
4049 raiseMediaBrowserWindow(win, modal);
4050 }
4051 return !!id;
4052 }
4053
4054 function raiseMediaBrowserWindow(win, modal) {
4055 win = win || closestElement(modal, ".dbx-window");
4056 if (!win && modal && modal.__dbxCmsWindowId) win = document.getElementById(modal.__dbxCmsWindowId);
4057 if (!win) return;
4058 const apply = () => {
4059 win.classList.add("dbx-cms-media-browser-window");
4060 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function" && win.id) {
4061 dbx.openWin.bringToFront(win.id);
4062 }
4063 };
4064 apply();
4065 window.requestAnimationFrame(apply);
4066 window.setTimeout(apply, 80);
4067 }
4068
4069 function mediaBrowserPreset(modal, value) {
4070 if (!modal || !value) return;
4071 const width = qs(modal, "[data-cms-bulk-resize-width]");
4072 const height = qs(modal, "[data-cms-bulk-resize-height]");
4073 if (!width || !height) return;
4074 const parts = String(value).toLowerCase().split("x");
4075 width.value = Number(parts[0] || 0) || "";
4076 height.value = Number(parts[1] || 0) || "";
4077 }
4078
4079 function bindMediaBrowserEvents(root, cfg, modal) {
4080 if (!modal || modal.__dbxCmsEventsBound) return;
4081 modal.__dbxCmsEventsBound = true;
4082
4083 const batchBtn = qs(modal, "[data-cms-media-batch-open]");
4084 if (batchBtn && !batchBtn.__dbxCmsBatchBound) {
4085 batchBtn.__dbxCmsBatchBound = true;
4086 batchBtn.addEventListener("mousedown", e => e.stopPropagation());
4087 batchBtn.addEventListener("click", e => {
4088 e.preventDefault();
4089 e.stopPropagation();
4090 openMediaBatchWindow(modal.__dbxCmsRoot || root, browserCfg(modal), modal);
4091 });
4092 }
4093
4094 modal.addEventListener("click", e => {
4095 e.stopPropagation();
4096 const cfg = browserCfg(modal);
4097
4098 const browserClose = closestElement(e.target, "[data-cms-media-browser-close]");
4099 if (browserClose && modal.contains(browserClose)) {
4100 e.preventDefault();
4101 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
4102 dbx.openWin.close(modal.__dbxCmsWindowId);
4103 } else {
4104 modal.hidden = true;
4105 }
4106 clearCmsLoading(root);
4107 return;
4108 }
4109
4110 const batchOpen = closestElement(e.target, "[data-cms-media-batch-open]");
4111 if (batchOpen && modal.contains(batchOpen)) {
4112 e.preventDefault();
4113 e.stopPropagation();
4114 openMediaBatchWindow(root, cfg, modal);
4115 return;
4116 }
4117
4118 const browserMaintenance = closestElement(e.target, "[data-cms-media-maintenance]");
4119 if (browserMaintenance && modal.contains(browserMaintenance)) {
4120 e.preventDefault();
4121 e.stopPropagation();
4122 const batchPanel = modal.__dbxCmsBatchPanel || null;
4123 if (batchPanel) batchPanel.hidden = true;
4124 renderMediaMaintenanceHome(root, cfg, modal, null);
4125 return;
4126 }
4127
4128 const processStart = closestElement(e.target, "[data-cms-media-process-start]");
4129 if (processStart && modal.contains(processStart)) {
4130 e.preventDefault();
4131 e.stopPropagation();
4132 startMediaMaintenance(root, cfg, modal, null);
4133 return;
4134 }
4135
4136 const unusedAction = closestElement(e.target, "[data-cms-media-unused-action]");
4137 if (unusedAction && modal.contains(unusedAction)) {
4138 e.preventDefault();
4139 e.stopPropagation();
4140 executeUnusedMediaMaintenance(root, cfg, modal, unusedAction.getAttribute("data-cms-media-unused-action"));
4141 return;
4142 }
4143
4144 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
4145 if (processClose && modal.contains(processClose)) {
4146 e.preventDefault();
4147 e.stopPropagation();
4148 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
4149 if (panel) {
4150 panel.hidden = true;
4151 panel.innerHTML = "";
4152 }
4153 modal.classList.remove("is-process-open");
4154 clearCmsLoading(root);
4155 return;
4156 }
4157
4158 const folderCreate = closestElement(e.target, "[data-cms-folder-create]");
4159 if (folderCreate && modal.contains(folderCreate)) {
4160 e.preventDefault();
4161 createMediaFolder(root, cfg, modal);
4162 return;
4163 }
4164
4165 const folderDelete = closestElement(e.target, "[data-cms-folder-delete]");
4166 if (folderDelete && modal.contains(folderDelete)) {
4167 e.preventDefault();
4168 deleteSelectedMediaFolder(root, cfg, modal);
4169 return;
4170 }
4171
4172 const folderRename = closestElement(e.target, "[data-cms-folder-rename]");
4173 if (folderRename && modal.contains(folderRename)) {
4174 e.preventDefault();
4175 renameSelectedMediaFolder(root, cfg, modal);
4176 return;
4177 }
4178
4179 const folderToggle = closestElement(e.target, "[data-cms-media-folder-toggle]");
4180 if (folderToggle && modal.contains(folderToggle)) {
4181 e.preventDefault();
4182 const tree = qs(modal, "[data-cms-media-folder-tree]");
4183 if (tree) {
4184 setMediaBrowserFolderTreeMode(modal, !modal.classList.contains("is-folder-tree-open"));
4185 }
4186 return;
4187 }
4188
4189 const folderTreeClose = closestElement(e.target, "[data-cms-media-folder-tree-close]");
4190 if (folderTreeClose && modal.contains(folderTreeClose)) {
4191 e.preventDefault();
4192 e.stopPropagation();
4193 setMediaBrowserFolderTreeMode(modal, false);
4194 return;
4195 }
4196
4197 const treeSize = closestElement(e.target, "[data-cms-media-tree-size]");
4198 if (treeSize && modal.contains(treeSize)) {
4199 e.preventDefault();
4200 e.stopPropagation();
4201 modal.__dbxCmsMediaTreeSize = String(treeSize.getAttribute("data-cms-media-tree-size") || "medium");
4202 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
4203 return;
4204 }
4205
4206 const treeFolderSelect = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4207 if (treeFolderSelect && modal.contains(treeFolderSelect)) {
4208 e.preventDefault();
4209 e.stopPropagation();
4210 modal.__dbxCmsMediaTreeFolder = String(treeFolderSelect.getAttribute("data-folder") || "");
4211 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
4212 return;
4213 }
4214
4215 const folderDropClick = closestElement(e.target, "[data-cms-media-folder-drop]");
4216 if (folderDropClick && modal.contains(folderDropClick)) {
4217 e.preventDefault();
4218 e.stopPropagation();
4219 const rows = selectedMediaBrowserRows(modal);
4220 if (!rows.length) {
4221 status(root, "Medien markieren oder per Drag auf den Zielordner ziehen.", "info");
4222 return;
4223 }
4224 moveMediaRowsToFolder(root, cfg, modal, rows, folderDropClick.getAttribute("data-folder") || "");
4225 return;
4226 }
4227
4228 const selectBtn = closestElement(e.target, "[data-cms-media-browser-select]");
4229 if (selectBtn && modal.contains(selectBtn)) {
4230 e.preventDefault();
4231 e.stopPropagation();
4232 const item = closestElement(selectBtn, "[data-cms-media-browser-item]");
4233 if (item) toggleMediaBrowserSelection(modal, item);
4234 return;
4235 }
4236
4237 const browserConfirm = closestElement(e.target, "[data-cms-media-browser-confirm]");
4238 if (browserConfirm && modal.contains(browserConfirm)) {
4239 e.preventDefault();
4240 if (modal.__dbxCmsMediaMode === "pick") {
4241 confirmPickMediaBrowser(root, modal);
4242 return;
4243 }
4244 const slot = modal.__dbxCmsAssignSlot || currentMediaSlot(root);
4245 const rows = selectedMediaBrowserRows(modal);
4246 if (!rows.length) {
4247 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
4248 return;
4249 }
4250
4251 let chain = Promise.resolve();
4252 rows.forEach(row => {
4253 chain = chain.then(() => assignMedia(root, cfg, row, slot).then(assignedRow => {
4254 if (!assignedRow) return;
4255 if (slot === "inline") {
4256 insertMediaRow(root, assignedRow);
4257 setLocalMediaSlot(root, assignedRow.id, "inline");
4258 }
4259 if (typeof modal.__dbxCmsAfterAssign === "function") {
4260 modal.__dbxCmsAfterAssign(assignedRow);
4261 }
4262 }));
4263 });
4264 chain.then(() => {
4265 modal.hidden = true;
4266 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") dbx.openWin.close(modal.__dbxCmsWindowId);
4267 clearCmsLoading(root);
4268 status(root, "Auswahl uebernommen.", "success");
4269 });
4270 return;
4271 }
4272
4273 const browserDelete = closestElement(e.target, "[data-cms-media-browser-delete]");
4274 if (browserDelete && modal.contains(browserDelete)) {
4275 e.preventDefault();
4276 const item = closestElement(browserDelete, "[data-cms-media-browser-item]");
4277 const mode = modal.__dbxCmsMediaMode === "assign" ? "assign" : "editor";
4278 deleteMedia(root, cfg, Number(item?.getAttribute("data-media-id") || 0))
4279 .then(() => openMediaBrowser(modal.__dbxCmsRoot || root, cfg, {
4280 mode,
4281 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
4282 mediaFolder: modal.__dbxCmsMediaFolder || "",
4283 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4284 afterAssign: modal.__dbxCmsAfterAssign
4285 }));
4286 return;
4287 }
4288
4289 const browserEdit = closestElement(e.target, "[data-cms-media-browser-edit]");
4290 if (browserEdit && modal.contains(browserEdit)) {
4291 e.preventDefault();
4292 const item = closestElement(browserEdit, "[data-cms-media-browser-item]");
4293 openMediaEdit(root, cfg, mediaRowFromItem(item));
4294 return;
4295 }
4296
4297 const browserPick = closestElement(e.target, "[data-cms-media-browser-pick]");
4298 if (browserPick && modal.contains(browserPick)) {
4299 e.preventDefault();
4300 const item = closestElement(browserPick, "[data-cms-media-browser-item]") || browserPick;
4301 const mediaRow = mediaRowFromItem(item);
4302 const mode = modal.__dbxCmsMediaMode || "editor";
4303 if (mode === "pick" || mode === "assign") {
4304 toggleMediaBrowserSelection(modal, item);
4305 return;
4306 }
4307 assignMedia(root, cfg, mediaRow, "inline").then(assignedRow => {
4308 if (!assignedRow) return;
4309 insertMediaRow(root, assignedRow);
4310 setLocalMediaSlot(root, assignedRow.id, "inline");
4311 modal.hidden = true;
4312 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") dbx.openWin.close(modal.__dbxCmsWindowId);
4313 clearCmsLoading(root);
4314 });
4315 }
4316 });
4317
4318 modal.addEventListener("change", e => {
4319 e.stopPropagation();
4320 const uploadForm = closestElement(e.target, "[data-cms-browser-upload]");
4321 if (uploadForm && modal.contains(uploadForm)) updateUploadLabel(uploadForm);
4322 const uploadFolderSelect = closestElement(e.target, "[data-cms-upload-folder]");
4323 if (uploadFolderSelect && modal.contains(uploadFolderSelect)) uploadFolderSelect.__dbxCmsTouched = true;
4324 });
4325
4326 modal.addEventListener("dragstart", e => {
4327 const item = closestElement(e.target, "[data-cms-media-browser-item]");
4328 if (item && modal.contains(item)) {
4329 const id = Number(item.getAttribute("data-media-id") || 0);
4330 if (!id || isExternalVideoRow(mediaRowFromItem(item))) {
4331 e.preventDefault();
4332 return;
4333 }
4334 modal.__dbxCmsDraggedMediaId = id;
4335 modal.__dbxCmsDraggedFolder = "";
4336 item.classList.add("is-dragging");
4337 if (e.dataTransfer) {
4338 e.dataTransfer.effectAllowed = "move";
4339 e.dataTransfer.setData("text/plain", String(id));
4340 e.dataTransfer.setData("application/x-dbx-media-id", String(id));
4341 }
4342 return;
4343 }
4344
4345 const folder = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4346 if (!folder || !modal.contains(folder)) return;
4347 const folderPath = String(folder.getAttribute("data-folder") || "");
4348 if (!folderPath) {
4349 e.preventDefault();
4350 return;
4351 }
4352 modal.__dbxCmsDraggedFolder = folderPath;
4353 modal.__dbxCmsDraggedMediaId = 0;
4354 folder.classList.add("is-dragging");
4355 if (e.dataTransfer) {
4356 e.dataTransfer.effectAllowed = "move";
4357 e.dataTransfer.setData("text/plain", folderPath);
4358 e.dataTransfer.setData("application/x-dbx-media-folder", folderPath);
4359 }
4360 });
4361
4362 modal.addEventListener("dragend", e => {
4363 const item = closestElement(e.target, "[data-cms-media-browser-item]");
4364 if (item) item.classList.remove("is-dragging");
4365 const folder = closestElement(e.target, "[data-cms-media-tree-folder-select]");
4366 if (folder) folder.classList.remove("is-dragging");
4367 qsa(modal, "[data-cms-media-folder-drop].is-dragover").forEach(el => el.classList.remove("is-dragover"));
4368 modal.__dbxCmsDraggedMediaId = 0;
4369 modal.__dbxCmsDraggedFolder = "";
4370 });
4371
4372 modal.addEventListener("dragover", e => {
4373 e.stopPropagation();
4374 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4375 if (folderDrop && modal.contains(folderDrop)) {
4376 e.preventDefault();
4377 folderDrop.classList.add("is-dragover");
4378 if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
4379 return;
4380 }
4381 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4382 if (!dropzone || !modal.contains(dropzone)) return;
4383 e.preventDefault();
4384 dropzone.classList.add("is-dragover");
4385 });
4386
4387 modal.addEventListener("dragleave", e => {
4388 e.stopPropagation();
4389 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4390 if (folderDrop && modal.contains(folderDrop)) {
4391 folderDrop.classList.remove("is-dragover");
4392 return;
4393 }
4394 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4395 if (!dropzone || !modal.contains(dropzone)) return;
4396 dropzone.classList.remove("is-dragover");
4397 });
4398
4399 modal.addEventListener("drop", e => {
4400 e.stopPropagation();
4401 const folderDrop = closestElement(e.target, "[data-cms-media-folder-drop]");
4402 if (folderDrop && modal.contains(folderDrop)) {
4403 e.preventDefault();
4404 folderDrop.classList.remove("is-dragover");
4405 const target = folderDrop.getAttribute("data-folder") || "";
4406 const draggedFolder = String((e.dataTransfer && e.dataTransfer.getData("application/x-dbx-media-folder")) || modal.__dbxCmsDraggedFolder || "");
4407 if (draggedFolder) {
4408 moveMediaFolderToFolder(root, browserCfg(modal), modal, draggedFolder, target);
4409 return;
4410 }
4411 const id = Number((e.dataTransfer && (e.dataTransfer.getData("application/x-dbx-media-id") || e.dataTransfer.getData("text/plain"))) || modal.__dbxCmsDraggedMediaId || 0);
4412 moveMediaRowsToFolder(root, browserCfg(modal), modal, draggedMediaRows(modal, id), target);
4413 return;
4414 }
4415 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
4416 if (!dropzone || !modal.contains(dropzone)) return;
4417 e.preventDefault();
4418 dropzone.classList.remove("is-dragover");
4419 const form = closestElement(dropzone, "form");
4420 const files = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length ? e.dataTransfer.files : null;
4421 if (!form || !files || !files.length) return;
4422 if (!setUploadFiles(form, files)) {
4423 status(root, "Datei bitte ueber die Dateiauswahl waehlen.", "error");
4424 }
4425 });
4426 }
4427
4428 function openMediaBrowser(root, cfg, options) {
4429 options = options || {};
4430 cfg = Object.assign({}, cfg || {});
4431 const mode = options.mode === "assign" ? "assign" : (options.mode === "pick" ? "pick" : "editor");
4432 const assignSlot = options.slot || (mode === "assign" ? currentMediaSlot(root) : "inline");
4433 let mediaFolder = String(options.mediaFolder || options.media_folder || "").trim();
4434 if (!mediaFolder && mediaBrowserProfile(cfg) === "mod") mediaFolder = "mod";
4435 const formDataExtra = options.formDataExtra && typeof options.formDataExtra === "object" ? options.formDataExtra : null;
4436 const afterAssign = typeof options.afterAssign === "function" ? options.afterAssign : null;
4437 clearCmsLoading(root);
4438 const mediaUrl = cfgUrl(cfg || {}, "media");
4439 if (!mediaUrl) {
4440 status(root, "Medienbrowser ist nicht konfiguriert.", "error");
4441 return;
4442 }
4443
4444 let modal = state(root).mediaBrowser;
4445 if (!modal || !document.documentElement.contains(modal)) {
4446 modal = qs(root, "[data-cms-media-browser]");
4447 }
4448 if (mediaBrowserNeedsRebuild(modal)) {
4449 disposeMediaBrowserModal(root, modal);
4450 modal = null;
4451 }
4452 if (!modal) {
4453 modal = document.createElement("div");
4454 modal.className = "dbx-cms-media-browser";
4455 modal.setAttribute("data-cms-media-browser", "1");
4456 modal.innerHTML = `
4457 <div class="dbx-cms-media-browser-head">
4458 <div>
4459 <strong><i class="bi bi-images"></i> Medienbrowser</strong>
4460 <div class="small text-muted" data-cms-media-root-hint></div>
4461 </div>
4462 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-browser-close title="Schliessen"><i class="bi bi-x-lg"></i></button>
4463 </div>
4464 <div class="dbx-cms-status dbx-cms-media-browser-status" data-cms-status aria-live="polite"></div>
4465 <details class="dbx-cms-media-upload-panel" data-cms-media-upload-panel>
4466 <summary>
4467 <span class="dbx-cms-media-upload-summary-main">
4468 <i class="bi bi-chevron-right dbx-cms-toggle-icon"></i>
4469 <span>Upload und YouTube</span>
4470 </span>
4471 <span class="dbx-cms-media-upload-summary-actions">
4472 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-batch-open title="Batch Resize">
4473 <i class="bi bi-tools"></i>
4474 <span>Batch</span>
4475 </button>
4476 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-maintenance title="Medienwartung im Medienbrowser starten">
4477 <i class="bi bi-tools"></i>
4478 <span>Wartung</span>
4479 </button>
4480 </span>
4481 </summary>
4482 <form class="dbx-cms-browser-upload" data-cms-browser-upload>
4483 <label class="dbx-cms-upload-drop" data-cms-dropzone>
4484 <input type="file" name="file" accept="image/*,video/*,.pdf" multiple>
4485 <span class="dbx-cms-upload-icon"><i class="bi bi-cloud-arrow-up"></i></span>
4486 <span class="dbx-cms-upload-text" data-cms-upload-label>Medium hochladen oder hier ablegen</span>
4487 </label>
4488 <div class="dbx-cms-upload-target">
4489 <label class="dbx-cms-upload-folder-label">
4490 <span class="form-label small mb-1">Zielordner</span>
4491 <select class="form-select form-select-sm" name="media_folder" data-cms-upload-folder title="Zielordner fuer Upload"></select>
4492 </label>
4493 <button type="submit" class="btn btn-primary btn-sm" title="Medium hochladen und einfuegen">
4494 <i class="bi bi-upload"></i>
4495 <span>Upload</span>
4496 </button>
4497 </div>
4498 <input type="hidden" name="slot" value="inline">
4499 <input type="hidden" name="template" value="image-inline">
4500 <input type="hidden" name="max_image_size" value="2560">
4501 </form>
4502 <div class="dbx-cms-media-folderbar">
4503 <span class="small text-muted dbx-cms-media-folderbar-title">Neuer Unterordner:</span>
4504 <select class="form-select form-select-sm" data-cms-folder-parent title="Vorhandenen Ordner als Basis waehlen"></select>
4505 <input type="text" class="form-control form-control-sm" data-cms-folder-name placeholder="Ordnername">
4506 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-folder-create title="Ordner anlegen">
4507 <i class="bi bi-folder-plus"></i>
4508 </button>
4509 <button type="button" class="btn btn-outline-danger btn-sm" data-cms-folder-delete title="Ausgewaehlten Upload-Ordner loeschen">
4510 <i class="bi bi-folder-x"></i>
4511 </button>
4512 <input type="text" class="form-control form-control-sm" data-cms-folder-rename-name placeholder="Neuer Ordnername">
4513 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-folder-rename title="Ordner umbenennen">
4514 <i class="bi bi-pencil"></i>
4515 </button>
4516 </div>
4517 <form class="dbx-cms-browser-external-video" data-cms-browser-external-video>
4518 <input type="url" class="form-control form-control-sm" name="url" placeholder="YouTube-URL einfuegen">
4519 <input type="hidden" name="slot" value="inline">
4520 <button type="submit" class="btn btn-outline-primary btn-sm" title="YouTube-Video hinzufuegen">
4521 <i class="bi bi-youtube"></i>
4522 <span>YouTube</span>
4523 </button>
4524 </form>
4525 </details>
4526 <div class="dbx-cms-media-process-panel" data-cms-media-process-panel hidden></div>
4527 <div class="dbx-cms-media-browser-tools">
4528 <button type="button" class="btn btn-outline-primary btn-sm dbx-cms-media-folder-toggle" data-cms-media-folder-toggle title="Medienordner anzeigen">
4529 <i class="bi bi-list-ul"></i>
4530 </button>
4531 <input type="text" class="form-control form-control-sm" data-cms-media-browser-search placeholder="Medien suchen">
4532 <select class="form-select form-select-sm" data-cms-media-browser-folder title="Verzeichnis anzeigen">
4533 <option value="all">Alle Verzeichnisse</option>
4534 </select>
4535 <select class="form-select form-select-sm" data-cms-media-browser-slot title="Bereich anzeigen">
4536 <option value="all">Alle</option>
4537 <option value="gallery">Galerie</option>
4538 <option value="hero">Hero</option>
4539 <option value="inline">Im Text</option>
4540 <option value="shop">Shop</option>
4541 </select>
4542 </div>
4543 <div class="dbx-cms-media-folder-tree" data-cms-media-folder-tree hidden></div>
4544 <div class="dbx-cms-media-browser-list" data-cms-media-browser-list>
4545 ${mediaBrowserSkeletonHtml(24)}
4546 </div>
4547 <div class="dbx-cms-media-browser-confirm" data-cms-media-browser-confirmbar hidden>
4548 <span><strong data-cms-media-browser-count>0</strong> Medien ausgewaehlt</span>
4549 <button type="button" class="btn btn-primary btn-sm" data-cms-media-browser-confirm>
4550 <i class="bi bi-check2"></i>
4551 <span>Auswahl uebernehmen</span>
4552 </button>
4553 </div>`;
4554 root.appendChild(modal);
4555
4556 const browserUpload = qs(modal, "[data-cms-browser-upload]");
4557 if (browserUpload) {
4558 browserUpload.addEventListener("submit", e => {
4559 e.preventDefault();
4560 const currentMode = modal.__dbxCmsMediaMode || "editor";
4561 const currentSlot = modal.__dbxCmsAssignSlot || (currentMode === "assign" ? currentMediaSlot(root) : "inline");
4562 uploadMedia(root, browserCfg(modal), browserUpload, {
4563 pickMode: currentMode === "pick",
4564 insertUploaded: currentMode === "editor",
4565 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4566 afterUpload: data => {
4567 const rows = data && Array.isArray(data.rows) ? data.rows : (data && data.row ? [data.row] : []);
4568 const responses = data && Array.isArray(data.responses) ? data.responses : [];
4569 const moduleResp = responses.find(item => item && Array.isArray(item.items)) || null;
4570 if (currentMode === "pick" && data && data.ok && moduleResp && Array.isArray(moduleResp.items)) {
4571 if (typeof modal.__dbxCmsAfterAssign === "function") {
4572 modal.__dbxCmsAfterAssign({ items: moduleResp.items, filename: moduleResp.filename || "" });
4573 }
4574 modal.hidden = true;
4575 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
4576 dbx.openWin.close(modal.__dbxCmsWindowId);
4577 }
4578 clearCmsLoading(root);
4579 return;
4580 }
4581 if (currentMode === "pick" && rows.length) {
4582 let chain = Promise.resolve();
4583 rows.forEach(row => {
4584 chain = chain.then(() => {
4585 if (typeof modal.__dbxCmsAfterAssign === "function") {
4586 return Promise.resolve(modal.__dbxCmsAfterAssign(row));
4587 }
4588 });
4589 });
4590 chain.then(() => {
4591 modal.hidden = true;
4592 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
4593 dbx.openWin.close(modal.__dbxCmsWindowId);
4594 }
4595 clearCmsLoading(root);
4596 });
4597 return;
4598 }
4599 if (currentMode === "assign" && rows.length) {
4600 rows.forEach(row => {
4601 if (currentSlot === "inline") applyInlineMediaAssignment(root, row);
4602 else setLocalMediaSlot(root, row.id, currentSlot);
4603 if (typeof modal.__dbxCmsAfterAssign === "function") modal.__dbxCmsAfterAssign(row);
4604 });
4605 }
4606 const refreshFolder = data.uploadFolder
4607 || (rows[0] && rows[0].media_folder)
4608 || qs(modal, "[data-cms-upload-folder]")?.value
4609 || modal.__dbxCmsMediaFolder
4610 || "";
4611 openMediaBrowser(root, browserCfg(modal), {
4612 mode: currentMode,
4613 slot: currentSlot,
4614 mediaFolder: refreshFolder,
4615 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4616 afterAssign: modal.__dbxCmsAfterAssign
4617 });
4618 }
4619 });
4620 });
4621 }
4622 const browserExternalVideo = qs(modal, "[data-cms-browser-external-video]");
4623 if (browserExternalVideo) {
4624 browserExternalVideo.addEventListener("submit", e => {
4625 e.preventDefault();
4626 const currentMode = modal.__dbxCmsMediaMode || "editor";
4627 const currentSlot = modal.__dbxCmsAssignSlot || (currentMode === "assign" ? currentMediaSlot(root) : "inline");
4628 addExternalVideo(root, browserCfg(modal), browserExternalVideo, {
4629 insertExternal: currentMode === "editor",
4630 slot: currentSlot,
4631 afterExternal: data => {
4632 const row = data && data.row ? data.row : null;
4633 if (currentMode === "assign" && row) {
4634 if (currentSlot === "inline") applyInlineMediaAssignment(root, row);
4635 else setLocalMediaSlot(root, row.id, currentSlot);
4636 if (typeof modal.__dbxCmsAfterAssign === "function") modal.__dbxCmsAfterAssign(row);
4637 }
4638 const refreshFolder = (row && row.media_folder)
4639 || qs(modal, "[data-cms-upload-folder]")?.value
4640 || modal.__dbxCmsMediaFolder
4641 || "";
4642 openMediaBrowser(root, browserCfg(modal), {
4643 mode: currentMode,
4644 slot: currentSlot,
4645 mediaFolder: refreshFolder,
4646 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4647 afterAssign: modal.__dbxCmsAfterAssign
4648 });
4649 }
4650 });
4651 });
4652 }
4653 state(root).mediaBrowser = modal;
4654 }
4655 state(root).mediaBrowser = modal;
4656 modal.__dbxCmsCfg = cfg;
4657 modal.__dbxCmsRoot = root;
4658 ensureMediaBrowserFolderUi(modal);
4659 applyMediaBrowserProfile(modal, cfg);
4660 bindMediaBrowserEvents(root, cfg, modal);
4661
4662 const list = qs(modal, "[data-cms-media-browser-list]");
4663 const search = qs(modal, "[data-cms-media-browser-search]");
4664 const slotSelect = qs(modal, "[data-cms-media-browser-slot]");
4665 const folderSelect = qs(modal, "[data-cms-media-browser-folder]");
4666 const uploadFolder = qs(modal, "[data-cms-upload-folder]");
4667 const uploadSlot = qs(modal, 'input[name="slot"]');
4668 const uploadTemplate = qs(modal, 'input[name="template"]');
4669 const externalVideoSlot = qs(modal, '[data-cms-browser-external-video] input[name="slot"]');
4670 modal.__dbxCmsMediaMode = mode;
4671 modal.__dbxCmsAssignSlot = assignSlot;
4672 modal.__dbxCmsAfterAssign = afterAssign;
4673 modal.__dbxCmsMediaFolder = mediaFolder;
4674 modal.__dbxCmsFormDataExtra = formDataExtra;
4675 modal.__dbxCmsSinglePick = options.singlePick === true;
4676 modal.__dbxCmsSelectedIds = new Set();
4677 updateMediaBrowserConfirm(modal);
4678 if (slotSelect) slotSelect.value = "all";
4679 if (folderSelect) folderSelect.value = mediaFolder || folderSelect.value || "all";
4680 if (uploadSlot) uploadSlot.value = assignSlot;
4681 if (uploadTemplate) uploadTemplate.value = mode === "assign" ? "" : "image-inline";
4682 if (externalVideoSlot) externalVideoSlot.value = assignSlot;
4683 clearCmsLoading(root);
4684 modal.hidden = false;
4685 openMediaBrowserWindow(root, modal);
4686 modal.classList.add("is-loading");
4687 modal.setAttribute("aria-busy", "true");
4688 const requestId = Date.now() + "-" + Math.random();
4689 modal.__dbxCmsMediaRequest = requestId;
4690 if (search) search.value = "";
4691 if (list) list.innerHTML = mediaBrowserSkeletonHtml(24);
4692
4693 refreshMediaFolderControls(root, cfg, modal).then(() => {
4694 if (mediaFolder && folderSelect) {
4695 folderSelect.value = mediaFolder;
4696 }
4697 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
4698 syncUploadFolderSelect(modal, uploadFolders, mediaFolder);
4699 const mediaParams = mediaBrowserQueryParams(String(folderSelect?.value || mediaFolder || "all"));
4700 if (!modal.__dbxCmsMediaSynced) mediaParams.sync = 1;
4701 if (formDataExtra && formDataExtra.xmodul) mediaParams.xmodul = formDataExtra.xmodul;
4702 return fetchJson(apiUrl(mediaUrl, mediaParams), { timeout: 30000 });
4703 })
4704 .then(data => {
4705 if (modal.__dbxCmsMediaRequest !== requestId) return;
4706 if (!data || !data.ok) throw new Error("bad response");
4707 const rows = Array.isArray(data.rows) ? data.rows : [];
4708 modal.__dbxCmsMediaSynced = true;
4709 modal.__dbxCmsRows = rows;
4710 renderMediaFolderTree(modal, modal.__dbxCmsFolders || []);
4711 const render = () => {
4712 const term = String(search?.value || "").toLowerCase();
4713 const slotFilter = String(slotSelect?.value || "all");
4714 const folderFilter = String(folderSelect?.value || "all");
4715 const selected = modal.__dbxCmsSelectedIds || new Set();
4716 const multi = isMediaBrowserMulti(modal);
4717 const needsConfirm = mediaBrowserUsesConfirmBar(modal);
4718 const filtered = rows.filter(row => {
4719 const hay = String((row.title || "") + " " + (row.file_name || "") + " " + (row.alt || "")).toLowerCase();
4720 const matchTerm = !term || hay.includes(term);
4721 const matchSlot = slotFilter === "all" || String(row.slot || "").trim() === slotFilter;
4722 const matchFolder = folderFilter === "all" || String(row.media_folder || "") === folderFilter;
4723 return matchTerm && matchSlot && matchFolder;
4724 });
4725 if (!filtered.length) {
4726 list.innerHTML = '<div class="dbx-cms-empty">Keine passenden Medien gefunden.</div>';
4727 updateMediaBrowserBatchUi(modal);
4728 return;
4729 }
4730 renderMediaRowsChunked(
4731 list,
4732 filtered,
4733 row => mediaBrowserItemHtml(row, selected, needsConfirm),
4734 () => {
4735 updateMediaBrowserConfirm(modal);
4736 updateMediaBrowserBatchUi(modal);
4737 renderMediaBatchList(modal);
4738 }
4739 );
4740 };
4741 render();
4742 if (search && !search.__dbxCmsMediaBrowserBound) {
4743 search.__dbxCmsMediaBrowserBound = true;
4744 search.addEventListener("input", render);
4745 }
4746 if (slotSelect && !slotSelect.__dbxCmsMediaBrowserBound) {
4747 slotSelect.__dbxCmsMediaBrowserBound = true;
4748 slotSelect.addEventListener("change", render);
4749 }
4750 if (folderSelect && !folderSelect.__dbxCmsMediaBrowserBound) {
4751 folderSelect.__dbxCmsMediaBrowserBound = true;
4752 folderSelect.addEventListener("change", () => {
4753 const uploadFolders = modal.__dbxCmsUploadFolders || uploadFolderOptions(modal.__dbxCmsFolders || []);
4754 syncUploadFolderSelect(modal, uploadFolders, folderSelect.value);
4755 openMediaBrowser(modal.__dbxCmsRoot || root, browserCfg(modal), {
4756 mode,
4757 slot: assignSlot,
4758 mediaFolder: folderSelect.value,
4759 formDataExtra: modal.__dbxCmsFormDataExtra || null,
4760 afterAssign: modal.__dbxCmsAfterAssign
4761 });
4762 });
4763 }
4764 })
4765 .catch(err => {
4766 if (modal.__dbxCmsMediaRequest !== requestId) return;
4767 dbx.error("[cms] media browser failed", err);
4768 if (list) list.innerHTML = '<div class="dbx-cms-empty">Medien konnten nicht geladen werden.</div>';
4769 })
4770 .finally(() => {
4771 if (modal.__dbxCmsMediaRequest === requestId) {
4772 modal.classList.remove("is-loading");
4773 modal.removeAttribute("aria-busy");
4774 clearCmsLoading(root);
4775 window.setTimeout(() => clearCmsLoading(root), 50);
4776 }
4777 });
4778 }
4779
4780 function openModBrowserWindow(root, modal) {
4781 if (!modal) return false;
4782 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
4783 ensureOpenWin().then(ok => {
4784 if (ok) openModBrowserWindow(root, modal);
4785 else status(root, "openWin.js nicht geladen.", "error");
4786 });
4787 return false;
4788 }
4789 const currentWindow = closestElement(modal, ".dbx-window");
4790 const ownWindowId = modal.__dbxCmsWindowId || "";
4791 if (currentWindow && ownWindowId && currentWindow.id === ownWindowId) {
4792 modal.hidden = false;
4793 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
4794 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
4795 }
4796 return true;
4797 }
4798 if (currentWindow && (!ownWindowId || currentWindow.id !== ownWindowId)) {
4799 document.body.appendChild(modal);
4800 }
4801 const id = dbx.openWin.open({
4802 title: '<i class="bi bi-puzzle"></i> Modul Aufruf',
4803 content: modal,
4804 width: "760",
4805 height: "520",
4806 minWidth: "560",
4807 minHeight: "360",
4808 position: "center",
4809 scroll: 0,
4810 topZ: 1,
4811 priority: "top",
4812 resizable: 1,
4813 minimizable: 1,
4814 maximizable: 1,
4815 reloadable: 0,
4816 persist: 0,
4817 reuse: 0
4818 }, root);
4819 if (id) {
4820 modal.__dbxCmsWindowId = id;
4821 modal.hidden = false;
4822 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
4823 dbx.openWin.bringToFront(id);
4824 }
4825 }
4826 return !!id;
4827 }
4828
4829 function closeModBrowserWindow(modal) {
4830 if (!modal) return;
4831 const winId = modal.__dbxCmsWindowId || "";
4832 modal.__dbxCmsWindowId = null;
4833 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
4834 dbx.openWin.close(winId);
4835 return;
4836 }
4837 modal.hidden = true;
4838 }
4839
4840 function renderModBrowserImages(list, items) {
4841 if (!list) return;
4842 if (!items.length) {
4843 list.innerHTML = '<div class="dbx-cms-empty">Keine Modul-Bilder in files/mod gefunden.</div>';
4844 return;
4845 }
4846 list.innerHTML = items.map(item => `
4847 <div class="dbx-cms-mod-browser-item" data-cms-mod-browser-item
4848 data-url="${escapeHtml(item.url || "")}"
4849 data-label="${escapeHtml(item.label || "")}"
4850 data-modul="${escapeHtml(item.default_modul || "")}"
4851 data-params="${escapeHtml(item.default_params || "")}"
4852 data-alt="${escapeHtml(item.default_alt || item.label || "")}">
4853 <button type="button" class="dbx-cms-mod-browser-pick" data-cms-mod-browser-pick title="In Editor einfuegen">
4854 <img src="${escapeHtml(item.url || "")}" alt="${escapeHtml(item.label || "")}" loading="lazy">
4855 <span class="dbx-cms-mod-browser-label">${escapeHtml(item.label || "")}</span>
4856 <small class="dbx-cms-mod-browser-params">${escapeHtml(item.description || item.default_params || "")}</small>
4857 </button>
4858 </div>`).join("");
4859 }
4860
4861 function renderModBrowserModules(list, items) {
4862 if (!list) return;
4863 if (!items.length) {
4864 list.innerHTML = '<div class="dbx-cms-empty">Keine Module gefunden.</div>';
4865 return;
4866 }
4867 list.innerHTML = items.filter(item => Number(item.image_count || 0) > 0).map(item => {
4868 const count = Number(item.image_count || 0);
4869 const runs = Array.isArray(item.run1_actions) ? item.run1_actions.slice(0, 4).join(", ") : "";
4870 const hint = count > 0
4871 ? (count + " Bild" + (count === 1 ? "" : "er"))
4872 : "keine Bilder";
4873 return `
4874 <div class="dbx-cms-mod-browser-module" data-cms-mod-browser-module data-modul="${escapeHtml(item.id || "")}">
4875 <button type="button" class="dbx-cms-mod-browser-module-pick" data-cms-mod-browser-module-pick>
4876 <span class="dbx-cms-mod-browser-module-name"><i class="bi bi-puzzle"></i> ${escapeHtml(item.label || item.id || "")}</span>
4877 <span class="dbx-cms-mod-browser-module-meta">${escapeHtml(hint)}</span>
4878 ${runs ? `<small class="dbx-cms-mod-browser-module-runs">${escapeHtml(runs)}</small>` : ""}
4879 </button>
4880 </div>`;
4881 }).join("");
4882 if (!list.innerHTML.trim()) {
4883 list.innerHTML = '<div class="dbx-cms-empty">Keine Module mit Bildern in files/mod gefunden.</div>';
4884 }
4885 }
4886
4887 function setModBrowserStep(modal, step, modul) {
4888 if (!modal) return;
4889 modal.__dbxCmsModStep = step || "modules";
4890 modal.__dbxCmsModSelected = modul || "";
4891 modal.classList.toggle("is-image-step", step === "images");
4892 const title = qs(modal, "[data-cms-mod-browser-title]");
4893 const back = qs(modal, "[data-cms-mod-browser-back]");
4894 if (title) {
4895 title.textContent = step === "images"
4896 ? ("Modul: " + (modul || ""))
4897 : "";
4898 }
4899 if (back) back.hidden = step !== "images";
4900 }
4901
4902 function loadModBrowserModules(root, cfg, modal) {
4903 const modulesUrl = cfgUrl(cfg, "modmodules");
4904 const list = qs(modal, "[data-cms-mod-browser-list]");
4905 if (!modulesUrl) {
4906 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Liste URL fehlt.</div>';
4907 return Promise.resolve();
4908 }
4909 if (list) list.innerHTML = '<div class="dbx-cms-empty">Lade Module...</div>';
4910 setModBrowserStep(modal, "modules", "");
4911 return fetchJson(apiUrl(modulesUrl)).then(data => {
4912 const items = data && Array.isArray(data.items) ? data.items : [];
4913 modal.__dbxCmsModModules = items;
4914 renderModBrowserModules(list, items);
4915 }).catch(err => {
4916 dbx.error("[cms] mod modules failed", err);
4917 if (list) list.innerHTML = '<div class="dbx-cms-empty">Module konnten nicht geladen werden.</div>';
4918 });
4919 }
4920
4921 function loadModBrowserCatalog(root, cfg, modal, modul) {
4922 const catalogUrl = cfgUrl(cfg, "modcatalog");
4923 const list = qs(modal, "[data-cms-mod-browser-list]");
4924 if (!catalogUrl || !modul) {
4925 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Katalog URL fehlt.</div>';
4926 return Promise.resolve();
4927 }
4928 if (list) list.innerHTML = '<div class="dbx-cms-empty">Lade Modul-Bilder...</div>';
4929 setModBrowserStep(modal, "images", modul);
4930 return fetchJson(apiUrl(catalogUrl, { modul: modul })).then(data => {
4931 const items = data && Array.isArray(data.items) ? data.items : [];
4932 renderModBrowserImages(list, items);
4933 }).catch(err => {
4934 dbx.error("[cms] mod catalog failed", err);
4935 if (list) list.innerHTML = '<div class="dbx-cms-empty">Modul-Bilder konnten nicht geladen werden.</div>';
4936 });
4937 }
4938
4939 function bindModBrowserEvents(root, cfg, modal) {
4940 if (!modal || modal.__dbxCmsModEventsBound) return;
4941 modal.__dbxCmsModEventsBound = true;
4942
4943 modal.addEventListener("click", e => {
4944 e.stopPropagation();
4945
4946 const backBtn = closestElement(e.target, "[data-cms-mod-browser-back]");
4947 if (backBtn && modal.contains(backBtn)) {
4948 e.preventDefault();
4949 loadModBrowserModules(root, cfg, modal);
4950 return;
4951 }
4952
4953 const modulePick = closestElement(e.target, "[data-cms-mod-browser-module-pick]");
4954 if (modulePick && modal.contains(modulePick)) {
4955 e.preventDefault();
4956 const row = closestElement(modulePick, "[data-cms-mod-browser-module]");
4957 const modul = row ? (row.getAttribute("data-modul") || "") : "";
4958 if (!modul) return;
4959 loadModBrowserCatalog(root, cfg, modal, modul);
4960 return;
4961 }
4962
4963 const pickBtn = closestElement(e.target, "[data-cms-mod-browser-pick]");
4964 if (pickBtn && modal.contains(pickBtn)) {
4965 e.preventDefault();
4966 const item = closestElement(pickBtn, "[data-cms-mod-browser-item]");
4967 if (!item) return;
4968 const row = {
4969 url: item.getAttribute("data-url") || "",
4970 label: item.getAttribute("data-label") || "",
4971 default_modul: item.getAttribute("data-modul") || "",
4972 default_params: item.getAttribute("data-params") || "",
4973 default_alt: item.getAttribute("data-alt") || ""
4974 };
4975 insertModPlaceholder(root, row, cfg);
4976 closeModBrowserWindow(modal);
4977 }
4978 });
4979 }
4980
4981 function openModBrowser(root, cfg) {
4982 cfg = cfg || cmsConfig(root) || {};
4983 saveEditorSelection(root);
4984 if (!cfgUrl(cfg, "modmodules") || !cfgUrl(cfg, "modcatalog")) {
4985 status(root, "Modul-Browser URLs fehlen.", "error");
4986 return;
4987 }
4988 let modal = state(root).modBrowser;
4989 if (!modal || !document.documentElement.contains(modal)) {
4990 modal = qs(root, "[data-cms-mod-browser]");
4991 }
4992 if (!modal) {
4993 modal = document.createElement("div");
4994 modal.className = "dbx-cms-mod-browser";
4995 modal.setAttribute("data-cms-mod-browser", "1");
4996 modal.innerHTML = `
4997 <div class="dbx-cms-mod-browser-head">
4998 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-mod-browser-back hidden title="Zurueck zur Modulliste">
4999 <i class="bi bi-arrow-left"></i>
5000 </button>
5001 <span data-cms-mod-browser-title></span>
5002 </div>
5003 <div class="dbx-cms-mod-browser-list" data-cms-mod-browser-list>
5004 <div class="dbx-cms-empty">Lade Module...</div>
5005 </div>`;
5006 root.appendChild(modal);
5007 state(root).modBrowser = modal;
5008 }
5009 state(root).modBrowser = modal;
5010 modal.__dbxCmsRoot = root;
5011 bindModBrowserEvents(root, cfg, modal);
5012 modal.hidden = false;
5013 openModBrowserWindow(root, modal);
5014 loadModBrowserModules(root, cfg, modal);
5015 }
5016
5017 function modOptionsNeedsRebuild(modal) {
5018 return !!qs(modal, "[data-cms-mod-options-dbx]") || !!qs(modal, "select[data-cms-mod-options-modul]");
5019 }
5020
5021 function ensureModPlaceholderOptionsDialog(root) {
5022 const s = state(root);
5023 let modal = s.modOptionsModal || qs(root, "[data-cms-mod-options]");
5024 if (modal && modOptionsNeedsRebuild(modal)) {
5025 modal.remove();
5026 modal = null;
5027 s.modOptionsModal = null;
5028 }
5029 if (modal) {
5030 modal.__dbxCmsRoot = root;
5031 s.modOptionsModal = modal;
5032 return modal;
5033 }
5034 modal = document.createElement("div");
5035 modal.className = "dbx-cms-mod-options";
5036 modal.setAttribute("data-cms-mod-options", "1");
5037 modal.__dbxCmsRoot = root;
5038 modal.hidden = true;
5039 modal.innerHTML = `
5040 <div class="dbx-cms-mod-options-body">
5041 <label>Alt-Text (img alt)
5042 <input type="text" class="form-control form-control-sm" data-cms-mod-options-alt placeholder="Alternativtext des Pseudo-Bildes">
5043 </label>
5044 <div class="dbx-cms-mod-options-module">
5045 <span class="dbx-cms-mod-options-module-label">Modul</span>
5046 <strong class="dbx-cms-mod-options-module-value" data-cms-mod-options-modul></strong>
5047 </div>
5048 <label>Parameter
5049 <input type="text" class="form-control form-control-sm" data-cms-mod-options-params placeholder="z.B. dbx_run1=show&root=0">
5050 <small class="text-muted">Query-String fuer [modul=...]parameter[/modul]</small>
5051 </label>
5052 </div>
5053 <div class="dbx-cms-mod-options-actions">
5054 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-mod-options-close><i class="bi bi-x-lg"></i><span>Abbrechen</span></button>
5055 <button type="button" class="btn btn-primary btn-sm" data-cms-mod-options-apply><i class="bi bi-check2"></i><span>Uebernehmen</span></button>
5056 </div>`;
5057 modal.addEventListener("click", e => {
5058 if (closestElement(e.target, "[data-cms-mod-options-close]")) {
5059 e.preventDefault();
5060 closeModPlaceholderOptionsWindow(modal);
5061 return;
5062 }
5063 if (closestElement(e.target, "[data-cms-mod-options-apply]")) {
5064 e.preventDefault();
5065 applyModPlaceholderOptions(root, modal);
5066 }
5067 });
5068 root.appendChild(modal);
5069 s.modOptionsModal = modal;
5070 return modal;
5071 }
5072
5073 function closeModPlaceholderOptionsWindow(modal) {
5074 if (!modal) return;
5075 modal.__dbxCmsModPlaceholder = null;
5076 modal.hidden = true;
5077 const winId = modal.__dbxCmsWindowId || "";
5078 if (winId && dbx.openWin && typeof dbx.openWin.close === "function") {
5079 dbx.openWin.close(winId);
5080 modal.__dbxCmsWindowId = null;
5081 }
5082 }
5083
5084 function openModPlaceholderOptions(root, wrapper, cfg) {
5085 if (!wrapper) return false;
5086 cfg = cfg || cmsConfig(root) || {};
5087 const modal = ensureModPlaceholderOptionsDialog(root);
5088 modal.__dbxCmsModPlaceholder = wrapper;
5089 modal.__dbxCmsCfg = cfg;
5090 const values = modPlaceholderValues(wrapper);
5091 const params = qs(modal, "[data-cms-mod-options-params]");
5092 const alt = qs(modal, "[data-cms-mod-options-alt]");
5093 const modulEl = qs(modal, "[data-cms-mod-options-modul]");
5094 if (params) params.value = values.params || "";
5095 if (alt) alt.value = values.alt || "";
5096 if (modulEl) modulEl.textContent = values.modul || "–";
5097 modal.hidden = false;
5098 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
5099 ensureOpenWin().then(ok => {
5100 if (ok) openModPlaceholderOptionsWindow(root, modal);
5101 });
5102 return true;
5103 }
5104 openModPlaceholderOptionsWindow(root, modal);
5105 return true;
5106 }
5107
5108 function openModPlaceholderOptionsWindow(root, modal) {
5109 if (!modal) return false;
5110 if (!dbx.openWin || typeof dbx.openWin.open !== "function") {
5111 ensureOpenWin().then(ok => {
5112 if (ok) openModPlaceholderOptionsWindow(root, modal);
5113 else status(root, "openWin.js nicht geladen.", "error");
5114 });
5115 return false;
5116 }
5117 const currentWindow = closestElement(modal, ".dbx-window");
5118 const ownWindowId = modal.__dbxCmsWindowId || "";
5119 if (currentWindow && currentWindow.isConnected && ownWindowId && currentWindow.id === ownWindowId) {
5120 modal.hidden = false;
5121 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
5122 dbx.openWin.bringToFront(modal.__dbxCmsWindowId);
5123 }
5124 return true;
5125 }
5126 if (currentWindow && currentWindow.isConnected && (!ownWindowId || currentWindow.id !== ownWindowId)) {
5127 document.body.appendChild(modal);
5128 } else if (currentWindow && !currentWindow.isConnected) {
5129 modal.__dbxCmsWindowId = null;
5130 if (modal.parentNode) modal.parentNode.removeChild(modal);
5131 }
5132 const id = dbx.openWin.open({
5133 title: '<i class="bi bi-puzzle"></i> Modul-Platzhalter',
5134 content: modal,
5135 width: "520",
5136 height: "260",
5137 minWidth: "420",
5138 minHeight: "300",
5139 position: "center",
5140 scroll: 0,
5141 topZ: 1,
5142 priority: "top",
5143 resizable: 1,
5144 minimizable: 0,
5145 maximizable: 0,
5146 reloadable: 0,
5147 persist: 0,
5148 reuse: 0
5149 }, root);
5150 if (id) {
5151 modal.__dbxCmsWindowId = id;
5152 modal.hidden = false;
5153 if (dbx.openWin && typeof dbx.openWin.bringToFront === "function") {
5154 dbx.openWin.bringToFront(id);
5155 }
5156 }
5157 return !!id;
5158 }
5159
5160 function applyModPlaceholderOptions(root, modal) {
5161 const wrapper = modal && modal.__dbxCmsModPlaceholder;
5162 if (!wrapper) return false;
5163 const values = modPlaceholderValues(wrapper);
5164 const modul = values.modul || "";
5165 const params = qs(modal, "[data-cms-mod-options-params]")?.value || "";
5166 const alt = qs(modal, "[data-cms-mod-options-alt]")?.value || "";
5167 const img = qs(wrapper, "img") || qs(wrapper, ".dbx-cms-mod-image");
5168 if (!modul) {
5169 status(root, "Modul konnte am Platzhalter nicht erkannt werden.", "warning");
5170 return false;
5171 }
5172 wrapper.setAttribute("data-cms-mod-params", params);
5173 wrapper.setAttribute("title", alt);
5174 if (img) {
5175 img.setAttribute("alt", alt);
5176 img.setAttribute("title", alt);
5177 img.removeAttribute("data-dbx");
5178 img.setAttribute("data-cms-mod-params", params);
5179 }
5180 normalizeModPlaceholders(editorSurface(root));
5181 syncEditorAfterContextAction(root);
5182 markDirty(root);
5183 closeModPlaceholderOptionsWindow(modal);
5184 return true;
5185 }
5186
5187 function bindModPlaceholderEvents(root) {
5188 if (root.__dbxCmsModPlaceholderBound) return;
5189 root.__dbxCmsModPlaceholderBound = true;
5190 root.addEventListener("dblclick", e => {
5191 const video = inlineVideoEventTarget(root, e);
5192 if (video) {
5193 e.preventDefault();
5194 e.stopPropagation();
5195 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
5196 openInlineVideoOptions(root, video);
5197 return;
5198 }
5199 const mod = inlineModTarget(root, e.target);
5200 if (!mod) return;
5201 e.preventDefault();
5202 e.stopPropagation();
5203 openModPlaceholderOptions(root, mod, cmsConfig(root) || {});
5204 }, true);
5205 }
5206
5207 function visibleJoditDialogInputs(panel) {
5208 return qsa(panel, "input, textarea").filter(input => {
5209 const type = String(input.getAttribute("type") || "text").toLowerCase();
5210 if (["hidden", "file", "button", "submit", "reset", "checkbox", "radio"].includes(type)) return false;
5211 if (input.disabled || input.readOnly) return false;
5212 return !!input.getClientRects().length;
5213 });
5214 }
5215
5216 function joditDialogValueInputs(panel) {
5217 return qsa(panel, "input, textarea").filter(input => {
5218 const type = String(input.getAttribute("type") || "text").toLowerCase();
5219 if (["hidden", "file", "button", "submit", "reset", "checkbox", "radio"].includes(type)) return false;
5220 return !input.disabled && !input.readOnly;
5221 });
5222 }
5223
5224 function findJoditImageDialogPathInput(panel) {
5225 if (panel && panel.__dbxCmsJoditPathInput && panel.contains(panel.__dbxCmsJoditPathInput)) {
5226 return panel.__dbxCmsJoditPathInput;
5227 }
5228 const visibleInputs = visibleJoditDialogInputs(panel);
5229 const inputs = visibleInputs.length ? visibleInputs : joditDialogValueInputs(panel);
5230 return inputs.find(input => /src|url|path|image|bild/i.test(String(input.name || input.id || input.getAttribute("placeholder") || "")))
5231 || inputs.find(input => String(input.value || "").indexOf("index.php") >= 0 || /\.(jpg|jpeg|png|gif|webp|svg)(\?|#|$)/i.test(input.value || ""))
5232 || inputs[0]
5233 || null;
5234 }
5235
5236 function joditImageDialogPanel(target) {
5237 const panel = closestElement(target, ".jodit-dialog__panel, .jodit-dialog");
5238 if (!panel) return null;
5239 const text = String(panel.textContent || "");
5240 if (!/Bildeigenschaften|Image properties|Bild/i.test(text)) return null;
5241 return findJoditImageDialogPathInput(panel) ? panel : null;
5242 }
5243
5244 function hideJoditUploadControl(el) {
5245 if (!el) return;
5246 const node = closestElement(el, ".jodit-drag-and-drop__file-box, .jodit-form__group, .jodit-ui-block, .jodit-ui-group, .jodit-upload, .jodit-uploader, label, div") || el;
5247 node.classList.add("dbx-cms-jodit-upload-hidden");
5248 node.hidden = true;
5249 }
5250
5251 function compactJoditImageDialog(panel) {
5252 if (!panel) return;
5253 panel.classList.add("dbx-cms-jodit-image-dialog");
5254 qsa(panel, "input[type='file'], .jodit-drag-and-drop__file-box, .jodit_uploadfile_button, .jodit-uploader, .jodit-upload").forEach(hideJoditUploadControl);
5255 qsa(panel, "button, a, [role='button']").forEach(btn => {
5256 const label = String((btn.textContent || "") + " " + (btn.title || "") + " " + (btn.getAttribute("aria-label") || ""));
5257 if (/Hochladen|Upload/i.test(label)) {
5258 if (isJoditImageDialogMediaTrigger(panel, btn)) {
5259 btn.title = "Bild aus Medienbrowser auswaehlen";
5260 return;
5261 }
5262 btn.classList.add("dbx-cms-jodit-upload-hidden");
5263 btn.hidden = true;
5264 }
5265 });
5266 ensureJoditImageDialogMediaButton(panel);
5267 hideJoditImageDialogPathField(panel);
5268 }
5269
5270 function joditImageDialogPathContainer(pathInput) {
5271 if (!pathInput) return null;
5272 const dialog = closestElement(pathInput, ".jodit-dialog__panel, .jodit-dialog");
5273 let node = pathInput.parentElement;
5274 for (let i = 0; node && node !== dialog && i < 5; i++) {
5275 const inputs = joditDialogValueInputs(node);
5276 const text = String(node.textContent || "");
5277 if (inputs.length === 1 && inputs[0] === pathInput && /Pfad|URL|Src|Quelle|Source/i.test(text)) return node;
5278 node = node.parentElement;
5279 }
5280 return pathInput;
5281 }
5282
5283 function joditImagePreviewHost(panel) {
5284 const img = qsa(panel, "img").find(item => {
5285 if (item.closest && item.closest(".jodit-dialog__header")) return false;
5286 return !!item.getClientRects().length;
5287 });
5288 if (!img) return null;
5289 const dialog = closestElement(img, ".jodit-dialog__panel, .jodit-dialog");
5290 let host = img.parentElement;
5291 for (let i = 0; host && host !== dialog && i < 4; i++) {
5292 if (host.children.length <= 4) break;
5293 host = host.parentElement;
5294 }
5295 if (!host || host === dialog) host = img.parentElement;
5296 if (host) host.classList.add("dbx-cms-jodit-preview-picker");
5297 return host;
5298 }
5299
5300 function joditSingleInputBox(input) {
5301 if (!input) return null;
5302 const dialog = closestElement(input, ".jodit-dialog__panel, .jodit-dialog");
5303 let node = closestElement(input, ".jodit-ui-input, .jodit-form__group, .jodit-ui-block, div") || input.parentElement;
5304 for (let i = 0; node && node !== dialog && i < 5; i++) {
5305 if (joditDialogValueInputs(node).length === 1) return node;
5306 node = node.parentElement;
5307 }
5308 return input.parentElement || input;
5309 }
5310
5311 function arrangeJoditImageSizeControls(panel) {
5312 const pathInput = findJoditImageDialogPathInput(panel);
5313 const inputs = joditDialogValueInputs(panel).filter(input => input !== pathInput);
5314 const pathIndex = joditDialogValueInputs(panel).indexOf(pathInput);
5315 const beforePath = pathIndex > 0 ? joditDialogValueInputs(panel).slice(0, pathIndex) : inputs;
5316 const sizeInputs = beforePath
5317 .filter(input => input !== pathInput && !input.closest(".dbx-cms-jodit-openwin"))
5318 .filter(input => /^\s*\d+(\.\d+)?\s*$/.test(String(input.value || "")) || String(input.getAttribute("type") || "").toLowerCase() === "number")
5319 .slice(0, 2);
5320 if (sizeInputs.length < 2) return;
5321
5322 let row = qs(panel, ".dbx-cms-jodit-size-row");
5323 if (!row) {
5324 row = document.createElement("div");
5325 row.className = "dbx-cms-jodit-size-row";
5326 }
5327
5328 const firstBox = joditSingleInputBox(sizeInputs[0]);
5329 const secondBox = joditSingleInputBox(sizeInputs[1]);
5330 const common = firstBox && secondBox ? (firstBox.parentElement === secondBox.parentElement ? firstBox.parentElement : null) : null;
5331 const lockButton = common ? qsa(common, "button, .jodit-ui-button, [role='button']").find(btn => {
5332 const text = String((btn.textContent || "") + " " + (btn.title || "") + " " + (btn.getAttribute("aria-label") || "") + " " + (btn.className || ""));
5333 return /lock|proportion|ratio|verhaeltnis|verh\u00e4ltnis/i.test(text) || qsa(btn, "svg,.jodit-icon").length;
5334 }) : null;
5335
5336 const preview = joditImagePreviewHost(panel);
5337 if (preview && row.parentElement !== preview.parentElement) {
5338 preview.insertAdjacentElement("afterend", row);
5339 } else if (!row.parentElement && firstBox && firstBox.parentElement) {
5340 firstBox.insertAdjacentElement("beforebegin", row);
5341 }
5342
5343 if (firstBox) row.appendChild(firstBox);
5344 if (lockButton) row.appendChild(lockButton);
5345 if (secondBox) row.appendChild(secondBox);
5346 }
5347
5348 function ensureJoditImageDialogMediaButton(panel) {
5349 const pathInput = findJoditImageDialogPathInput(panel);
5350 if (!pathInput || qs(panel, "[data-cms-jodit-media-select]")) return;
5351 panel.__dbxCmsJoditPathInput = pathInput;
5352 const btn = document.createElement("button");
5353 btn.type = "button";
5354 btn.className = "btn btn-outline-primary btn-sm dbx-cms-hero-pick dbx-cms-jodit-media-select";
5355 btn.setAttribute("data-cms-jodit-media-select", "1");
5356 btn.title = "Bild aus Medienbrowser auswaehlen";
5357 btn.innerHTML = '<i class="bi bi-image"></i><i class="bi bi-camera-video"></i><i class="bi bi-upload"></i><span>Auswahl</span>';
5358 const preview = joditImagePreviewHost(panel);
5359 if (preview) {
5360 preview.insertAdjacentElement("beforebegin", btn);
5361 return;
5362 }
5363 const container = joditImageDialogPathContainer(pathInput);
5364 if (container && container.parentElement) {
5365 container.insertAdjacentElement("beforebegin", btn);
5366 return;
5367 }
5368 pathInput.insertAdjacentElement("beforebegin", btn);
5369 }
5370
5371 function hideJoditImageDialogPathField(panel) {
5372 const pathInput = findJoditImageDialogPathInput(panel);
5373 if (!pathInput) return;
5374 panel.__dbxCmsJoditPathInput = pathInput;
5375 const container = joditImageDialogPathContainer(pathInput);
5376 if (container && container !== pathInput && joditDialogValueInputs(container).length === 1) {
5377 container.classList.add("dbx-cms-jodit-path-hidden");
5378 container.hidden = true;
5379 return;
5380 }
5381 pathInput.classList.add("dbx-cms-jodit-path-hidden");
5382 pathInput.hidden = true;
5383 pathInput.setAttribute("aria-hidden", "true");
5384 }
5385
5386 function findJoditDialogLinkInput(panel) {
5387 const pathInput = findJoditImageDialogPathInput(panel);
5388 const inputs = joditDialogValueInputs(panel).filter(input => input !== pathInput);
5389 return inputs.find(input => /link|href|url/i.test(String(input.name || input.id || input.getAttribute("placeholder") || "")))
5390 || inputs[inputs.length - 1]
5391 || null;
5392 }
5393
5394 function ensureJoditImageOpenWinControls(panel) {
5395 if (qs(panel, "[data-cms-jodit-openwin]")) return;
5396 const linkInput = findJoditDialogLinkInput(panel);
5397 if (!linkInput) return;
5398 const host = document.createElement("div");
5399 host.className = "dbx-cms-jodit-openwin";
5400 host.setAttribute("data-cms-jodit-openwin", "1");
5401 host.innerHTML = `
5402 <label class="dbx-cms-jodit-openwin-field">
5403 <span>Link oeffnen</span>
5404 <select class="form-select form-select-sm" data-cms-jodit-link-mode>
5405 <option value="">Normal</option>
5406 <option value="blank">Neuer Tab</option>
5407 <option value="openwin">openWin</option>
5408 </select>
5409 </label>
5410 <div class="dbx-cms-jodit-openwin-options" data-cms-jodit-openwin-options hidden>
5411 <label><span>Groesse</span><select class="form-select form-select-sm" data-cms-jodit-openwin-size>
5412 <option value="900x620">900 x 620</option>
5413 <option value="1024x760">1024 x 760</option>
5414 <option value="80%x80%">80% x 80%</option>
5415 <option value="1320x860">1320 x 860</option>
5416 </select></label>
5417 <label><span>Position</span><select class="form-select form-select-sm" data-cms-jodit-openwin-position>
5418 <option value="center">Zentriert</option>
5419 <option value="center-top">Zentriert oben</option>
5420 <option value="cascade">Versetzt</option>
5421 <option value="fullscreen">Vollbild</option>
5422 </select></label>
5423 </div>`;
5424
5425 const linkBox = joditSingleInputBox(linkInput);
5426 if (linkBox && linkBox.parentElement) linkBox.insertAdjacentElement("afterend", host);
5427 else linkInput.insertAdjacentElement("afterend", host);
5428
5429 const newTab = qsa(panel, "input[type='checkbox']").find(input => /tab|target|blank|neu/i.test(String(input.name || input.id || input.closest("label")?.textContent || "")));
5430 const mode = qs(host, "[data-cms-jodit-link-mode]");
5431 const options = qs(host, "[data-cms-jodit-openwin-options]");
5432 if (newTab && newTab.checked) mode.value = "blank";
5433 mode.addEventListener("change", () => {
5434 if (options) options.hidden = mode.value !== "openwin";
5435 if (newTab) newTab.checked = mode.value === "blank";
5436 });
5437 }
5438
5439 function readJoditOpenWinSettings(panel) {
5440 const mode = qs(panel, "[data-cms-jodit-link-mode]");
5441 const size = qs(panel, "[data-cms-jodit-openwin-size]");
5442 const position = qs(panel, "[data-cms-jodit-openwin-position]");
5443 const value = String(mode?.value || "");
5444 const parts = String(size?.value || "900x620").split("x");
5445 return {
5446 mode: value,
5447 width: parts[0] || "900",
5448 height: parts[1] || "620",
5449 position: String(position?.value || "center")
5450 };
5451 }
5452
5453 function findImageByDialogPath(root, path) {
5454 const surface = editorSurface(root);
5455 if (!surface) return null;
5456 const clean = String(path || "").split("#")[0];
5457 return qsa(surface, "img").find(img => {
5458 const src = String(img.getAttribute("src") || img.src || "").split("#")[0];
5459 return src === clean || src.endsWith(clean) || clean.endsWith(src);
5460 }) || null;
5461 }
5462
5463 function applyJoditOpenWinSettings(root, panel, settings) {
5464 if (!settings || !settings.mode) return;
5465 const linkInput = findJoditDialogLinkInput(panel);
5466 const href = String(linkInput?.value || "").trim();
5467 if (!href) return;
5468 const pathInput = findJoditImageDialogPathInput(panel);
5469 const img = findImageByDialogPath(root, pathInput ? pathInput.value : "");
5470 const link = img ? closestElement(img, "a") : null;
5471 if (!link) return;
5472 if (settings.mode === "blank") {
5473 link.setAttribute("target", "_blank");
5474 link.setAttribute("rel", "noopener");
5475 link.classList.remove("dbx-win");
5476 link.removeAttribute("data-dbx");
5477 link.removeAttribute("data-url");
5478 return;
5479 }
5480 if (settings.mode === "openwin") {
5481 const title = img.getAttribute("alt") || img.getAttribute("title") || "Information";
5482 const dbxData = "lib=openWin|url=" + href + "|title=" + title + "|width=" + settings.width + "|height=" + settings.height + "|position=" + settings.position + "|reload=1|minimizable=1|maximizable=1";
5483 link.classList.add("dbx-win");
5484 link.removeAttribute("target");
5485 link.setAttribute("href", href);
5486 link.setAttribute("data-url", href);
5487 link.setAttribute("data-title", title);
5488 link.setAttribute("data-width", settings.width);
5489 link.setAttribute("data-height", settings.height);
5490 link.setAttribute("data-position", settings.position);
5491 link.setAttribute("data-dbx", dbxData);
5492 syncEditorDom(root);
5493 markDirty(root);
5494 }
5495 }
5496
5497 function isJoditImageDialogMediaTrigger(panel, target) {
5498 const trigger = closestElement(target, "button, a, [role='button'], .jodit-ui-button, .jodit-button, .jodit-input__icon, .jodit-icon");
5499 if (!panel || !trigger || !panel.contains(trigger)) return false;
5500 if (trigger.hasAttribute("data-cms-jodit-media-select")) return true;
5501 if (closestElement(trigger, ".jodit-dialog__header, .jodit-dialog__footer")) return false;
5502 const label = String((trigger.textContent || "") + " " + (trigger.title || "") + " " + (trigger.getAttribute("aria-label") || ""));
5503 if (/Abbrechen|Anwenden|Loeschen|L\u00f6schen|Schliessen|Schlie\u00dfen|Fortgeschritten/i.test(label)) return false;
5504
5505 const pathInput = findJoditImageDialogPathInput(panel);
5506 if (!pathInput) return false;
5507 const inputRect = pathInput.getBoundingClientRect();
5508 const triggerRect = trigger.getBoundingClientRect();
5509 if (!inputRect.width || !triggerRect.width) return false;
5510 return triggerRect.left >= inputRect.right - 12
5511 && triggerRect.top <= inputRect.bottom + 8
5512 && triggerRect.bottom >= inputRect.top - 8;
5513 }
5514
5515 function setJoditDialogFieldValue(field, value, overwrite) {
5516 if (!field || value == null) return;
5517 if (!overwrite && String(field.value || "").trim()) return;
5518 field.value = String(value || "");
5519 field.dispatchEvent(new Event("input", { bubbles: true }));
5520 field.dispatchEvent(new Event("change", { bubbles: true }));
5521 }
5522
5523 function applyMediaToJoditImageDialog(root, panel, row) {
5524 if (!row || !row.url) return false;
5525 if (!isImageRow(row)) {
5526 status(root, "Bitte ein Bild auswaehlen.", "error");
5527 return false;
5528 }
5529 const inputs = visibleJoditDialogInputs(panel);
5530 const pathInput = findJoditImageDialogPathInput(panel);
5531 setJoditDialogFieldValue(pathInput, row.url, true);
5532
5533 const afterPath = inputs.slice(Math.max(0, inputs.indexOf(pathInput) + 1))
5534 .filter(input => String(input.getAttribute("type") || "text").toLowerCase() !== "number");
5535 const titleInput = afterPath.find(input => /title|titel/i.test(String(input.name || input.id || input.getAttribute("placeholder") || ""))) || afterPath[0] || null;
5536 const altInput = afterPath.find(input => /alt|alternative/i.test(String(input.name || input.id || input.getAttribute("placeholder") || ""))) || afterPath.find(input => input !== titleInput) || null;
5537 const label = row.title || row.alt || row.file_name || "";
5538 setJoditDialogFieldValue(titleInput, row.title || label, false);
5539 setJoditDialogFieldValue(altInput, row.alt || label, false);
5540 qsa(panel, "img").forEach(img => {
5541 if (img.closest && img.closest(".jodit-dialog__header")) return;
5542 img.src = row.thumb_url || row.url;
5543 img.alt = row.alt || label;
5544 });
5545 status(root, "Bild aus Medienbrowser uebernommen.", "success");
5546 return true;
5547 }
5548
5549 function openJoditImageDialogMediaBrowser(root, cfg, panel) {
5550 compactJoditImageDialog(panel);
5551 openMediaBrowser(root, cfg || {}, {
5552 mode: "pick",
5553 slot: "inline",
5554 singlePick: true,
5555 afterAssign(row) {
5556 return applyMediaToJoditImageDialog(root, panel, row);
5557 }
5558 });
5559 }
5560
5561 function openEditorMediaBrowser(root, cfg) {
5562 openMediaBrowser(root, cfg || {}, {
5563 mode: "pick",
5564 slot: "inline",
5565 singlePick: true,
5566 afterAssign(row) {
5567 return assignMedia(root, cfg || {}, row, "inline").then(assignedRow => {
5568 if (!assignedRow) return false;
5569 insertMediaRow(root, assignedRow);
5570 setLocalMediaSlot(root, assignedRow.id, "inline");
5571 return true;
5572 });
5573 }
5574 });
5575 }
5576
5577 function bindJoditImageDialogMediaPicker(root, cfg) {
5578 if (root.__dbxCmsJoditImagePickerBound) return;
5579 root.__dbxCmsJoditImagePickerBound = true;
5580
5581 document.addEventListener("click", e => {
5582 const panel = joditImageDialogPanel(e.target);
5583 if (!panel) return;
5584 compactJoditImageDialog(panel);
5585 if (!isJoditImageDialogMediaTrigger(panel, e.target)) return;
5586 e.preventDefault();
5587 e.stopPropagation();
5588 if (e.stopImmediatePropagation) e.stopImmediatePropagation();
5589 openJoditImageDialogMediaBrowser(root, cfg || {}, panel);
5590 }, true);
5591
5592 document.addEventListener("focusin", e => {
5593 const panel = joditImageDialogPanel(e.target);
5594 if (panel) compactJoditImageDialog(panel);
5595 }, true);
5596
5597 // Kein MutationObserver hier: Jodit baut den Dialog dynamisch um und Firefox
5598 // kann sonst bei DOM-Korrekturen in eine dauernde Mutation-Schleife laufen.
5599 }
5600
5601 function initEditor(root, cfg) {
5602 const editor = qs(root, "[data-cms-editor]");
5603 if (!editor || editor.__dbxJoditReady) return;
5604
5605 if (!window.Jodit || !window.Jodit.make) {
5606 dbx.warn("[cms] Jodit not loaded, fallback contenteditable active");
5607 return;
5608 }
5609
5610 editor.__dbxJoditReady = true;
5611 bindModPlaceholderEvents(root);
5612 const cmsMarkers = [
5613 { label: "Hero-Text", marker: "dbx:hero" },
5614 { label: "Header", marker: "dbx:header" },
5615 { label: "Footer", marker: "dbx:footer" },
5616 { label: "col-2 Trenner", marker: "dbx:col2" },
5617 { label: "col-3a Trenner", marker: "dbx:col3a" },
5618 { label: "col-3b Trenner", marker: "dbx:col3b" },
5619 { label: "Druck Seitenumbruch", marker: "dbx:pagebreak" }
5620 ];
5621
5622 window.Jodit.defaultOptions.controls.dbxMarkerMenu = {
5623 tooltip: "Trenner Marker fuer Bereiche des Content",
5624 icon: cmsMarkerMenuIcon(),
5625 text: "",
5626 popup: function (jodit, current, control, close) {
5627 const box = document.createElement("div");
5628 box.className = "dbx-cms-marker-menu";
5629 cmsMarkers.forEach(item => {
5630 const btn = document.createElement("button");
5631 btn.type = "button";
5632 btn.className = "dbx-cms-marker-menu-item";
5633 btn.textContent = item.label;
5634 btn.addEventListener("mousedown", e => {
5635 e.preventDefault();
5636 });
5637 btn.addEventListener("click", e => {
5638 e.preventDefault();
5639 insertMarker(root, item.marker, item.label);
5640 if (typeof close === "function") close();
5641 });
5642 box.appendChild(btn);
5643 });
5644 return box;
5645 },
5646 exec: function (jodit) {
5647 if (jodit && jodit.e && jodit.e.fire) jodit.e.fire("togglePopup", "dbxMarkerMenu");
5648 }
5649 };
5650 window.Jodit.defaultOptions.controls.dbxImageBrowser = {
5651 tooltip: "Medium aus Medienbrowser einfuegen",
5652 icon: "image",
5653 exec: function () {
5654 openEditorMediaBrowser(root, cfg || {});
5655 }
5656 };
5657 window.Jodit.defaultOptions.controls.dbxModBrowser = {
5658 tooltip: "Modul Aufruf einfuegen",
5659 icon: cmsModBrowserIcon(),
5660 text: "",
5661 exec: function () {
5662 saveEditorSelection(root);
5663 openModBrowser(root, cfg || {});
5664 }
5665 };
5666 window.Jodit.defaultOptions.controls.dbxBootstrapComponents = {
5667 tooltip: "Bootstrap Content-Komponente einfuegen",
5668 icon: cmsBootstrapComponentIcon(),
5669 text: "",
5670 popup: function (jodit, current, control, close) {
5671 const box = document.createElement("div");
5672 box.className = "dbx-cms-marker-menu";
5673 bootstrapComponentItems().forEach(item => {
5674 const btn = document.createElement("button");
5675 btn.type = "button";
5676 btn.className = "dbx-cms-marker-menu-item";
5677 btn.textContent = item.label;
5678 btn.addEventListener("mousedown", e => e.preventDefault());
5679 btn.addEventListener("click", e => {
5680 e.preventDefault();
5681 insertBootstrapComponent(root, item.html);
5682 if (typeof close === "function") close();
5683 });
5684 box.appendChild(btn);
5685 });
5686 return box;
5687 },
5688 exec: function (jodit) {
5689 if (jodit && jodit.e && jodit.e.fire) jodit.e.fire("togglePopup", "dbxBootstrapComponents");
5690 }
5691 };
5692 window.Jodit.defaultOptions.controls.dbxTextStyle = {
5693 tooltip: "Textformatierung",
5694 icon: cmsTextStyleIcon(),
5695 text: "",
5696 popup: function (jodit, current, control, close) {
5697 const box = document.createElement("div");
5698 box.className = "dbx-cms-marker-menu dbx-cms-text-style-menu";
5699 [
5700 { label: "Fett", mark: "B", command: "bold", className: "is-bold" },
5701 { label: "Kursiv", mark: "I", command: "italic", className: "is-italic" },
5702 { label: "Unterstrichen", mark: "U", command: "underline", className: "is-underlined" },
5703 { label: "Durchgestrichen", mark: "S", command: "strikethrough", className: "is-struck" }
5704 ].forEach(item => {
5705 const btn = document.createElement("button");
5706 btn.type = "button";
5707 btn.className = "dbx-cms-marker-menu-item dbx-cms-text-style-menu-item";
5708 btn.innerHTML = '<span class="dbx-cms-text-style-menu-mark ' + item.className + '">' + item.mark + '</span><span>' + item.label + '</span>';
5709 btn.addEventListener("mousedown", e => e.preventDefault());
5710 btn.addEventListener("click", e => {
5711 e.preventDefault();
5712 execEditorCommand(root, item.command);
5713 if (typeof close === "function") close();
5714 });
5715 box.appendChild(btn);
5716 });
5717 return box;
5718 },
5719 exec: function (jodit) {
5720 if (jodit && jodit.e && jodit.e.fire) jodit.e.fire("togglePopup", "dbxTextStyle");
5721 }
5722 };
5723 window.Jodit.defaultOptions.controls.dbxHr = {
5724 tooltip: "Horizontale Linie an Cursorposition einfuegen",
5725 icon: cmsHrIcon(),
5726 text: "",
5727 exec: function () {
5728 insertEditorPlainHr(root);
5729 }
5730 };
5731 window.Jodit.defaultOptions.controls.dbxSave = {
5732 tooltip: "Alles speichern",
5733 icon: cmsSaveIcon(),
5734 text: "",
5735 exec: function () {
5736 saveCurrentCms(root, cfg || {});
5737 }
5738 };
5739
5740 bindJoditImageDialogMediaPicker(root, cfg || {});
5741
5742 root.__dbxCmsJodit = window.Jodit.make(editor, {
5743 language: "de",
5744 height: 430,
5745 minHeight: 320,
5746 toolbarSticky: false,
5747 autofocus: false,
5748 enter: "p",
5749 defaultMode: "1",
5750 askBeforePasteHTML: false,
5751 askBeforePasteFromWord: false,
5752 cleanHTML: {
5753 removeEmptyElements: false
5754 },
5755 toolbarAdaptive: false,
5756 showCharsCounter: false,
5757 showWordsCounter: false,
5758 showXPathInStatusbar: false,
5759 imageDefaultWidth: 720,
5760 uploader: false,
5761 buttons: [
5762 "source", "dbxSave", "|",
5763 "dbxTextStyle", "|",
5764 "ul", "ol", "outdent", "indent", "|",
5765 "paragraph", "brush", "|",
5766 "link", "dbxImageBrowser", "dbxModBrowser", "dbxBootstrapComponents", "video", "dbxHr", "|",
5767 "dbxMarkerMenu", "|",
5768 "undo", "redo", "|",
5769 "fullsize"
5770 ],
5771 events: {
5772 change: function () {
5773 normalizeEditorMarkers(root);
5774 const surface = editorSurface(root);
5775 normalizeBootstrapComponents(surface);
5776 normalizeInlineMediaLayout(surface);
5777 normalizeModPlaceholders(surface);
5778 const html = surface ? (surface.innerHTML || "") : (this.value || "");
5779 if (html !== (this.value || "")) this.value = html;
5780 setField(root, "content", html);
5781 markDirty(root);
5782 scheduleEditorHeight(root);
5783 },
5784 focus: function () {
5785 window.requestAnimationFrame(() => saveEditorSelection(root));
5786 },
5787 afterSelectionChange: function () {
5788 window.requestAnimationFrame(() => saveEditorSelection(root));
5789 }
5790 }
5791 });
5792 bindEditorSlashCommands(root, cfg || {});
5793 bindBootstrapCardEditingGuards(root);
5794 bindEditorHeight(root);
5795 window.setTimeout(() => {
5796 normalizeEditorMarkers(root);
5797 const surface = editorSurface(root);
5798 normalizeBootstrapComponents(surface);
5799 bindBootstrapCardEditingGuards(root);
5800 bindEditorSlashCommands(root, cfg || {});
5801 normalizeModPlaceholders(surface);
5802 bindEditorMarkerEventsRetry(root);
5803 bindEditorSaveButton(root, cfg || {}, 0);
5804 scheduleEditorHeight(root);
5805 }, 0);
5806 }
5807
5808 function bindEditorSaveButton(root, cfg, attempt) {
5809 attempt = Number(attempt || 0);
5810 const instance = getEditorInstance(root);
5811 const container = instance && instance.container ? instance.container : root;
5812 let buttons = qsa(container, ".jodit-toolbar-button");
5813 if (!buttons.length) buttons = qsa(root, ".jodit-toolbar-button");
5814 const saveButton = buttons.find(btn => /dbxsave/i.test(btn.className || "")) || buttons[1] || null;
5815 const button = saveButton ? qs(saveButton, "button") : null;
5816 if (!button) {
5817 if (attempt < 20) window.setTimeout(() => bindEditorSaveButton(root, cfg || {}, attempt + 1), 100);
5818 return;
5819 }
5820 if (button.__dbxCmsSaveBound) return;
5821 button.__dbxCmsSaveBound = true;
5822 button.setAttribute("title", "Alles speichern");
5823 button.setAttribute("aria-label", "Alles speichern");
5824 button.addEventListener("click", event => {
5825 event.preventDefault();
5826 event.stopPropagation();
5827 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
5828 saveCurrentCms(root, cfg || {});
5829 }, true);
5830 }
5831
5832 function setEditorHtml(root, html) {
5833 html = repairInlineVideoHtml(root, html);
5834 const instance = getEditorInstance(root);
5835 const editor = qs(root, "[data-cms-editor]");
5836 if (instance) instance.value = html || "";
5837 else if (editor) editor.innerHTML = html || "";
5838 setField(root, "content", html || "");
5839 bindEditorHeight(root);
5840 scheduleEditorHeight(root);
5841 window.setTimeout(() => {
5842 normalizeEditorMarkers(root);
5843 const surface = editorSurface(root);
5844 normalizeBootstrapComponents(surface);
5845 repairInlineVideoPlayers(root, surface);
5846 normalizeInlineMediaLayout(surface);
5847 normalizeModPlaceholders(surface);
5848 if (surface) cleanEditorRuntimeNodes(surface);
5849 const repairedHtml = surface ? surface.innerHTML : getEditorHtml(root);
5850 const current = getEditorInstance(root);
5851 if (current) current.value = repairedHtml || "";
5852 if (/(<video\b|<iframe\b|dbx-cms-inline-video-block)/i.test(html) && !/(<video\b|<iframe\b|dbx-cms-inline-video-block)/i.test(repairedHtml || "")) {
5853 setField(root, "content", html || "");
5854 } else {
5855 setField(root, "content", repairedHtml || "");
5856 }
5857 bindEditorMarkerEventsRetry(root);
5858 scheduleEditorHeight(root);
5859 }, 0);
5860 }
5861
5862 function getEditorHtml(root) {
5863 const instance = getEditorInstance(root);
5864 const surface = editorSurface(root);
5865 if (surface) {
5866 normalizeBootstrapComponents(surface);
5867 normalizeInlineMediaLayout(surface);
5868 cleanEditorRuntimeNodes(surface);
5869 const html = surface.innerHTML || "";
5870 if (instance) instance.value = html;
5871 setField(root, "content", html);
5872 return html;
5873 }
5874 if (instance) return instance.value || "";
5875 const editor = qs(root, "[data-cms-editor]");
5876 return editor ? editor.innerHTML : getField(root, "content");
5877 }
5878
5879 function editorSurface(root) {
5880 const instance = getEditorInstance(root);
5881 if (instance && instance.editor) return instance.editor;
5882 return qs(root, ".jodit-wysiwyg") || qs(root, "[data-cms-editor]");
5883 }
5884
5885 function nodeElement(node) {
5886 if (!node) return null;
5887 return node.nodeType === 1 ? node : node.parentElement;
5888 }
5889
5890 function rangeInsideSurface(surface, range) {
5891 if (!surface || !range) return false;
5892 const start = nodeElement(range.startContainer);
5893 const end = nodeElement(range.endContainer);
5894 return !!(start && end && surface.contains(start) && surface.contains(end));
5895 }
5896
5897 function nodeHasEditorContent(node) {
5898 if (!node) return false;
5899 if (node.nodeType === 3) {
5900 return String(node.nodeValue || "").replace(/\uFEFF/g, "").replace(/\u00a0/g, " ").trim() !== "";
5901 }
5902 if (node.nodeType !== 1 && node.nodeType !== 11) return false;
5903 if (node.nodeType === 1) {
5904 const tag = node.tagName || "";
5905 if (tag === "BR") return false;
5906 if (/^(IMG|VIDEO|AUDIO|IFRAME|OBJECT|EMBED|TABLE|HR)$/i.test(tag)) return true;
5907 }
5908 return Array.from(node.childNodes || []).some(nodeHasEditorContent);
5909 }
5910
5911 function setEditorCaretAfterNode(root, node) {
5912 const surface = editorSurface(root);
5913 if (!surface || !node) return false;
5914 const doc = surface.ownerDocument || document;
5915 const sel = doc.getSelection ? doc.getSelection() : null;
5916 if (!sel) return false;
5917 const range = doc.createRange();
5918 const next = node.nextSibling;
5919 if (next && next.nodeType === 1 && /^(P|DIV|H1|H2|H3|H4|H5|H6|BLOCKQUOTE|LI)$/i.test(next.tagName || "")) {
5920 range.setStart(next, 0);
5921 } else {
5922 range.setStartAfter(node);
5923 }
5924 range.collapse(true);
5925 sel.removeAllRanges();
5926 sel.addRange(range);
5927 state(root).editorRange = range.cloneRange();
5928 return true;
5929 }
5930
5931 function cleanEditorRuntimeNodes(container) {
5932 normalizeCommentMarkers(container);
5933 syncInlineVideoBlockSizes(container);
5934 qsa(container, "[data-jodit-temp], [data-jodit-selection_marker], span[id^='jodit-selection_marker_']").forEach(el => el.remove());
5935 qsa(container, "[data-cms-inline-video-options-open]").forEach(el => el.remove());
5936 qsa(container, ".dbx-cms-inline-media video, .dbx-cms-inline-media iframe, .dbx-cms-inline-media source").forEach(media => {
5937 const src = String(media.getAttribute("src") || "");
5938 const poster = String(media.getAttribute("poster") || "");
5939 if (/^data:/i.test(src) || /^blob:/i.test(src)) media.removeAttribute("src");
5940 if (/^data:/i.test(poster) || /^blob:/i.test(poster)) media.removeAttribute("poster");
5941 if (!media.getAttribute("data-cms-media-slot")) media.setAttribute("data-cms-media-slot", "inline");
5942 if (/^video$/i.test(media.tagName || "")) {
5943 media.setAttribute("controls", "controls");
5944 media.setAttribute("preload", "none");
5945 media.setAttribute("playsinline", "playsinline");
5946 }
5947 });
5948 qsa(container, ".dbx-cms-inline-media, figure.dbx-cms-inline-video-block").forEach(wrapper => {
5949 if (!wrapper.getAttribute("data-cms-media-slot")) wrapper.setAttribute("data-cms-media-slot", "inline");
5950 if (wrapper.classList.contains("dbx-cms-inline-video-block")) wrapper.removeAttribute("contenteditable");
5951 if (wrapper.getAttribute("data-cms-media-id") && !inlineMediaWrapperHasContent(wrapper)) wrapper.remove();
5952 const inlineImage = qs(wrapper, "img");
5953 if (inlineImage && !wrapper.classList.contains("dbx-cms-inline-video-block")) {
5954 const srcMatch = String(inlineImage.getAttribute("src") || "").match(/dbx_mid=([0-9]+)/i);
5955 if (srcMatch && Number(srcMatch[1] || 0) > 0) {
5956 inlineImage.setAttribute("data-cms-media-id", String(Number(srcMatch[1])));
5957 }
5958 wrapper.removeAttribute("data-cms-media-id");
5959 }
5960 });
5961 normalizeInlineMissingMedia(container);
5962 }
5963
5964 function inlineMediaWrapperHasContent(wrap) {
5965 if (!wrap) return false;
5966 return !!qs(wrap, "img, video, iframe, source, .dbx-cms-inline-video-thumb, .dbx-cms-inline-video-empty, .dbx-cms-inline-media-missing");
5967 }
5968
5969 function inlineMissingMediaWrap(doc, id, label) {
5970 const p = doc.createElement("p");
5971 p.className = "dbx-cms-inline-media dbx-cms-inline-media-missing-wrap";
5972 if (id > 0) p.setAttribute("data-cms-media-id", String(id));
5973 p.setAttribute("data-cms-media-slot", "inline");
5974 p.setAttribute("contenteditable", "false");
5975 p.setAttribute("tabindex", "0");
5976 p.setAttribute("title", "Fehlende Mediendatei auswaehlen, Entf zum Loeschen");
5977 const span = doc.createElement("span");
5978 span.className = "dbx-cms-inline-media-missing";
5979 span.setAttribute("aria-hidden", "true");
5980 span.textContent = label || ("Mediendatei #" + id + " nicht verfuegbar");
5981 p.appendChild(span);
5982 return p;
5983 }
5984
5985 function normalizeInlineMissingMedia(container) {
5986 if (!container) return;
5987 const doc = container.ownerDocument || document;
5988 qsa(container, ".dbx-cms-inline-media-missing-wrap").forEach(wrap => {
5989 wrap.setAttribute("contenteditable", "false");
5990 wrap.classList.add("dbx-cms-inline-media");
5991 wrap.classList.remove("dbx-cms-mod-placeholder");
5992 const id = Number(wrap.getAttribute("data-cms-media-id") || 0);
5993 if (id > 0 && !wrap.getAttribute("data-cms-media-slot")) wrap.setAttribute("data-cms-media-slot", "inline");
5994 });
5995 qsa(container, ".dbx-cms-inline-media-missing").forEach(span => {
5996 if (closestElement(span, ".dbx-cms-inline-media-missing-wrap")) return;
5997 const id = Number(span.getAttribute("data-cms-media-id") || span.parentElement?.getAttribute("data-cms-media-id") || 0);
5998 const label = String(span.textContent || "").trim() || ("Mediendatei #" + id + " nicht verfuegbar");
5999 const host = span.parentElement;
6000 const wrap = inlineMissingMediaWrap(doc, id, label);
6001 if (host && (host.classList.contains("dbx-cms-mod-placeholder") || host.classList.contains("dbx-cms-inline-media"))) {
6002 host.replaceWith(wrap);
6003 } else {
6004 span.replaceWith(wrap);
6005 }
6006 });
6007 }
6008
6009 function inlineVideoMediaSize(media) {
6010 if (!media) return { width: "", height: "" };
6011 let width = media.style && media.style.width ? media.style.width : "";
6012 let height = media.style && media.style.height ? media.style.height : "";
6013 if (!width) width = media.getAttribute("width") || media.getAttribute("data-cms-video-width") || "";
6014 if (!height) height = media.getAttribute("height") || media.getAttribute("data-cms-video-height") || "";
6015 return {
6016 width: cssSizeValue(width),
6017 height: cssSizeValue(height)
6018 };
6019 }
6020
6021 function persistInlineVideoRenderedSize(wrapper) {
6022 if (!wrapper || !wrapper.getBoundingClientRect) return false;
6023 const rect = wrapper.getBoundingClientRect();
6024 if (!rect || rect.width < 32 || rect.height < 24) return false;
6025 const width = Math.round(rect.width) + "px";
6026 const height = Math.round(rect.height) + "px";
6027 const beforeWidth = cssSizeValue(wrapper.getAttribute("data-cms-video-width") || wrapper.style.width || "");
6028 const beforeHeight = cssSizeValue(wrapper.getAttribute("data-cms-video-height") || wrapper.style.height || "");
6029 const changed = beforeWidth !== width || beforeHeight !== height;
6030 wrapper.style.width = width;
6031 wrapper.style.height = height;
6032 wrapper.setAttribute("data-cms-video-width", width);
6033 wrapper.setAttribute("data-cms-video-height", height);
6034 const options = inlineVideoOptionsFromElement(wrapper);
6035 options.width = width;
6036 options.height = height;
6037 syncInlineVideoOptionsToMedia(wrapper, options);
6038 return changed;
6039 }
6040
6041 function beginInlineVideoResizeTrack(root, wrapper) {
6042 if (!root || !wrapper) return;
6043 const rect = wrapper.getBoundingClientRect ? wrapper.getBoundingClientRect() : null;
6044 state(root).inlineVideoResizeTrack = {
6045 wrapper,
6046 width: rect ? Math.round(rect.width) : 0,
6047 height: rect ? Math.round(rect.height) : 0
6048 };
6049 }
6050
6051 function finishInlineVideoResizeTrack(root) {
6052 const s = state(root);
6053 const track = s.inlineVideoResizeTrack;
6054 s.inlineVideoResizeTrack = null;
6055 if (!track || !track.wrapper || !track.wrapper.isConnected) return false;
6056 const rect = track.wrapper.getBoundingClientRect ? track.wrapper.getBoundingClientRect() : null;
6057 if (!rect) return false;
6058 const width = Math.round(rect.width);
6059 const height = Math.round(rect.height);
6060 if (Math.abs(width - track.width) < 2 && Math.abs(height - track.height) < 2) return false;
6061 if (!persistInlineVideoRenderedSize(track.wrapper)) return false;
6062 syncEditorDom(root);
6063 return true;
6064 }
6065
6066 function syncInlineVideoBlockSizes(container) {
6067 if (!container) return;
6068 qsa(container, ".dbx-cms-inline-video-block").forEach(wrapper => {
6069 if (!qs(wrapper, "[data-cms-inline-video-options-open]")) {
6070 wrapper.insertAdjacentHTML("beforeend", inlineVideoOptionsButtonHtml());
6071 }
6072 const media = qs(wrapper, ".dbx-cms-inline-video-thumb, img, video, iframe");
6073 const size = inlineVideoMediaSize(media);
6074 let width = size.width || cssSizeValue(wrapper.style.width || wrapper.getAttribute("data-cms-video-width") || "");
6075 let height = size.height || cssSizeValue(wrapper.style.height || wrapper.getAttribute("data-cms-video-height") || "");
6076 const rect = wrapper.getBoundingClientRect ? wrapper.getBoundingClientRect() : null;
6077 const defaultWidth = Math.min((container.getBoundingClientRect ? container.getBoundingClientRect().width : 720) || 720, 720);
6078 if (!width && rect && rect.width > 0 && Math.abs(rect.width - defaultWidth) > 2) width = Math.round(rect.width) + "px";
6079 if (!height && rect && rect.height > 0) {
6080 const ratioHeight = rect.width > 0 ? rect.width * 9 / 16 : 0;
6081 if (!ratioHeight || Math.abs(rect.height - ratioHeight) > 2) height = Math.round(rect.height) + "px";
6082 }
6083 if (width) {
6084 wrapper.style.width = width;
6085 wrapper.setAttribute("data-cms-video-width", width);
6086 }
6087 if (height && height !== "auto") {
6088 wrapper.style.height = height;
6089 wrapper.setAttribute("data-cms-video-height", height);
6090 }
6091 if (media) {
6092 media.removeAttribute("width");
6093 media.removeAttribute("height");
6094 if (media.style) {
6095 media.style.width = "";
6096 media.style.height = "";
6097 }
6098 }
6099 });
6100 }
6101
6102 function plainMarkerName(text) {
6103 const match = String(text || "").trim().match(/^dbx:(split|col2|col3a|col3b|header|teaser|footer|pagebreak)$/i);
6104 if (!match) return "";
6105 return match[1].toLowerCase() === "split" ? "col2" : match[1].toLowerCase();
6106 }
6107
6108 function normalizePlainTextMarkers(container) {
6109 if (!container) return;
6110 normalizeCommentMarkers(container);
6111 const doc = container.ownerDocument || document;
6112 qsa(container, "p,div").forEach(el => {
6113 if (el.querySelector(".dbx-cms-marker,[data-dbx-marker],img,video,iframe,table,hr,ul,ol")) return;
6114 const name = plainMarkerName(el.textContent);
6115 if (!name) return;
6116 const hr = cmsMarkerElement("dbx:" + name, null, doc);
6117 if (hr) el.replaceWith(hr);
6118 });
6119 Array.from(container.childNodes || []).forEach(node => {
6120 if (node.nodeType !== 3) return;
6121 const name = plainMarkerName(node.nodeValue);
6122 if (!name) return;
6123 const hr = cmsMarkerElement("dbx:" + name, null, doc);
6124 if (hr) node.replaceWith(hr);
6125 });
6126 }
6127
6128 function normalizeInlineMediaLayout(container) {
6129 if (!container) return;
6130 qsa(container, ".dbx-cms-inline-media img, .dbx-cms-inline-media video, .dbx-cms-inline-media iframe").forEach(media => {
6131 const wrapper = closestElement(media, ".dbx-cms-inline-media");
6132 if (!wrapper) return;
6133 const floatValue = String(media.style.float || "").toLowerCase();
6134 if (floatValue !== "left" && floatValue !== "right") return;
6135
6136 wrapper.style.float = floatValue;
6137 wrapper.style.marginLeft = media.style.marginLeft || "";
6138 wrapper.style.marginRight = media.style.marginRight || "";
6139 media.style.float = "";
6140 media.style.marginLeft = "";
6141 media.style.marginRight = "";
6142 media.style.display = "";
6143 });
6144 }
6145
6146 function topEditorChild(surface, range) {
6147 if (!surface || !range) return null;
6148 if (range.startContainer === surface) {
6149 const child = surface.childNodes[Math.min(range.startOffset, Math.max(surface.childNodes.length - 1, 0))];
6150 return child && child.nodeType === 1 ? child : null;
6151 }
6152 let el = nodeElement(range.startContainer);
6153 if (!el || el === surface || !surface.contains(el)) return null;
6154 while (el.parentElement && el.parentElement !== surface) el = el.parentElement;
6155 return el.parentElement === surface ? el : null;
6156 }
6157
6158 function canSplitForMarker(block) {
6159 if (!block || block.nodeType !== 1) return false;
6160 return /^(P|DIV|H1|H2|H3|H4|H5|H6|BLOCKQUOTE|UL|OL|LI)$/i.test(block.tagName || "")
6161 && !/^(TABLE|FIGURE)$/i.test(block.tagName || "");
6162 }
6163
6164 function insertFragmentAfter(parent, fragment, afterNode) {
6165 let last = afterNode;
6166 Array.from(fragment.childNodes || []).forEach(node => {
6167 parent.insertBefore(node, last ? last.nextSibling : null);
6168 last = node;
6169 });
6170 return last;
6171 }
6172
6173 function insertEditorHrNode(root, hrNode) {
6174 const instance = getEditorInstance(root);
6175 if (instance && typeof instance.setMode === "function") {
6176 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
6177 }
6178
6179 restoreEditorSelection(root);
6180
6181 const surface = editorSurface(root);
6182 if (!surface) return false;
6183 const doc = surface.ownerDocument || document;
6184 const sel = doc.getSelection ? doc.getSelection() : null;
6185 let range = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : state(root).editorRange;
6186 if (!hrNode) return false;
6187
6188 if (!rangeInsideSurface(surface, range)) {
6189 surface.appendChild(hrNode);
6190 setEditorCaretAfterNode(root, hrNode);
6191 normalizeEditorMarkers(root);
6192 bindEditorMarkerEventsRetry(root);
6193 syncEditorDom(root);
6194 return true;
6195 }
6196
6197 if (!range.collapsed) {
6198 range.deleteContents();
6199 if (sel) {
6200 sel.removeAllRanges();
6201 sel.addRange(range);
6202 }
6203 }
6204
6205 const block = topEditorChild(surface, range);
6206 if (canSplitForMarker(block) && block.contains(nodeElement(range.startContainer))) {
6207 const parent = block.parentElement;
6208 const suffixRange = range.cloneRange();
6209 suffixRange.setEndAfter(block);
6210 const suffix = suffixRange.extractContents();
6211 const blockHasContent = nodeHasEditorContent(block);
6212
6213 if (blockHasContent) {
6214 parent.insertBefore(hrNode, block.nextSibling);
6215 } else {
6216 parent.insertBefore(hrNode, block);
6217 block.remove();
6218 }
6219
6220 if (nodeHasEditorContent(suffix)) {
6221 insertFragmentAfter(parent, suffix, hrNode);
6222 }
6223 } else if (range.startContainer === surface) {
6224 surface.insertBefore(hrNode, surface.childNodes[range.startOffset] || null);
6225 } else if (block && block.parentElement === surface) {
6226 surface.insertBefore(hrNode, block.nextSibling);
6227 } else {
6228 range.insertNode(hrNode);
6229 }
6230
6231 normalizeEditorMarkers(root);
6232 bindEditorMarkerEventsRetry(root);
6233 syncEditorDom(root);
6234 setEditorCaretAfterNode(root, hrNode);
6235 return true;
6236 }
6237
6238 function insertEditorPlainHr(root) {
6239 const surface = editorSurface(root);
6240 const doc = surface ? (surface.ownerDocument || document) : document;
6241 return insertEditorHrNode(root, doc.createElement("hr"));
6242 }
6243
6244 function insertEditorMarkerElement(root, marker, label) {
6245 const surface = editorSurface(root);
6246 const doc = surface ? (surface.ownerDocument || document) : document;
6247 return insertEditorHrNode(root, cmsMarkerElement(marker, label, doc));
6248 }
6249
6250 function saveEditorSelection(root) {
6251 const surface = editorSurface(root);
6252 if (!surface) return false;
6253 if (state(root).selectedMarker || state(root).selectedMissingMedia) {
6254 hideEditorCaretHint(root);
6255 return false;
6256 }
6257 const doc = surface.ownerDocument || document;
6258 const sel = doc.getSelection ? doc.getSelection() : null;
6259 if (!sel || !sel.rangeCount) {
6260 hideEditorCaretHint(root);
6261 return false;
6262 }
6263 const range = sel.getRangeAt(0);
6264 const common = range.commonAncestorContainer;
6265 const commonEl = nodeElement(common);
6266 if (common !== surface && (!commonEl || !surface.contains(commonEl))) {
6267 hideEditorCaretHint(root);
6268 return false;
6269 }
6270 state(root).editorRange = range.cloneRange();
6271 refreshEditorCaretHint(root);
6272 return true;
6273 }
6274
6275 function refreshEditorCaretHint(root) {
6276 const surface = editorSurface(root);
6277 if (!surface || state(root).selectedMarker || state(root).selectedMissingMedia) {
6278 hideEditorCaretHint(root);
6279 return;
6280 }
6281 const doc = surface.ownerDocument || document;
6282 const sel = doc.getSelection ? doc.getSelection() : null;
6283 if (!sel || !sel.rangeCount) {
6284 hideEditorCaretHint(root);
6285 return;
6286 }
6287 const range = sel.getRangeAt(0);
6288 if (!range.collapsed) {
6289 hideEditorCaretHint(root);
6290 return;
6291 }
6292 showEditorCaretHint(root, range);
6293 }
6294
6295 function hideEditorCaretHint(root) {
6296 const s = state(root);
6297 if (s.editorCaretHintTimer) {
6298 window.clearTimeout(s.editorCaretHintTimer);
6299 s.editorCaretHintTimer = null;
6300 }
6301 const hint = qs(root, "[data-cms-editor-caret-hint]");
6302 if (hint) hint.hidden = true;
6303 }
6304
6305 function editorCaretBlock(surface, range) {
6306 if (!surface || !range) return null;
6307 let el = nodeElement(range.startContainer);
6308 if (!el || el === surface) {
6309 const child = surface.childNodes[Math.max(0, Math.min(range.startOffset || 0, surface.childNodes.length - 1))];
6310 el = nodeElement(child) || surface;
6311 }
6312 while (el && el !== surface) {
6313 if (/^(P|DIV|H1|H2|H3|H4|H5|H6|BLOCKQUOTE|LI|TD|TH)$/i.test(el.tagName || "")) return el;
6314 el = el.parentElement;
6315 }
6316 return null;
6317 }
6318
6319 function editorCaretBlockRect(surface, range) {
6320 const block = editorCaretBlock(surface, range);
6321 if (!block || !block.getBoundingClientRect) return null;
6322 const blockRect = block.getBoundingClientRect();
6323 if (!blockRect || !blockRect.width || !blockRect.height) return null;
6324 const style = window.getComputedStyle ? window.getComputedStyle(block) : null;
6325 const paddingLeft = Number.parseFloat(style?.paddingLeft || "0") || 0;
6326 const paddingTop = Number.parseFloat(style?.paddingTop || "0") || 0;
6327 let lineHeight = Number.parseFloat(style?.lineHeight || "0") || 0;
6328 if (!lineHeight) {
6329 const fontSize = Number.parseFloat(style?.fontSize || "16") || 16;
6330 lineHeight = fontSize * 1.55;
6331 }
6332 return {
6333 left: blockRect.left + paddingLeft,
6334 top: blockRect.top + paddingTop,
6335 height: Math.max(18, Math.min(blockRect.height, lineHeight || 24)),
6336 blockRect
6337 };
6338 }
6339
6340 function caretRectLooksValid(rect, surface, range) {
6341 if (!rect || !rect.height) return false;
6342 const surfaceRect = surface.getBoundingClientRect ? surface.getBoundingClientRect() : null;
6343 if (surfaceRect && (rect.top < surfaceRect.top - 4 || rect.top > surfaceRect.bottom + 4)) return false;
6344 const block = editorCaretBlock(surface, range);
6345 if (!block || !block.getBoundingClientRect) return true;
6346 const blockRect = block.getBoundingClientRect();
6347 return rect.top >= blockRect.top - 4 && rect.top <= blockRect.bottom + 4;
6348 }
6349
6350 function editorCaretLineHeight(surface, range) {
6351 const block = editorCaretBlock(surface, range);
6352 if (!block) return 18;
6353 const style = window.getComputedStyle ? window.getComputedStyle(block) : null;
6354 let lineHeight = Number.parseFloat(style?.lineHeight || "0") || 0;
6355 if (!lineHeight || style?.lineHeight === "normal") {
6356 lineHeight = (Number.parseFloat(style?.fontSize || "16") || 16) * 1.45;
6357 }
6358 return Math.max(14, Math.min(lineHeight, 22));
6359 }
6360
6361 function showEditorCaretHint(root, range) {
6362 const surface = editorSurface(root);
6363 if (!surface || !range || !range.collapsed) {
6364 hideEditorCaretHint(root);
6365 return;
6366 }
6367
6368 let rect = null;
6369 const rects = range.getClientRects ? Array.from(range.getClientRects()) : [];
6370 rect = rects.find(item => item && item.height > 0 && item.height <= 36) || null;
6371 if (!rect && range.startContainer && range.startContainer.nodeType === 3) {
6372 const probe = range.cloneRange();
6373 const text = range.startContainer.nodeValue || "";
6374 if (range.startOffset < text.length) probe.setEnd(range.startContainer, range.startOffset + 1);
6375 else if (range.startOffset > 0) probe.setStart(range.startContainer, range.startOffset - 1);
6376 rect = Array.from(probe.getClientRects ? probe.getClientRects() : []).find(item => item && item.height > 0 && item.height <= 36) || null;
6377 }
6378 if (!caretRectLooksValid(rect, surface, range)) {
6379 hideEditorCaretHint(root);
6380 return;
6381 }
6382
6383 const lineHeight = editorCaretLineHeight(surface, range);
6384 let hint = qs(root, "[data-cms-editor-caret-hint]");
6385 if (!hint) {
6386 hint = document.createElement("span");
6387 hint.className = "dbx-cms-editor-caret-hint";
6388 hint.setAttribute("data-cms-editor-caret-hint", "1");
6389 hint.setAttribute("aria-hidden", "true");
6390 root.appendChild(hint);
6391 }
6392
6393 hint.hidden = false;
6394 hint.style.left = Math.round(rect.left) + "px";
6395 hint.style.top = Math.round(rect.top + Math.max(0, (rect.height - lineHeight) / 2)) + "px";
6396 hint.style.height = Math.round(lineHeight) + "px";
6397
6398 const s = state(root);
6399 if (s.editorCaretHintTimer) {
6400 window.clearTimeout(s.editorCaretHintTimer);
6401 s.editorCaretHintTimer = null;
6402 }
6403 }
6404
6405 function restoreEditorSelection(root) {
6406 const surface = editorSurface(root);
6407 if (!surface) return false;
6408 const doc = surface.ownerDocument || document;
6409 const range = state(root).editorRange;
6410 if (!range || !range.startContainer || !range.endContainer) {
6411 if (surface.focus) surface.focus();
6412 return false;
6413 }
6414
6415 const start = nodeElement(range.startContainer);
6416 const end = nodeElement(range.endContainer);
6417 if (!start || !end || !surface.contains(start) || !surface.contains(end)) {
6418 state(root).editorRange = null;
6419 if (surface.focus) surface.focus();
6420 return false;
6421 }
6422
6423 const sel = doc.getSelection ? doc.getSelection() : null;
6424 if (!sel) return false;
6425 if (surface.focus) surface.focus({ preventScroll: true });
6426 sel.removeAllRanges();
6427 sel.addRange(range);
6428 return true;
6429 }
6430
6431 function pushEditorHtml(root) {
6432 const surface = editorSurface(root);
6433 const html = surface ? (surface.innerHTML || "") : getEditorHtml(root);
6434 const instance = getEditorInstance(root);
6435 if (instance && instance.value !== html) instance.value = html;
6436 setField(root, "content", html);
6437 markDirty(root);
6438 scheduleEditorHeight(root);
6439 }
6440
6441 function hoistEditorMarkersToSurface(surface) {
6442 if (!surface) return;
6443 qsa(surface, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
6444 if (marker.parentElement === surface) return;
6445 let block = marker.parentElement;
6446 while (block && block.parentElement && block.parentElement !== surface) {
6447 block = block.parentElement;
6448 }
6449 if (block && block.parentElement === surface) {
6450 surface.insertBefore(marker, block.nextSibling);
6451 } else {
6452 surface.appendChild(marker);
6453 }
6454 });
6455 }
6456
6457 function surfaceEditorBlocks(surface, ignoreMarker) {
6458 if (!surface) return [];
6459 return Array.from(surface.children).filter(el => {
6460 return el.nodeType === 1 && el !== ignoreMarker;
6461 });
6462 }
6463
6464 function markerSurfacePlacement(surface, x, y, ignoreMarker) {
6465 if (!surface) return null;
6466 const blocks = surfaceEditorBlocks(surface, ignoreMarker);
6467 if (!blocks.length) {
6468 return { ref: null, before: true, target: null };
6469 }
6470
6471 for (let i = 0; i < blocks.length; i++) {
6472 const block = blocks[i];
6473 const rect = block.getBoundingClientRect ? block.getBoundingClientRect() : null;
6474 if (!rect || !rect.height) continue;
6475 if (y < rect.top + rect.height / 2) {
6476 return { ref: block, before: true, target: block };
6477 }
6478 }
6479
6480 const last = blocks[blocks.length - 1];
6481 return { ref: last, before: false, target: last };
6482 }
6483
6484 function syncEditorDom(root, options) {
6485 options = options || {};
6486 const surface = editorSurface(root);
6487 normalizeEditorMarkers(root);
6488 repairInlineVideoPlayers(root, surface);
6489 normalizeInlineMediaLayout(surface);
6490 if (surface) cleanEditorRuntimeNodes(surface);
6491 const html = surface ? surface.innerHTML : getEditorHtml(root);
6492 const instance = getEditorInstance(root);
6493 if (instance) instance.value = html || "";
6494 setField(root, "content", html || "");
6495 if (!options.silent) markDirty(root);
6496 scheduleEditorHeight(root);
6497 }
6498
6499 function saveCurrentCms(root, cfg) {
6500 if (root.classList.contains("is-folder-editing")) return saveFolder(root, cfg || {});
6501 syncEditorDom(root, { silent: true });
6502 return savePage(root, cfg || {});
6503 }
6504
6505 function duplicateCurrentPage(root, cfg) {
6506 const s = state(root);
6507 const id = Number(getField(root, "id") || 0);
6508 const url = cfgUrl(cfg, "duplicatepage");
6509 if (root.classList.contains("is-folder-editing") || s.selectedType !== "page" || !id || !url) {
6510 status(root, "Bitte zuerst eine Seite auswaehlen.", "error");
6511 return Promise.resolve();
6512 }
6513 if (s.dirty) {
6514 status(root, "Bitte ungespeicherte Aenderungen vor dem Duplizieren speichern.", "warning");
6515 return Promise.resolve();
6516 }
6517 if (s.saving || s.duplicating) return Promise.resolve();
6518
6519 s.duplicating = true;
6520 updateHeaderActionTooltips(root);
6521 status(root, "Seite wird dupliziert...", "info");
6522
6523 return fetchJson(apiUrl(url, cmsLngParams(root)), {
6524 method: "POST",
6525 headers: { "Content-Type": "application/json" },
6526 body: JSON.stringify({ id })
6527 }).then(data => {
6528 if (!data || !data.ok || !data.id) {
6529 throw new Error(data && data.msg ? data.msg : "duplicate failed");
6530 }
6531 return loadTree(root, cfg)
6532 .then(() => loadPage(root, cfg, data.id))
6533 .then(() => {
6534 status(root, data.msg || "Seite wurde dupliziert.", "success");
6535 maybeOpenLngProvisionAfterCreate(root, cfg, data);
6536 return data;
6537 });
6538 }).catch(err => {
6539 dbx.error("[cms] duplicate page failed", err);
6540 status(root, err && err.message ? err.message : "Seite konnte nicht dupliziert werden.", "error");
6541 }).finally(() => {
6542 s.duplicating = false;
6543 updateHeaderActionTooltips(root);
6544 });
6545 }
6546
6547 function deleteCurrentCms(root, cfg) {
6548 if (root.classList.contains("is-folder-editing")) return deleteFolder(root, cfg || {});
6549 return deletePage(root, cfg || {});
6550 }
6551
6552 function bindCmsKeyboardShortcuts(root, cfg) {
6553 if (!root || root.__dbxCmsKeyboardShortcutsBound) return;
6554 root.__dbxCmsKeyboardShortcutsBound = true;
6555 root.addEventListener("keydown", e => {
6556 const key = String(e.key || "").toLowerCase();
6557 if (!(e.ctrlKey || e.metaKey) || key !== "s") return;
6558 if (!root.contains(e.target)) return;
6559 e.preventDefault();
6560 e.stopPropagation();
6561 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
6562 saveCurrentCms(root, cfg || {});
6563 }, true);
6564 }
6565
6566 function cmsConfig(root) {
6567 if (!root || !root.getAttribute || !dbx.parseData) return {};
6568 const cfgList = dbx.parseData(root.getAttribute("data-dbx"));
6569 return cfgList.find(item => item.lib === LIB) || {};
6570 }
6571
6572 function editorDropTarget(root, target) {
6573 const surface = editorSurface(root);
6574 if (!surface) return null;
6575 let el = target && target.nodeType === 1 ? target : target?.parentElement;
6576 if (!el || !surface.contains(el)) return surface;
6577 const selector = ".dbx-cms-marker,p,h1,h2,h3,h4,ul,ol,blockquote,figure,table,img,hr";
6578 const block = el.closest ? el.closest(selector) : null;
6579 return block && surface.contains(block) ? block : surface;
6580 }
6581
6582 function clearEditorDropMarks(root) {
6583 const surface = editorSurface(root);
6584 if (!surface) return;
6585 qsa(surface, ".is-drop-before,.is-drop-after").forEach(el => el.classList.remove("is-drop-before", "is-drop-after"));
6586 }
6587
6588 function ensureLeadingEditorParagraph(surface) {
6589 if (!surface) return null;
6590 const first = surface.firstElementChild;
6591 if (!first || !first.classList.contains("dbx-cms-marker")) return null;
6592 const doc = surface.ownerDocument || document;
6593 const p = doc.createElement("p");
6594 p.innerHTML = "<br>";
6595 surface.insertBefore(p, first);
6596 return p;
6597 }
6598
6599 function setEditorCaretInElement(root, el, offset) {
6600 const surface = editorSurface(root);
6601 if (!surface || !el) return false;
6602 const doc = surface.ownerDocument || document;
6603 const sel = doc.getSelection ? doc.getSelection() : null;
6604 if (!sel) return false;
6605 const range = doc.createRange();
6606 offset = Number(offset || 0);
6607 if (el.firstChild) {
6608 range.setStart(el.firstChild, Math.min(offset, el.firstChild.length || 0));
6609 } else {
6610 range.setStart(el, 0);
6611 }
6612 range.collapse(true);
6613 if (surface.focus) surface.focus({ preventScroll: true });
6614 sel.removeAllRanges();
6615 sel.addRange(range);
6616 state(root).editorRange = range.cloneRange();
6617 refreshEditorCaretHint(root);
6618 return true;
6619 }
6620
6621 function focusEditorAboveMarker(root, marker) {
6622 const surface = editorSurface(root);
6623 if (!surface || !marker) return false;
6624 let lead = marker.previousElementSibling;
6625 if (!lead || !/^(P|DIV|H[1-6]|BLOCKQUOTE|UL|OL)$/i.test(lead.tagName)) {
6626 if (marker === surface.firstElementChild) {
6627 lead = ensureLeadingEditorParagraph(surface);
6628 }
6629 if (!lead) {
6630 const doc = surface.ownerDocument || document;
6631 lead = doc.createElement("p");
6632 lead.innerHTML = "<br>";
6633 surface.insertBefore(lead, marker);
6634 }
6635 }
6636 if (!lead) return false;
6637 selectEditorMarker(root, null);
6638 return setEditorCaretInElement(root, lead, 0);
6639 }
6640
6641 function createMarkerDragGhost(marker, rect) {
6642 const doc = marker.ownerDocument || document;
6643 const ghost = doc.createElement("div");
6644 ghost.className = marker.className;
6645 ghost.classList.remove("is-selected", "is-hovered", "is-dragging", "is-dragging-source");
6646 ghost.classList.add("dbx-cms-marker-drag-ghost");
6647 ghost.setAttribute("data-label", marker.getAttribute("data-label") || "");
6648 ghost.setAttribute("aria-hidden", "true");
6649 ghost.style.position = "fixed";
6650 ghost.style.zIndex = "2147483647";
6651 ghost.style.pointerEvents = "none";
6652 ghost.style.width = Math.max(rect.width, 120) + "px";
6653 ghost.style.height = Math.max(rect.height, 34) + "px";
6654 ghost.style.left = rect.left + "px";
6655 ghost.style.top = rect.top + "px";
6656 (doc.body || document.body).appendChild(ghost);
6657 return ghost;
6658 }
6659
6660 function updateMarkerDragGhost(drag, clientX, clientY) {
6661 if (!drag || !drag.ghost) return;
6662 drag.ghost.style.left = (clientX - drag.ghostOffsetX) + "px";
6663 drag.ghost.style.top = (clientY - drag.ghostOffsetY) + "px";
6664 }
6665
6666 function removeMarkerDragGhost(ghost) {
6667 if (ghost && ghost.parentNode) ghost.parentNode.removeChild(ghost);
6668 }
6669
6670 function clearMarkerPointerDrag(root) {
6671 const drag = state(root).pointerDragMarker;
6672 if (!drag) return;
6673 removeMarkerDragGhost(drag.ghost);
6674 if (drag.marker) drag.marker.classList.remove("is-dragging", "is-dragging-source");
6675 clearEditorDropMarks(root);
6676 state(root).pointerDragMarker = null;
6677 }
6678
6679 function normalizeEditorMarkers(root) {
6680 const surface = editorSurface(root);
6681 if (!surface) return;
6682 const selectedName = markerNameFromElement(state(root).selectedMarker);
6683 normalizeCommentMarkers(surface);
6684 normalizePlainTextMarkers(surface);
6685 qsa(surface, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
6686 const raw = marker.getAttribute("data-dbx-marker") || marker.getAttribute("data-dbx-marker-comment") || "";
6687 const name = cmsMarkerName(raw);
6688 if (marker.tagName !== "HR") {
6689 const hr = document.createElement("hr");
6690 Array.from(marker.attributes || []).forEach(attr => {
6691 if (attr.name === "data-dbx-marker-comment") return;
6692 hr.setAttribute(attr.name, attr.value);
6693 });
6694 marker.replaceWith(hr);
6695 marker = hr;
6696 }
6697 marker.classList.add("dbx-cms-marker", "dbx-cms-marker-" + cmsMarkerClassName(name));
6698 marker.setAttribute("data-dbx-marker", "dbx:" + name);
6699 if (!marker.getAttribute("data-label")) marker.setAttribute("data-label", cmsMarkerLabel("dbx:" + name));
6700 marker.setAttribute("contenteditable", "false");
6701 marker.setAttribute("draggable", "false");
6702 marker.setAttribute("tabindex", "0");
6703 marker.setAttribute("role", "button");
6704 marker.setAttribute("title", "Marker auswaehlen, ziehen zum Verschieben, Entf zum Loeschen");
6705 });
6706 hoistEditorMarkersToSurface(surface);
6707 dedupeAdjacentMarkers(surface);
6708 ensureLeadingEditorParagraph(surface);
6709 if (selectedName) {
6710 const restored = qsa(surface, ".dbx-cms-marker").find(item => markerNameFromElement(item) === selectedName);
6711 if (restored) selectEditorMarker(root, restored);
6712 }
6713 }
6714
6715 function clearEditorMarkerHover(surface, except) {
6716 if (!surface) return;
6717 qsa(surface, ".dbx-cms-marker.is-hovered").forEach(el => {
6718 if (el !== except) el.classList.remove("is-hovered");
6719 });
6720 }
6721
6722 function selectEditorMarker(root, marker) {
6723 const surface = editorSurface(root);
6724 selectEditorMissingMedia(root, null);
6725 qsa(surface, ".dbx-cms-marker.is-selected").forEach(el => {
6726 if (el !== marker) {
6727 el.classList.remove("is-selected");
6728 el.removeAttribute("aria-selected");
6729 }
6730 });
6731 const s = state(root);
6732 if (marker && surface && surface.contains(marker)) {
6733 marker.classList.add("is-selected");
6734 marker.setAttribute("aria-selected", "true");
6735 s.selectedMarker = marker;
6736 clearEditorMarkerHover(surface, marker);
6737 hideEditorCaretHint(root);
6738 const doc = surface.ownerDocument || document;
6739 const sel = doc.getSelection ? doc.getSelection() : null;
6740 if (sel) sel.removeAllRanges();
6741 if (marker.focus) marker.focus({ preventScroll: true });
6742 } else {
6743 s.selectedMarker = null;
6744 qsa(surface, ".dbx-cms-marker[aria-selected]").forEach(el => el.removeAttribute("aria-selected"));
6745 }
6746 }
6747
6748 function handleEditorMarkerClick(root, marker, e) {
6749 if (!marker) return false;
6750 selectEditorMarker(root, marker);
6751 if (e) {
6752 if (typeof e.preventDefault === "function") e.preventDefault();
6753 if (typeof e.stopPropagation === "function") e.stopPropagation();
6754 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
6755 }
6756 return true;
6757 }
6758
6759 function emptyEditorParagraph(el) {
6760 if (!el || el.nodeType !== 1 || el.tagName !== "P") return false;
6761 if (el.querySelector("img,video,table,hr,.dbx-cms-marker")) return false;
6762 return String(el.textContent || "").trim() === "" && !el.querySelector("br");
6763 }
6764
6765 function removeEditorMarker(root, marker) {
6766 const surface = editorSurface(root);
6767 if (!marker || !surface || !surface.contains(marker)) return false;
6768 const next = marker.nextElementSibling;
6769 marker.remove();
6770 if (emptyEditorParagraph(next)) next.remove();
6771 state(root).selectedMarker = null;
6772 clearEditorDropMarks(root);
6773 syncEditorDom(root);
6774 return true;
6775 }
6776
6777 function rangeIntersectsNode(range, node) {
6778 if (!range || !node) return false;
6779 if (range.intersectsNode) {
6780 try {
6781 return range.intersectsNode(node);
6782 } catch (e) {
6783 return false;
6784 }
6785 }
6786 return false;
6787 }
6788
6789 function editorRangeFromPoint(surface, x, y) {
6790 const doc = surface ? (surface.ownerDocument || document) : document;
6791 if (!surface || !doc) return null;
6792 let range = null;
6793 if (doc.caretRangeFromPoint) {
6794 range = doc.caretRangeFromPoint(x, y);
6795 } else if (doc.caretPositionFromPoint) {
6796 const pos = doc.caretPositionFromPoint(x, y);
6797 if (pos) {
6798 range = doc.createRange();
6799 range.setStart(pos.offsetNode, pos.offset);
6800 range.collapse(true);
6801 }
6802 }
6803 if (!range) return null;
6804 const start = nodeElement(range.startContainer);
6805 return start && surface.contains(start) ? range : null;
6806 }
6807
6808 function setEditorCaretFromPoint(root, x, y) {
6809 const surface = editorSurface(root);
6810 const doc = surface ? (surface.ownerDocument || document) : document;
6811 const range = editorRangeFromPoint(surface, x, y);
6812 const sel = doc && doc.getSelection ? doc.getSelection() : null;
6813 if (!range || !sel) return false;
6814 if (surface.focus) surface.focus({ preventScroll: true });
6815 sel.removeAllRanges();
6816 sel.addRange(range);
6817 state(root).editorRange = range.cloneRange();
6818 return true;
6819 }
6820
6821 function showEditorMarkerDropHint(root, x, y, ignoreMarker) {
6822 const surface = editorSurface(root);
6823 clearEditorDropMarks(root);
6824 if (!surface) return;
6825 const placement = markerSurfacePlacement(surface, x, y, ignoreMarker);
6826 const target = placement && placement.target ? placement.target : null;
6827 if (!target) return;
6828 target.classList.toggle("is-drop-before", !!placement.before);
6829 target.classList.toggle("is-drop-after", !placement.before);
6830 }
6831
6832 function finishEditorMarkerDrop(root, marker) {
6833 if (!marker) return;
6834 marker.classList.remove("is-dragging", "is-dragging-source");
6835 marker.removeAttribute("data-cms-drag-token");
6836 const drag = state(root).pointerDragMarker;
6837 if (drag && drag.ghost) removeMarkerDragGhost(drag.ghost);
6838 clearEditorDropMarks(root);
6839 state(root).dragMarker = null;
6840 state(root).pointerDragMarker = null;
6841 const surface = editorSurface(root);
6842 hoistEditorMarkersToSurface(surface);
6843 normalizeEditorMarkers(root);
6844 selectEditorMarker(root, marker);
6845 pushEditorHtml(root);
6846 }
6847
6848 function moveEditorMarkerToPoint(root, marker, x, y) {
6849 const surface = editorSurface(root);
6850 if (!marker || !surface || !surface.contains(marker)) return false;
6851 const placement = markerSurfacePlacement(surface, x, y, marker);
6852 if (!placement) return false;
6853
6854 if (marker.parentElement) marker.parentElement.removeChild(marker);
6855
6856 if (placement.ref && placement.before) {
6857 surface.insertBefore(marker, placement.ref);
6858 } else if (placement.ref) {
6859 surface.insertBefore(marker, placement.ref.nextSibling);
6860 } else {
6861 surface.appendChild(marker);
6862 }
6863
6864 finishEditorMarkerDrop(root, marker);
6865 return true;
6866 }
6867
6868 function editorSelectionRange(root) {
6869 const surface = editorSurface(root);
6870 if (!surface) return null;
6871 const doc = surface.ownerDocument || document;
6872 const sel = doc.getSelection ? doc.getSelection() : null;
6873 if (!sel || !sel.rangeCount) return null;
6874 const range = sel.getRangeAt(0);
6875 return rangeInsideSurface(surface, range) ? range : null;
6876 }
6877
6878 function editorSelectionText(root) {
6879 const range = editorSelectionRange(root);
6880 return range && !range.collapsed ? String(range.cloneContents().textContent || "") : "";
6881 }
6882
6883 function selectEditorContents(root) {
6884 const surface = editorSurface(root);
6885 if (!surface) return false;
6886 const doc = surface.ownerDocument || document;
6887 const sel = doc.getSelection ? doc.getSelection() : null;
6888 if (!sel) return false;
6889 const range = doc.createRange();
6890 range.selectNodeContents(surface);
6891 if (surface.focus) surface.focus({ preventScroll: true });
6892 sel.removeAllRanges();
6893 sel.addRange(range);
6894 state(root).editorRange = range.cloneRange();
6895 return true;
6896 }
6897
6898 function editorContextBlock(root, target) {
6899 const surface = editorSurface(root);
6900 if (!surface) return null;
6901 const el = closestBootstrapComponent(root, target) || closestElement(target, ".dbx-cms-marker,figure,table,img,video,hr,p,h1,h2,h3,h4,ul,ol,blockquote");
6902 return el && surface.contains(el) ? el : null;
6903 }
6904
6905 function closestBootstrapComponent(root, target) {
6906 const surface = editorSurface(root);
6907 if (!surface || !target) return null;
6908 const el = closestElement(target, ".row,.alert,.card,.list-group,.accordion,.table-responsive,.nav-tabs,.tab-content");
6909 if (!el || !surface.contains(el)) return null;
6910 if (el.classList.contains("row") && !qs(el, ".col,.card")) return null;
6911 return el;
6912 }
6913
6914 function contextMissingMediaTarget(root, target) {
6915 const surface = editorSurface(root);
6916 const wrap = closestElement(target, ".dbx-cms-inline-media-missing-wrap");
6917 return wrap && surface && surface.contains(wrap) ? wrap : null;
6918 }
6919
6920 function selectEditorMissingMedia(root, wrap) {
6921 const surface = editorSurface(root);
6922 qsa(surface, ".dbx-cms-inline-media-missing-wrap.is-selected").forEach(el => {
6923 el.classList.remove("is-selected");
6924 el.removeAttribute("aria-selected");
6925 });
6926 if (wrap && surface && surface.contains(wrap)) {
6927 selectEditorMarker(root, null);
6928 wrap.classList.add("is-selected");
6929 wrap.setAttribute("aria-selected", "true");
6930 state(root).selectedMissingMedia = wrap;
6931 hideEditorCaretHint(root);
6932 const doc = surface.ownerDocument || document;
6933 const sel = doc.getSelection ? doc.getSelection() : null;
6934 if (sel) sel.removeAllRanges();
6935 if (wrap.focus) wrap.focus({ preventScroll: true });
6936 } else {
6937 state(root).selectedMissingMedia = null;
6938 qsa(surface, ".dbx-cms-inline-media-missing-wrap[aria-selected]").forEach(el => el.removeAttribute("aria-selected"));
6939 }
6940 }
6941
6942 function removeEditorMissingMedia(root, wrap) {
6943 wrap = wrap || state(root).selectedMissingMedia;
6944 if (!wrap || !wrap.parentNode) return false;
6945 selectEditorMissingMedia(root, null);
6946 wrap.remove();
6947 syncEditorDom(root);
6948 markDirty(root);
6949 return true;
6950 }
6951
6952 function handleEditorMissingMediaClick(root, wrap, e) {
6953 if (!wrap) return false;
6954 selectEditorMissingMedia(root, wrap);
6955 if (e) {
6956 if (typeof e.preventDefault === "function") e.preventDefault();
6957 if (typeof e.stopPropagation === "function") e.stopPropagation();
6958 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
6959 }
6960 return true;
6961 }
6962
6963 function removableEditorContextTarget(root, target) {
6964 const surface = editorSurface(root);
6965 if (!surface) return null;
6966 const component = closestBootstrapComponent(root, target);
6967 if (component && surface.contains(component)) return component;
6968 const mod = inlineModTarget(root, target);
6969 if (mod && surface.contains(mod)) return mod;
6970 const missing = closestElement(target, ".dbx-cms-inline-media-missing-wrap");
6971 if (missing && surface.contains(missing)) return missing;
6972 const el = closestElement(target, ".dbx-cms-marker,figure,table,img,video,hr");
6973 if (!el || !surface.contains(el)) return null;
6974 if ((el.tagName === "IMG" || el.tagName === "VIDEO") && el.parentElement && el.parentElement.tagName === "FIGURE") {
6975 return el.parentElement;
6976 }
6977 return el;
6978 }
6979
6980 function editorElementSibling(el, dir) {
6981 let node = dir < 0 ? el?.previousSibling : el?.nextSibling;
6982 while (node && node.nodeType !== 1) node = dir < 0 ? node.previousSibling : node.nextSibling;
6983 return node || null;
6984 }
6985
6986 function moveEditorContextBlock(root, block, dir) {
6987 const surface = editorSurface(root);
6988 if (!block || !surface || !surface.contains(block) || block === surface) return false;
6989 const sibling = editorElementSibling(block, dir);
6990 if (!sibling || !block.parentElement) return false;
6991 if (dir < 0) block.parentElement.insertBefore(block, sibling);
6992 else block.parentElement.insertBefore(sibling, block);
6993 if (block.matches && block.matches(".dbx-cms-marker")) selectEditorMarker(root, block);
6994 syncEditorDom(root);
6995 return true;
6996 }
6997
6998 function closeEditorContextMenu(root) {
6999 const s = state(root);
7000 if (typeof s.editorContextCleanup === "function") s.editorContextCleanup();
7001 if (s.editorContextMenu && s.editorContextMenu.parentNode) s.editorContextMenu.parentNode.removeChild(s.editorContextMenu);
7002 s.editorContextCleanup = null;
7003 s.editorContextMenu = null;
7004 }
7005
7006 function clipboardWriteText(text) {
7007 if (navigator.clipboard && navigator.clipboard.writeText) {
7008 return navigator.clipboard.writeText(text).catch(() => false);
7009 }
7010 return Promise.resolve(false);
7011 }
7012
7013 function clipboardReadText() {
7014 if (navigator.clipboard && navigator.clipboard.readText) {
7015 return navigator.clipboard.readText().catch(() => "");
7016 }
7017 return Promise.resolve("");
7018 }
7019
7020 function copyEditorContext(root, target) {
7021 const marker = closestElement(target, ".dbx-cms-marker");
7022 if (marker && editorSurface(root)?.contains(marker)) {
7023 state(root).editorClipboardHtml = marker.outerHTML;
7024 return clipboardWriteText(marker.outerHTML);
7025 }
7026 restoreEditorSelection(root);
7027 const text = editorSelectionText(root);
7028 const copied = document.execCommand && document.execCommand("copy");
7029 if (!copied && text) return clipboardWriteText(text);
7030 return Promise.resolve(copied);
7031 }
7032
7033 function cutEditorContext(root, target) {
7034 const marker = closestElement(target, ".dbx-cms-marker");
7035 if (marker && editorSurface(root)?.contains(marker)) {
7036 state(root).editorClipboardHtml = marker.outerHTML;
7037 return clipboardWriteText(marker.outerHTML).finally(() => removeEditorMarker(root, marker));
7038 }
7039 restoreEditorSelection(root);
7040 const text = editorSelectionText(root);
7041 const cut = document.execCommand && document.execCommand("cut");
7042 if (cut) {
7043 syncEditorDom(root);
7044 return Promise.resolve(true);
7045 }
7046 if (text) {
7047 return clipboardWriteText(text).finally(() => {
7048 execEditorCommand(root, "delete");
7049 syncEditorDom(root);
7050 });
7051 }
7052 return Promise.resolve(false);
7053 }
7054
7055 function pasteEditorContext(root) {
7056 restoreEditorSelection(root);
7057 return clipboardReadText().then(text => {
7058 if (text) {
7059 if (document.execCommand && document.execCommand("insertText", false, text)) {
7060 syncEditorDom(root);
7061 return true;
7062 }
7063 insertEditorHtml(root, escapeHtml(text));
7064 return true;
7065 }
7066 const html = state(root).editorClipboardHtml || "";
7067 if (html) {
7068 insertEditorHtml(root, html);
7069 return true;
7070 }
7071 if (document.execCommand && document.execCommand("paste")) {
7072 syncEditorDom(root);
7073 return true;
7074 }
7075 return false;
7076 });
7077 }
7078
7079 function deleteEditorContext(root, target) {
7080 const marker = closestElement(target, ".dbx-cms-marker") || state(root).selectedMarker;
7081 if (marker && editorSurface(root)?.contains(marker)) return removeEditorMarker(root, marker);
7082 const missing = contextMissingMediaTarget(root, target) || state(root).selectedMissingMedia;
7083 if (missing && editorSurface(root)?.contains(missing)) return removeEditorMissingMedia(root, missing);
7084 const mod = inlineModTarget(root, target) || state(root).selectedModPlaceholder;
7085 if (mod && editorSurface(root)?.contains(mod)) return removeEditorModPlaceholder(root, mod);
7086 const removable = removableEditorContextTarget(root, target);
7087 if (removable) {
7088 removable.remove();
7089 syncEditorDom(root);
7090 return true;
7091 }
7092 restoreEditorSelection(root);
7093 execEditorCommand(root, "delete");
7094 syncEditorDom(root);
7095 return true;
7096 }
7097
7098 function selectEditorNode(root, node) {
7099 const surface = editorSurface(root);
7100 if (!surface || !node || !surface.contains(node)) return false;
7101 const doc = surface.ownerDocument || document;
7102 const sel = doc.getSelection ? doc.getSelection() : null;
7103 if (!sel) return false;
7104 const range = doc.createRange();
7105 range.selectNode(node);
7106 if (surface.focus) surface.focus({ preventScroll: true });
7107 sel.removeAllRanges();
7108 sel.addRange(range);
7109 state(root).editorRange = range.cloneRange();
7110 return true;
7111 }
7112
7113 function syncEditorAfterContextAction(root) {
7114 const instance = getEditorInstance(root);
7115 if (instance && instance.synchronizeValues) instance.synchronizeValues();
7116 syncEditorDom(root);
7117 }
7118
7119 function contextImageTarget(root, target) {
7120 if (inlineModTarget(root, target)) return null;
7121 const surface = editorSurface(root);
7122 const img = closestElement(target, "img");
7123 return img && surface && surface.contains(img) ? img : null;
7124 }
7125
7126 function contextLinkTarget(root, target) {
7127 const surface = editorSurface(root);
7128 const link = closestElement(target, "a");
7129 return link && surface && surface.contains(link) ? link : null;
7130 }
7131
7132 function contextTableCell(root, target) {
7133 const surface = editorSurface(root);
7134 const cell = closestElement(target, "td,th");
7135 return cell && surface && surface.contains(cell) ? cell : null;
7136 }
7137
7138 function contextTableTarget(root, target) {
7139 const surface = editorSurface(root);
7140 const table = closestElement(target, "table");
7141 return table && surface && surface.contains(table) ? table : null;
7142 }
7143
7144 function openEditorImageProperties(root, img) {
7145 const instance = getEditorInstance(root);
7146 if (!instance || !img) return false;
7147 selectEditorNode(root, img);
7148 if (instance.e && instance.e.fire) {
7149 instance.e.fire("openImageProperties", img);
7150 return true;
7151 }
7152 return false;
7153 }
7154
7155 function alignContextImage(root, img, mode) {
7156 if (!img) return false;
7157 const media = closestElement(img, ".dbx-cms-inline-media");
7158 const target = media && editorSurface(root)?.contains(media) ? media : img;
7159 [img, target].forEach(el => {
7160 if (!el) return;
7161 el.style.float = "";
7162 el.style.marginLeft = "";
7163 el.style.marginRight = "";
7164 el.style.display = "";
7165 });
7166 if (mode === "left") {
7167 target.style.float = "left";
7168 target.style.marginRight = "1.5rem";
7169 } else if (mode === "right") {
7170 target.style.float = "right";
7171 target.style.marginLeft = "1.5rem";
7172 } else if (mode === "center") {
7173 target.style.display = "block";
7174 target.style.marginLeft = "auto";
7175 target.style.marginRight = "auto";
7176 }
7177 syncEditorAfterContextAction(root);
7178 return true;
7179 }
7180
7181 function openEditorLinkDialog(root, targetNode) {
7182 const instance = getEditorInstance(root);
7183 if (!instance) return false;
7184 if (targetNode) selectEditorNode(root, targetNode);
7185 if (instance.execCommand) {
7186 instance.execCommand("openLinkDialog");
7187 return true;
7188 }
7189 return false;
7190 }
7191
7192 function removeContextLink(root, link) {
7193 if (!link || !link.parentNode) return false;
7194 while (link.firstChild) link.parentNode.insertBefore(link.firstChild, link);
7195 link.remove();
7196 syncEditorAfterContextAction(root);
7197 return true;
7198 }
7199
7200 function tableApi(root) {
7201 const instance = getEditorInstance(root);
7202 if (!instance || !instance.getInstance) return null;
7203 try {
7204 return instance.getInstance("Table", instance.o);
7205 } catch (e) {
7206 return null;
7207 }
7208 }
7209
7210 function runTableContextAction(root, cell, action) {
7211 const table = contextTableTarget(root, cell);
7212 const api = tableApi(root);
7213 if (!table || !cell || !api) return false;
7214 const row = cell.parentElement;
7215 switch (action) {
7216 case "row-before":
7217 api.appendRow(table, row, false);
7218 break;
7219 case "row-after":
7220 api.appendRow(table, row, true);
7221 break;
7222 case "row-delete":
7223 api.removeRow(table, row.rowIndex);
7224 break;
7225 case "column-before":
7226 api.appendColumn(table, cell, false);
7227 break;
7228 case "column-after":
7229 api.appendColumn(table, cell, true);
7230 break;
7231 case "column-delete":
7232 api.removeColumn(table, cell.cellIndex);
7233 break;
7234 case "cell-empty":
7235 cell.innerHTML = "<br>";
7236 break;
7237 case "table-delete":
7238 table.remove();
7239 break;
7240 default:
7241 return false;
7242 }
7243 syncEditorAfterContextAction(root);
7244 return true;
7245 }
7246
7247 function contextMenuButton(label, icon, action, disabled) {
7248 const btn = document.createElement("button");
7249 btn.type = "button";
7250 btn.className = "dbx-cms-context-menu-item";
7251 btn.disabled = !!disabled;
7252 if (icon) {
7253 const iconEl = document.createElement("i");
7254 iconEl.className = "bi " + icon;
7255 iconEl.setAttribute("aria-hidden", "true");
7256 btn.appendChild(iconEl);
7257 }
7258 const labelEl = document.createElement("span");
7259 labelEl.textContent = label;
7260 btn.appendChild(labelEl);
7261 btn.addEventListener("mousedown", e => e.preventDefault());
7262 btn.addEventListener("click", e => {
7263 e.preventDefault();
7264 e.stopPropagation();
7265 if (btn.disabled) return;
7266 const root = btn.__dbxCmsRoot;
7267 closeEditorContextMenu(root);
7268 Promise.resolve(action()).catch(err => dbx.warn("[cms] context action failed", err));
7269 });
7270 return btn;
7271 }
7272
7273 function showEditorContextMenu(root, e) {
7274 const surface = editorSurface(root);
7275 if (!surface) return;
7276 closeEditorContextMenu(root);
7277
7278 const target = e.target;
7279 const marker = closestElement(target, ".dbx-cms-marker");
7280 const missingMedia = contextMissingMediaTarget(root, target);
7281 const hasSelection = !!editorSelectionText(root);
7282 const img = contextImageTarget(root, target);
7283 const videoMedia = inlineVideoEventTarget(root, e);
7284 const modPlaceholder = inlineModTarget(root, target);
7285 const component = closestBootstrapComponent(root, target);
7286 const cell = contextTableCell(root, target);
7287 const table = contextTableTarget(root, target);
7288
7289 if (marker && surface.contains(marker)) {
7290 selectEditorMarker(root, marker);
7291 } else if (missingMedia) {
7292 selectEditorMissingMedia(root, missingMedia);
7293 } else if (cell) {
7294 selectEditorNode(root, cell);
7295 } else if (modPlaceholder) {
7296 selectEditorModPlaceholder(root, modPlaceholder);
7297 } else if (component && surface.contains(component)) {
7298 selectEditorNode(root, component);
7299 } else if (videoMedia) {
7300 selectEditorNode(root, videoMedia);
7301 } else if (img) {
7302 selectEditorNode(root, img);
7303 } else if (!hasSelection) {
7304 setEditorCaretFromPoint(root, e.clientX, e.clientY);
7305 selectEditorMarker(root, null);
7306 }
7307
7308 const menu = document.createElement("div");
7309 menu.className = "dbx-cms-context-menu";
7310 menu.setAttribute("role", "menu");
7311 menu.setAttribute("aria-label", "Editor Kontextmenue");
7312
7313 const movable = removableEditorContextTarget(root, target);
7314 const hasContextTarget = !!(marker || missingMedia || modPlaceholder || component || videoMedia || img || table || movable);
7315 const items = [
7316 ["Rueckgaengig", "bi-arrow-counterclockwise", () => execEditorCommand(root, "undo"), false],
7317 ["Wiederholen", "bi-arrow-clockwise", () => execEditorCommand(root, "redo"), false],
7318 ["Alles markieren", "bi-check2-square", () => selectEditorContents(root), false],
7319 ["Block nach oben", "bi-arrow-up", () => moveEditorContextBlock(root, movable, -1), !movable],
7320 ["Block nach unten", "bi-arrow-down", () => moveEditorContextBlock(root, movable, 1), !movable],
7321 ["Modul Platzhalter", "bi-puzzle", () => openModPlaceholderOptions(root, modPlaceholder, cmsConfig(root) || {}), !modPlaceholder],
7322 ["Video Optionen", "bi-camera-video", () => openInlineVideoOptions(root, videoMedia), !videoMedia],
7323 ["Kopieren", "bi-clipboard", () => copyEditorContext(root, target), !hasSelection && !hasContextTarget],
7324 ["Ausschneiden", "bi-scissors", () => cutEditorContext(root, target), !hasSelection && !hasContextTarget],
7325 ["Einfuegen", "bi-clipboard-plus", () => pasteEditorContext(root), false],
7326 ["Loeschen", "bi-trash", () => deleteEditorContext(root, target), !hasSelection && !hasContextTarget]
7327 ];
7328
7329 items.forEach(item => {
7330 const btn = contextMenuButton(item[0], item[1], item[2], item[3]);
7331 btn.__dbxCmsRoot = root;
7332 menu.appendChild(btn);
7333 });
7334
7335 document.body.appendChild(menu);
7336 const vw = window.innerWidth || document.documentElement.clientWidth || 0;
7337 const vh = window.innerHeight || document.documentElement.clientHeight || 0;
7338 const rect = menu.getBoundingClientRect();
7339 const left = Math.max(8, Math.min(e.clientX, vw - rect.width - 8));
7340 const top = Math.max(8, Math.min(e.clientY, vh - rect.height - 8));
7341 menu.style.left = left + "px";
7342 menu.style.top = top + "px";
7343
7344 const close = evt => {
7345 if (evt && menu.contains(evt.target)) return;
7346 closeEditorContextMenu(root);
7347 };
7348 const closeOnKey = evt => {
7349 if (evt.key === "Escape") closeEditorContextMenu(root);
7350 };
7351 const win = window;
7352 window.setTimeout(() => {
7353 document.addEventListener("mousedown", close, true);
7354 document.addEventListener("keydown", closeOnKey, true);
7355 win.addEventListener("scroll", close, true);
7356 win.addEventListener("resize", close, true);
7357 }, 0);
7358 state(root).editorContextMenu = menu;
7359 state(root).editorContextCleanup = () => {
7360 document.removeEventListener("mousedown", close, true);
7361 document.removeEventListener("keydown", closeOnKey, true);
7362 win.removeEventListener("scroll", close, true);
7363 win.removeEventListener("resize", close, true);
7364 };
7365 }
7366
7367 function editorMarkerFromEvent(root, e) {
7368 const surface = editorSurface(root);
7369 const marker = closestElement(e.target, ".dbx-cms-marker");
7370 return marker && surface && surface.contains(marker) ? marker : null;
7371 }
7372
7373 function editorRootFromMarker(marker) {
7374 if (!marker) return null;
7375 const surface = closestElement(marker, ".jodit-wysiwyg, [data-cms-editor]");
7376 const root = closestElement(marker, ".dbx-cms");
7377 return root && surface && root.contains(surface) ? root : null;
7378 }
7379
7380 function editorMarkerAtPoint(x, y) {
7381 if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) return null;
7382 const direct = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
7383 const directMarker = closestElement(direct, ".dbx-cms-marker");
7384 if (directMarker) return directMarker;
7385 return qsa(document, ".jodit-wysiwyg .dbx-cms-marker, [data-cms-editor] .dbx-cms-marker").find(marker => {
7386 const rect = marker.getBoundingClientRect ? marker.getBoundingClientRect() : null;
7387 return rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
7388 }) || null;
7389 }
7390
7391 function editorMarkerFromPointerEvent(e) {
7392 return closestElement(e.target, ".dbx-cms-marker") || editorMarkerAtPoint(e.clientX, e.clientY);
7393 }
7394
7395 function bindEditorMarkerEvents(root) {
7396 const surface = editorSurface(root);
7397 if (!surface) return;
7398
7399 ["mouseup", "keyup", "input", "touchend"].forEach(type => {
7400 surface.addEventListener(type, () => saveEditorSelection(root), true);
7401 });
7402 const doc = surface.ownerDocument || document;
7403 if (!root.__dbxCmsSelectionBound && doc && doc.addEventListener) {
7404 root.__dbxCmsSelectionBound = true;
7405 doc.addEventListener("selectionchange", () => saveEditorSelection(root));
7406 }
7407 if (!root.__dbxCmsMarkerKeyBound) {
7408 root.__dbxCmsMarkerKeyBound = true;
7409 root.addEventListener("keydown", e => {
7410 if (e.key !== "Delete" && e.key !== "Backspace") return;
7411 const marker = state(root).selectedMarker;
7412 const mod = state(root).selectedModPlaceholder;
7413 const currentSurface = editorSurface(root);
7414 if (marker && currentSurface && currentSurface.contains(marker)) {
7415 e.preventDefault();
7416 e.stopPropagation();
7417 removeEditorMarker(root, marker);
7418 return;
7419 }
7420 if (mod && currentSurface && currentSurface.contains(mod)) {
7421 e.preventDefault();
7422 e.stopPropagation();
7423 removeEditorModPlaceholder(root, mod);
7424 }
7425 }, true);
7426 }
7427 if (!root.__dbxCmsInlineVideoResizeBound) {
7428 root.__dbxCmsInlineVideoResizeBound = true;
7429 const finish = () => finishInlineVideoResizeTrack(root);
7430 document.addEventListener("mouseup", finish, true);
7431 document.addEventListener("touchend", finish, true);
7432 document.addEventListener("pointerup", finish, true);
7433 }
7434 if (!root.__dbxCmsMarkerContainerBound) {
7435 root.__dbxCmsMarkerContainerBound = true;
7436 root.addEventListener("mousedown", e => {
7437 const marker = editorMarkerFromEvent(root, e) || editorMarkerFromPointerEvent(e);
7438 if (marker && editorRootFromMarker(marker) !== root) return;
7439 if (!marker) return;
7440 selectEditorMarker(root, marker);
7441 e.stopPropagation();
7442 }, true);
7443 root.addEventListener("click", e => {
7444 const marker = editorMarkerFromEvent(root, e) || editorMarkerFromPointerEvent(e);
7445 if (marker && editorRootFromMarker(marker) !== root) return;
7446 if (!marker) return;
7447 handleEditorMarkerClick(root, marker, e);
7448 }, true);
7449 }
7450
7451 if (surface.__dbxCmsMarkerEventsBound) return;
7452 surface.__dbxCmsMarkerEventsBound = true;
7453
7454 surface.addEventListener("mousedown", e => {
7455 const marker = closestElement(e.target, ".dbx-cms-marker");
7456 if (marker && surface.contains(marker)) {
7457 selectEditorMarker(root, marker);
7458 e.stopPropagation();
7459 return;
7460 }
7461 const missingMedia = contextMissingMediaTarget(root, e.target);
7462 if (missingMedia) {
7463 handleEditorMissingMediaClick(root, missingMedia, e);
7464 return;
7465 }
7466 const modPlaceholder = inlineModTarget(root, e.target);
7467 if (modPlaceholder) {
7468 selectEditorModPlaceholder(root, modPlaceholder);
7469 e.preventDefault();
7470 e.stopPropagation();
7471 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7472 return;
7473 }
7474 const videoBlock = inlineVideoEventTarget(root, e);
7475 if (videoBlock) {
7476 beginInlineVideoResizeTrack(root, videoBlock);
7477 if (e.button !== undefined && e.button !== 0) return;
7478 if (!isInlineVideoResizeHandleEvent(videoBlock, e)) {
7479 e.preventDefault();
7480 e.stopPropagation();
7481 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7482 openInlineVideoOptions(root, videoBlock);
7483 }
7484 return;
7485 }
7486 const first = surface.firstElementChild;
7487 if (first && first.classList.contains("dbx-cms-marker")) {
7488 const rect = first.getBoundingClientRect();
7489 if (e.clientY < rect.top + 8) {
7490 focusEditorAboveMarker(root, first);
7491 e.preventDefault();
7492 e.stopPropagation();
7493 return;
7494 }
7495 }
7496 selectEditorMarker(root, null);
7497 selectEditorMissingMedia(root, null);
7498 selectEditorModPlaceholder(root, null);
7499 }, true);
7500
7501 surface.addEventListener("mouseover", e => {
7502 const marker = closestElement(e.target, ".dbx-cms-marker");
7503 clearEditorMarkerHover(surface, marker);
7504 if (marker && surface.contains(marker)) marker.classList.add("is-hovered");
7505 }, true);
7506
7507 surface.addEventListener("mouseout", e => {
7508 const marker = closestElement(e.target, ".dbx-cms-marker");
7509 if (!marker || !surface.contains(marker)) return;
7510 const related = e.relatedTarget;
7511 if (!related || !marker.contains(related)) marker.classList.remove("is-hovered");
7512 }, true);
7513
7514 surface.addEventListener("focusin", () => {
7515 if (!state(root).selectedMarker && !state(root).selectedMissingMedia) refreshEditorCaretHint(root);
7516 }, true);
7517
7518 surface.addEventListener("focusout", e => {
7519 const next = e.relatedTarget;
7520 if (next && surface.contains(next)) return;
7521 if (next && closestElement(next, ".dbx-cms-marker")) return;
7522 hideEditorCaretHint(root);
7523 }, true);
7524
7525 surface.addEventListener("click", e => {
7526 const marker = closestElement(e.target, ".dbx-cms-marker");
7527 if (marker && surface.contains(marker)) {
7528 handleEditorMarkerClick(root, marker, e);
7529 return;
7530 }
7531 const missingMedia = contextMissingMediaTarget(root, e.target);
7532 if (missingMedia) {
7533 handleEditorMissingMediaClick(root, missingMedia, e);
7534 return;
7535 }
7536 const modPlaceholder = inlineModTarget(root, e.target);
7537 if (modPlaceholder) {
7538 selectEditorModPlaceholder(root, modPlaceholder);
7539 e.preventDefault();
7540 e.stopPropagation();
7541 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7542 return;
7543 }
7544 const videoBlock = inlineVideoEventTarget(root, e);
7545 if (videoBlock) {
7546 if (e.button !== undefined && e.button !== 0) return;
7547 e.preventDefault();
7548 e.stopPropagation();
7549 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7550 openInlineVideoOptions(root, videoBlock);
7551 return;
7552 }
7553 saveEditorSelection(root);
7554 selectEditorMarker(root, null);
7555 selectEditorMissingMedia(root, null);
7556 }, true);
7557
7558 surface.addEventListener("keydown", e => {
7559 if (e.key !== "Delete" && e.key !== "Backspace") return;
7560 const marker = closestElement(e.target, ".dbx-cms-marker") || state(root).selectedMarker;
7561 if (marker && surface.contains(marker)) {
7562 e.preventDefault();
7563 e.stopPropagation();
7564 removeEditorMarker(root, marker);
7565 return;
7566 }
7567 const missingMedia = contextMissingMediaTarget(root, e.target) || state(root).selectedMissingMedia;
7568 if (missingMedia && surface.contains(missingMedia)) {
7569 e.preventDefault();
7570 e.stopPropagation();
7571 removeEditorMissingMedia(root, missingMedia);
7572 return;
7573 }
7574 const mod = inlineModTarget(root, e.target) || state(root).selectedModPlaceholder;
7575 if (mod && surface.contains(mod)) {
7576 e.preventDefault();
7577 e.stopPropagation();
7578 removeEditorModPlaceholder(root, mod);
7579 }
7580 }, true);
7581
7582 surface.addEventListener("contextmenu", e => {
7583 if (!surface.contains(e.target)) return;
7584 e.preventDefault();
7585 e.stopPropagation();
7586 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7587 showEditorContextMenu(root, e);
7588 }, true);
7589 }
7590
7591 function bindEditorMarkerEventsRetry(root, attempt) {
7592 attempt = Number(attempt || 0);
7593 bindEditorMarkerEvents(root);
7594 const surface = editorSurface(root);
7595 if (surface && surface.__dbxCmsMarkerEventsBound) return;
7596 if (attempt < 20) window.setTimeout(() => bindEditorMarkerEventsRetry(root, attempt + 1), 100);
7597 }
7598
7599 function bindGlobalCmsEditorEvents() {
7600 if (document.__dbxCmsEditorGlobalEventsBound) return;
7601 document.__dbxCmsEditorGlobalEventsBound = true;
7602 if (document.documentElement) document.documentElement.setAttribute("data-dbx-cms-global-events", "1");
7603 document.addEventListener("pointerdown", e => {
7604 if (e.button !== 0) return;
7605 const marker = editorMarkerFromPointerEvent(e);
7606 const root = editorRootFromMarker(marker);
7607 if (!root) return;
7608 selectEditorMarker(root, marker);
7609 state(root).pointerDragMarker = {
7610 marker,
7611 startX: e.clientX,
7612 startY: e.clientY,
7613 dragging: false
7614 };
7615 e.stopPropagation();
7616 }, true);
7617 document.addEventListener("pointermove", e => {
7618 qsa(document, ".dbx-cms").forEach(currentRoot => {
7619 const drag = state(currentRoot).pointerDragMarker;
7620 if (!drag || !drag.marker) return;
7621 const dx = Math.abs(e.clientX - drag.startX);
7622 const dy = Math.abs(e.clientY - drag.startY);
7623 if (!drag.dragging && dx < 6 && dy < 6) return;
7624 if (!drag.dragging) {
7625 drag.dragging = true;
7626 const rect = drag.marker.getBoundingClientRect();
7627 drag.ghostOffsetX = drag.startX - rect.left;
7628 drag.ghostOffsetY = drag.startY - rect.top;
7629 drag.ghost = createMarkerDragGhost(drag.marker, rect);
7630 drag.marker.classList.add("is-dragging-source");
7631 }
7632 e.preventDefault();
7633 updateMarkerDragGhost(drag, e.clientX, e.clientY);
7634 showEditorMarkerDropHint(currentRoot, e.clientX, e.clientY, drag.marker);
7635 });
7636 }, true);
7637 document.addEventListener("pointerup", e => {
7638 qsa(document, ".dbx-cms").forEach(root => {
7639 const drag = state(root).pointerDragMarker;
7640 if (!drag || !drag.marker) return;
7641 if (drag.dragging) {
7642 e.preventDefault();
7643 e.stopPropagation();
7644 if (!moveEditorMarkerToPoint(root, drag.marker, e.clientX, e.clientY)) {
7645 clearMarkerPointerDrag(root);
7646 }
7647 } else {
7648 selectEditorMarker(root, drag.marker);
7649 clearMarkerPointerDrag(root);
7650 }
7651 });
7652 }, true);
7653 document.addEventListener("pointercancel", () => {
7654 qsa(document, ".dbx-cms").forEach(root => clearMarkerPointerDrag(root));
7655 }, true);
7656 document.addEventListener("keydown", e => {
7657 const key = String(e.key || "").toLowerCase();
7658 const targetRoot = closestElement(e.target, ".dbx-cms");
7659 if ((e.ctrlKey || e.metaKey) && key === "s" && targetRoot) {
7660 e.preventDefault();
7661 e.stopPropagation();
7662 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7663 saveCurrentCms(targetRoot, cmsConfig(targetRoot));
7664 return;
7665 }
7666 if (e.key !== "Delete" && e.key !== "Backspace") return;
7667 const root = targetRoot || editorRootFromMarker(closestElement(e.target, ".dbx-cms-marker"));
7668 const marker = root ? state(root).selectedMarker : null;
7669 const missingMedia = root ? state(root).selectedMissingMedia : null;
7670 const mod = root ? state(root).selectedModPlaceholder : null;
7671 const surface = root ? editorSurface(root) : null;
7672 if (marker && surface && surface.contains(marker)) {
7673 e.preventDefault();
7674 e.stopPropagation();
7675 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7676 removeEditorMarker(root, marker);
7677 return;
7678 }
7679 if (missingMedia && surface && surface.contains(missingMedia)) {
7680 e.preventDefault();
7681 e.stopPropagation();
7682 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7683 removeEditorMissingMedia(root, missingMedia);
7684 return;
7685 }
7686 if (mod && surface && surface.contains(mod)) {
7687 e.preventDefault();
7688 e.stopPropagation();
7689 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
7690 removeEditorModPlaceholder(root, mod);
7691 }
7692 }, true);
7693 }
7694
7695 function insertEditorHtml(root, html) {
7696 const instance = getEditorInstance(root);
7697 if (instance && typeof instance.setMode === "function") {
7698 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
7699 }
7700 hideEditorCaretHint(root);
7701 restoreEditorSelection(root);
7702 if (instance && instance.s && instance.s.insertHTML) {
7703 instance.s.insertHTML(html);
7704 normalizeEditorMarkers(root);
7705 normalizeBootstrapComponents(editorSurface(root));
7706 bindEditorMarkerEventsRetry(root);
7707 syncEditorDom(root);
7708 saveEditorSelection(root);
7709 return;
7710 }
7711 document.execCommand("insertHTML", false, html);
7712 normalizeEditorMarkers(root);
7713 normalizeBootstrapComponents(editorSurface(root));
7714 bindEditorMarkerEventsRetry(root);
7715 syncEditorDom(root);
7716 saveEditorSelection(root);
7717 }
7718
7719 function bootstrapComponentItems() {
7720 const openWinData = "lib=openWin|title=Information|width=900|height=80%|position=center-top|reload=1|minimizable=1|maximizable=1";
7721 return [
7722 {
7723 label: "Hinweis / Alert",
7724 html: '<div class="alert alert-info" role="alert"><h4 class="alert-heading">Hinweis</h4><p>Kurzer Hinweistext fuer den Inhalt.</p></div><p></p>'
7725 },
7726 {
7727 label: "Card",
7728 html: '<div class="card"><div class="card-body"><h3 class="card-title">Karten-Titel</h3><p class="card-text">Kurzer Text fuer diese Karte.</p><a class="btn btn-primary" href="#">Mehr erfahren</a></div></div><p></p>'
7729 },
7730 {
7731 label: "3 Karten",
7732 html: '<div class="row row-cols-1 row-cols-md-3 g-3"><div class="col"><div class="card h-100"><div class="card-body"><h3 class="card-title">Erste Karte</h3><p class="card-text">Kurzer Text.</p></div></div></div><div class="col"><div class="card h-100"><div class="card-body"><h3 class="card-title">Zweite Karte</h3><p class="card-text">Kurzer Text.</p></div></div></div><div class="col"><div class="card h-100"><div class="card-body"><h3 class="card-title">Dritte Karte</h3><p class="card-text">Kurzer Text.</p></div></div></div></div><p></p>'
7733 },
7734 {
7735 label: "List Group",
7736 html: '<div class="list-group"><a class="list-group-item list-group-item-action active" href="#">Aktiver Punkt</a><a class="list-group-item list-group-item-action" href="#">Weiterer Punkt</a><a class="list-group-item list-group-item-action" href="#">Dritter Punkt</a></div><p></p>'
7737 },
7738 {
7739 label: "CTA Button",
7740 html: '<p><a class="btn btn-primary" href="#">Informationen anfragen</a> <a class="btn btn-outline-primary" href="#">Mehr erfahren</a></p><p></p>'
7741 },
7742 {
7743 label: "openWin Link",
7744 html: '<p><a class="btn btn-outline-primary dbx-win" href="kontakt" data-dbx="' + openWinData + '|url=kontakt">Im Fenster oeffnen</a></p><p></p>'
7745 },
7746 {
7747 label: "Accordion",
7748 html: '<div class="accordion" id="dbxCmsAccordion"><div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#dbxCmsAccordionOne" aria-expanded="true" aria-controls="dbxCmsAccordionOne">Erster Bereich</button></h3><div id="dbxCmsAccordionOne" class="accordion-collapse collapse show" data-bs-parent="#dbxCmsAccordion"><div class="accordion-body">Inhalt des ersten Bereichs.</div></div></div><div class="accordion-item"><h3 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#dbxCmsAccordionTwo" aria-expanded="false" aria-controls="dbxCmsAccordionTwo">Zweiter Bereich</button></h3><div id="dbxCmsAccordionTwo" class="accordion-collapse collapse" data-bs-parent="#dbxCmsAccordion"><div class="accordion-body">Inhalt des zweiten Bereichs.</div></div></div></div><p></p>'
7749 },
7750 {
7751 label: "Tabs",
7752 html: '<div><ul class="nav nav-tabs" role="tablist"><li class="nav-item" role="presentation"><button class="nav-link active" type="button" data-bs-toggle="tab" data-bs-target="#dbxCmsTabOne" role="tab">Tab 1</button></li><li class="nav-item" role="presentation"><button class="nav-link" type="button" data-bs-toggle="tab" data-bs-target="#dbxCmsTabTwo" role="tab">Tab 2</button></li></ul><div class="tab-content border border-top-0 p-3"><div class="tab-pane fade show active" id="dbxCmsTabOne" role="tabpanel">Inhalt Tab 1.</div><div class="tab-pane fade" id="dbxCmsTabTwo" role="tabpanel">Inhalt Tab 2.</div></div></div><p></p>'
7753 },
7754 {
7755 label: "Tabelle",
7756 html: '<div class="table-responsive"><table class="table table-striped table-hover align-middle"><thead><tr><th>Spalte 1</th><th>Spalte 2</th><th>Spalte 3</th></tr></thead><tbody><tr><td>Wert</td><td>Wert</td><td>Wert</td></tr><tr><td>Wert</td><td>Wert</td><td>Wert</td></tr></tbody></table></div><p></p>'
7757 }
7758 ];
7759 }
7760
7761 function cmsBootstrapComponentIcon() {
7762 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M7 8h10M7 12h4M13 12h4M7 16h10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
7763 }
7764
7765 function insertBootstrapComponent(root, html) {
7766 const uid = "dbxCms" + Date.now().toString(36) + Math.floor(Math.random() * 1000).toString(36);
7767 html = String(html || "")
7768 .replaceAll("dbxCmsAccordion", uid + "Accordion")
7769 .replaceAll("dbxCmsAccordionOne", uid + "AccordionOne")
7770 .replaceAll("dbxCmsAccordionTwo", uid + "AccordionTwo")
7771 .replaceAll("dbxCmsTabOne", uid + "TabOne")
7772 .replaceAll("dbxCmsTabTwo", uid + "TabTwo");
7773 insertEditorFragment(root, html);
7774 normalizeEditorMarkers(root);
7775 normalizeBootstrapComponents(editorSurface(root));
7776 syncEditorDom(root);
7777 markDirty(root);
7778 scheduleEditorHeight(root);
7779 }
7780
7781 function normalizeBootstrapComponents(surface) {
7782 if (!surface) return;
7783 const doc = surface.ownerDocument || document;
7784 qsa(surface, ".card").forEach(card => {
7785 let body = Array.from(card.children || []).find(child => child.classList && child.classList.contains("card-body"));
7786 if (!body) {
7787 body = doc.createElement("div");
7788 body.className = "card-body";
7789 const insertAfter = Array.from(card.children || []).reverse().find(child => {
7790 if (!child.classList) return false;
7791 return child.classList.contains("card-img-top")
7792 || child.classList.contains("card-img")
7793 || child.classList.contains("card-header")
7794 || child.classList.contains("badge")
7795 || child.classList.contains("position-absolute");
7796 });
7797 card.insertBefore(body, insertAfter ? insertAfter.nextSibling : card.firstChild);
7798 }
7799
7800 Array.from(card.childNodes || []).forEach(node => {
7801 if (node === body) return;
7802 if (node.nodeType === 3) {
7803 if (String(node.nodeValue || "").trim() === "") return;
7804 body.appendChild(node);
7805 return;
7806 }
7807 if (node.nodeType !== 1) return;
7808 if (node.classList && (
7809 node.classList.contains("card-body")
7810 || node.classList.contains("card-img-top")
7811 || node.classList.contains("card-img")
7812 || node.classList.contains("card-header")
7813 || node.classList.contains("card-footer")
7814 || node.classList.contains("list-group")
7815 || node.classList.contains("dbx-cms-inline-media")
7816 || node.classList.contains("dbx-media")
7817 || node.classList.contains("position-absolute")
7818 )) return;
7819 if (isEmptyEditorBlock(node)) {
7820 node.remove();
7821 return;
7822 }
7823 if (/^(H[1-6]|P|A|BUTTON|UL|OL|DIV|SPAN|SMALL|STRONG|EM)$/i.test(node.tagName || "")) {
7824 body.appendChild(node);
7825 }
7826 });
7827
7828 qsa(card, ".position-absolute.badge, .position-absolute .badge, .badge.position-absolute").forEach(badge => {
7829 if (!card.contains(badge)) return;
7830 badge.setAttribute("contenteditable", "false");
7831 Array.from(badge.children || []).forEach(child => {
7832 if (/^(H[1-6]|P|DIV|A|BUTTON|UL|OL)$/i.test(child.tagName || "")) body.appendChild(child);
7833 });
7834 });
7835
7836 qsa(card, "img.card-img-top,img.card-img,img.card-img-bottom").forEach(img => {
7837 img.setAttribute("contenteditable", "false");
7838 img.setAttribute("draggable", "false");
7839 });
7840
7841 qsa(body, "p,div").forEach(block => {
7842 if (block === body) return;
7843 if (!isEmptyEditorBlock(block)) return;
7844 block.remove();
7845 });
7846
7847 if (!nodeHasEditorContent(body)) {
7848 const p = doc.createElement("p");
7849 p.innerHTML = "<br>";
7850 body.appendChild(p);
7851 }
7852 });
7853 }
7854
7855 function isEmptyEditorBlock(block) {
7856 if (!block || block.nodeType !== 1) return false;
7857 if (!/^(P|DIV)$/i.test(block.tagName || "")) return false;
7858 if (block.querySelector("img,video,iframe,object,embed,table,hr,ul,ol,.dbx-cms-marker,.dbx-cms-inline-media")) return false;
7859 return String(block.textContent || "").replace(/\u00a0/g, " ").replace(/\uFEFF/g, "").trim() === "";
7860 }
7861
7862 function editableCardBlockFromSelection(surface) {
7863 if (!surface) return null;
7864 const doc = surface.ownerDocument || document;
7865 const sel = doc.getSelection ? doc.getSelection() : null;
7866 if (!sel || !sel.rangeCount) return null;
7867 const range = sel.getRangeAt(0);
7868 if (!range.collapsed || !surface.contains(range.commonAncestorContainer)) return null;
7869 const el = nodeElement(range.startContainer);
7870 const body = closestElement(el, ".card-body");
7871 if (!body || !surface.contains(body)) return null;
7872 const block = closestElement(el, "p,div,h1,h2,h3,h4,h5,h6,li") || body;
7873 if (!body.contains(block)) return null;
7874 return { body, block, range };
7875 }
7876
7877 function rangeIsAtElementBoundary(range, element, boundary) {
7878 if (!range || !element) return false;
7879 const probe = range.cloneRange();
7880 if (boundary === "start") {
7881 probe.selectNodeContents(element);
7882 probe.setEnd(range.startContainer, range.startOffset);
7883 } else {
7884 probe.selectNodeContents(element);
7885 probe.setStart(range.startContainer, range.startOffset);
7886 }
7887 return String(probe.toString() || "").replace(/\u00a0/g, " ").replace(/\uFEFF/g, "").trim() === "";
7888 }
7889
7890 function firstEditableCardBodyBlock(body) {
7891 return Array.from(body.children || []).find(child => {
7892 if (!/^(P|DIV|H1|H2|H3|H4|H5|H6|UL|OL|LI|A|BUTTON)$/i.test(child.tagName || "")) return false;
7893 return nodeHasEditorContent(child);
7894 }) || null;
7895 }
7896
7897 function lastEditableCardBodyBlock(body) {
7898 return Array.from(body.children || []).reverse().find(child => {
7899 if (!/^(P|DIV|H1|H2|H3|H4|H5|H6|UL|OL|LI|A|BUTTON)$/i.test(child.tagName || "")) return false;
7900 return nodeHasEditorContent(child);
7901 }) || null;
7902 }
7903
7904 function setEditorCaretInCardBody(root, body) {
7905 const first = firstEditableCardBodyBlock(body) || body;
7906 return setEditorCaretInElement(root, first, 0);
7907 }
7908
7909 function handleBootstrapCardDeleteKey(root, event) {
7910 const surface = editorSurface(root);
7911 const ctx = editableCardBlockFromSelection(surface);
7912 if (!ctx) return false;
7913 const key = String(event.key || "");
7914 if (key !== "Backspace" && key !== "Delete") return false;
7915 const { body, block, range } = ctx;
7916
7917 if (block !== body && isEmptyEditorBlock(block)) {
7918 event.preventDefault();
7919 event.stopPropagation();
7920 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
7921 const next = block.nextElementSibling;
7922 const prev = block.previousElementSibling;
7923 block.remove();
7924 const target = key === "Delete" ? (next || prev || body) : (prev || next || body);
7925 setEditorCaretInElement(root, target, 0);
7926 normalizeBootstrapComponents(surface);
7927 syncEditorDom(root);
7928 return true;
7929 }
7930
7931 const first = firstEditableCardBodyBlock(body);
7932 if (key === "Backspace" && first && (block === first || first.contains(block)) && rangeIsAtElementBoundary(range, block, "start")) {
7933 event.preventDefault();
7934 event.stopPropagation();
7935 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
7936 setEditorCaretInCardBody(root, body);
7937 return true;
7938 }
7939
7940 const last = lastEditableCardBodyBlock(body);
7941 if (key === "Delete" && last && (block === last || last.contains(block)) && rangeIsAtElementBoundary(range, block, "end")) {
7942 event.preventDefault();
7943 event.stopPropagation();
7944 if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
7945 return true;
7946 }
7947 return false;
7948 }
7949
7950 function bindBootstrapCardEditingGuards(root) {
7951 if (!root) return;
7952 const surface = editorSurface(root);
7953 if (!surface || surface.__dbxCmsBootstrapCardGuardsBound) return;
7954 surface.__dbxCmsBootstrapCardGuardsBound = true;
7955 surface.addEventListener("keydown", event => {
7956 handleBootstrapCardDeleteKey(root, event);
7957 }, true);
7958 surface.addEventListener("input", () => {
7959 window.requestAnimationFrame(() => {
7960 normalizeBootstrapComponents(surface);
7961 });
7962 }, true);
7963 }
7964
7965 function insertEditorFragment(root, html) {
7966 const surface = editorSurface(root);
7967 if (!surface) return false;
7968 const doc = surface.ownerDocument || document;
7969 const tpl = doc.createElement("template");
7970 tpl.innerHTML = String(html || "");
7971 if (!tpl.content || !tpl.content.childNodes.length) return false;
7972
7973 const instance = getEditorInstance(root);
7974 if (instance && typeof instance.setMode === "function") {
7975 instance.setMode(window.Jodit && window.Jodit.MODE_WYSIWYG ? window.Jodit.MODE_WYSIWYG : 1);
7976 }
7977 hideEditorCaretHint(root);
7978 restoreEditorSelection(root);
7979 if (surface.focus) surface.focus({ preventScroll: true });
7980
7981 const sel = doc.getSelection ? doc.getSelection() : null;
7982 let range = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
7983 if (!range || !surface.contains(range.commonAncestorContainer)) {
7984 range = doc.createRange();
7985 range.selectNodeContents(surface);
7986 range.collapse(false);
7987 }
7988
7989 const nodes = Array.from(tpl.content.childNodes);
7990 range.deleteContents();
7991 range.insertNode(tpl.content);
7992
7993 normalizeEditorMarkers(root);
7994 normalizeBootstrapComponents(surface);
7995 bindEditorMarkerEventsRetry(root);
7996 syncEditorDom(root);
7997 setEditorCaretAfterNode(root, nodes[nodes.length - 1]);
7998 saveEditorSelection(root);
7999 return true;
8000 }
8001
8002 function execEditorCommand(root, command) {
8003 const instance = getEditorInstance(root);
8004 if (instance && instance.execCommand) {
8005 instance.execCommand(command);
8006 setField(root, "content", instance.value || "");
8007 markDirty(root);
8008 scheduleEditorHeight(root);
8009 return;
8010 }
8011 document.execCommand(command, false, null);
8012 markDirty(root);
8013 scheduleEditorHeight(root);
8014 }
8015
8016 function serializeCmsMarkers(html) {
8017 const box = document.createElement("div");
8018 box.innerHTML = html || "";
8019 normalizeCommentMarkers(box);
8020 cleanEditorRuntimeNodes(box);
8021 normalizePlainTextMarkers(box);
8022 qsa(box, ".dbx-cms-marker[data-dbx-marker-comment]").forEach(marker => {
8023 const markerText = marker.getAttribute("data-dbx-marker-comment") || "";
8024 const name = cmsMarkerName(markerText);
8025 const hr = document.createElement("hr");
8026 hr.className = "dbx-cms-marker dbx-cms-marker-" + cmsMarkerClassName(name);
8027 hr.setAttribute("data-dbx-marker", "dbx:" + name);
8028 hr.setAttribute("data-label", marker.textContent || markerText);
8029 hr.setAttribute("contenteditable", "false");
8030 hr.setAttribute("draggable", "false");
8031 hr.setAttribute("tabindex", "0");
8032 marker.replaceWith(hr);
8033 });
8034 qsa(box, ".dbx-cms-marker,[data-dbx-marker]").forEach(marker => {
8035 const name = cmsMarkerName(marker.getAttribute("data-dbx-marker") || "");
8036 if (marker.tagName !== "HR") {
8037 const hr = document.createElement("hr");
8038 Array.from(marker.attributes || []).forEach(attr => {
8039 if (attr.name === "data-dbx-marker-comment") return;
8040 hr.setAttribute(attr.name, attr.value);
8041 });
8042 marker.replaceWith(hr);
8043 marker = hr;
8044 }
8045 marker.classList.remove("is-selected", "is-dragging", "is-drop-before", "is-drop-after");
8046 marker.classList.add("dbx-cms-marker", "dbx-cms-marker-" + cmsMarkerClassName(name));
8047 marker.removeAttribute("data-cms-drag-token");
8048 marker.setAttribute("data-dbx-marker", "dbx:" + name);
8049 if (!marker.getAttribute("data-label")) marker.setAttribute("data-label", cmsMarkerLabel("dbx:" + name));
8050 marker.setAttribute("contenteditable", "false");
8051 marker.setAttribute("draggable", "false");
8052 marker.setAttribute("tabindex", "0");
8053 });
8054 dedupeAdjacentMarkers(box);
8055 return box.innerHTML;
8056 }
8057
8058 function folderTreeEditButton(id) {
8059 return `<button type="button" class="dbx-cms-tree-edit" data-cms-folder-edit-btn data-id="${escapeHtml(id)}" title="Ordner bearbeiten" aria-label="Ordner bearbeiten"><i class="bi bi-pencil-square"></i></button>`;
8060 }
8061
8062 function bindTreeSearchClear(root, input) {
8063 if (!input || input.__dbxCmsTreeSearchClearBound) return;
8064 const wrap = input.closest(".dbx-input-clearable");
8065 if (!wrap) return;
8066
8067 input.__dbxCmsTreeSearchClearBound = true;
8068 wrap.classList.add("dbx-cms-tree-search-wrap");
8069
8070 let btn = wrap.querySelector(".dbx-clear-btn");
8071 if (!btn) {
8072 btn = document.createElement("button");
8073 btn.type = "button";
8074 btn.className = "dbx-clear-btn";
8075 btn.setAttribute("aria-label", "Suche leeren");
8076 btn.setAttribute("tabindex", "-1");
8077 btn.innerHTML = "&times;";
8078 wrap.appendChild(btn);
8079 }
8080 btn.classList.add("dbx-cms-tree-search-clear");
8081
8082 const sync = () => {
8083 if (String(input.value || "").length > 0) {
8084 btn.removeAttribute("hidden");
8085 } else {
8086 btn.setAttribute("hidden", "");
8087 }
8088 };
8089
8090 input.addEventListener("input", sync);
8091 btn.addEventListener("click", e => {
8092 e.preventDefault();
8093 input.value = "";
8094 input.dispatchEvent(new Event("input", { bubbles: true }));
8095 input.dispatchEvent(new Event("change", { bubbles: true }));
8096 input.focus();
8097 renderTree(root);
8098 sync();
8099 });
8100 sync();
8101 }
8102
8103 function renderTree(root) {
8104 const s = state(root);
8105 const box = qs(root, "[data-cms-tree]");
8106 const searchRaw = String(qs(root, "[data-cms-search]")?.value || "").trim();
8107 const search = searchRaw.toLowerCase();
8108 const searchIsNumeric = /^\d+$/.test(searchRaw);
8109 if (!box) return;
8110
8111 function treeSearchText(node) {
8112 const rights = root.classList.contains("dbx-cms-view") ? "" : String(node._rights || "");
8113 return [
8114 String(node._id || ""),
8115 String(node._title || ""),
8116 rights
8117 ].join(" ").toLowerCase();
8118 }
8119
8120 function nodeMatches(node) {
8121 if (!search) return true;
8122 if (searchIsNumeric && String(node._id || "") === searchRaw) return true;
8123 return treeSearchText(node).includes(search);
8124 }
8125
8126 function hasSearchMatch(node) {
8127 if (!search) return true;
8128 if (nodeMatches(node)) return true;
8129 const children = Array.isArray(node._children) ? node._children : [];
8130 return children.some(hasSearchMatch);
8131 }
8132
8133 function searchClass(node, children) {
8134 if (!search) return "";
8135 const exactId = searchIsNumeric && String(node._id || "") === searchRaw;
8136 if (exactId) return " is-search-match is-search-id-match";
8137 if (nodeMatches(node)) return " is-search-match";
8138 if (children.some(hasSearchMatch)) return " is-search-path";
8139 return "";
8140 }
8141
8142 function renderNode(node) {
8143 const children = Array.isArray(node._children) ? node._children : [];
8144 if (search && !hasSearchMatch(node)) return "";
8145 const type = node._type === "folder" ? "folder" : "page";
8146 const icon = type === "folder" ? "bi-folder2-open" : "bi-file-earmark-text";
8147 const active =
8148 (type === "page" && s.selectedType !== "folder" && Number(node._id) === Number(s.selectedPage)) ||
8149 (type === "folder" && s.selectedType === "folder" && Number(node._id) === Number(s.selectedFolder))
8150 ? " is-active"
8151 : "";
8152 const matchedClass = searchClass(node, children);
8153 const collapsed = type === "folder" && children.length ? isFolderCollapsed(root, node._id, search) : false;
8154 const expandedAttr = collapsed ? "false" : "true";
8155 const toggleClass = collapsed ? "bi-chevron-right" : "bi-chevron-down";
8156 const collapsedClass = collapsed ? " is-collapsed" : "";
8157 const editBtn = type === "folder" ? folderTreeEditButton(node._id) : "";
8158 const toggle = type === "folder" && children.length
8159 ? `<span class="dbx-cms-tree-toggle" data-cms-tree-toggle data-id="${escapeHtml(node._id)}" title="Ordner ein- oder ausklappen"><i class="bi ${toggleClass}"></i></span>`
8160 : `<span class="dbx-cms-tree-toggle-spacer"></span>`;
8161 if (node._row_html) {
8162 let html = String(node._row_html);
8163 if (active && !html.includes(" is-active")) {
8164 html = html.replace("dbx-cms-tree-row", "dbx-cms-tree-row is-active");
8165 }
8166 if (matchedClass) {
8167 html = html.replace(/\bdbx-cms-tree-row\b/, "dbx-cms-tree-row" + matchedClass);
8168 }
8169 if (!html.includes("data-cms-tree-toggle") && !html.includes("dbx-cms-tree-toggle-spacer")) {
8170 html = html.replace('<span class="dbx-cms-tree-icons">', toggle + '<span class="dbx-cms-tree-icons">');
8171 }
8172 if (type === "folder") {
8173 html = html.replace(/\bdbx-cms-tree-row\b/, "dbx-cms-tree-row" + collapsedClass);
8174 const rowAria = /(<div role="button"[^>]*?)\saria-expanded="[^"]*"/;
8175 if (rowAria.test(html)) {
8176 html = html.replace(rowAria, '$1 aria-expanded="' + expandedAttr + '"');
8177 } else {
8178 html = html.replace('<div role="button"', '<div role="button" aria-expanded="' + expandedAttr + '"');
8179 }
8180 if (!html.includes("data-cms-folder-edit-btn")) {
8181 if (html.includes('class="dbx-cms-lng-badges"')) {
8182 html = html.replace(/<span class="dbx-cms-lng-badges"/, editBtn + '<span class="dbx-cms-lng-badges"');
8183 } else {
8184 html = html.replace(/(<span class="dbx-cms-tree-meta">)([\s\S]*?)(<\/span>)/, '$1$2' + editBtn + '$3');
8185 }
8186 }
8187 }
8188 if (children.length) {
8189 html += `<div class="dbx-cms-tree-children"${collapsed ? " hidden" : ""}>`;
8190 children.forEach(child => {
8191 if (!search || hasSearchMatch(child)) html += renderNode(child);
8192 });
8193 html += `</div>`;
8194 }
8195 return html;
8196 }
8197 const rights = type === "folder" && node._rights ? `<span class="dbx-cms-rights" data-cms-folder-edit title="Ordnerrechte bearbeiten">${escapeHtml(node._rights)}</span>` : "";
8198 const lngBadges = node._lng_badges ? String(node._lng_badges) : "";
8199 const meta = `${rights}${editBtn}${lngBadges}`;
8200 const title = type === "page"
8201 ? `<span class="dbx-cms-page-id">(${escapeHtml(node._id)})</span> ${escapeHtml(node._title)}`
8202 : escapeHtml(node._title);
8203
8204 let html = "";
8205 if (!search || hasSearchMatch(node)) {
8206 html += `<div role="button" tabindex="0" draggable="true" class="dbx-cms-tree-row dbx-cms-tree-${type}${active}${matchedClass}${collapsedClass}" data-type="${type}" data-id="${escapeHtml(node._id)}" data-folder="${escapeHtml(node._parent || node._id)}"${type === "folder" ? ` aria-expanded="${expandedAttr}"` : ""}>`;
8207 html += toggle;
8208 html += `<span class="dbx-cms-tree-icons"><i class="bi ${icon}"></i>`;
8209 html += `</span><span class="dbx-cms-tree-label">${title}</span><span class="dbx-cms-tree-meta">${meta}</span>`;
8210 html += `</div>`;
8211 }
8212
8213 if (children.length) {
8214 html += `<div class="dbx-cms-tree-children"${collapsed ? " hidden" : ""}>`;
8215 children.forEach(child => {
8216 if (!search || hasSearchMatch(child)) html += renderNode(child);
8217 });
8218 html += `</div>`;
8219 }
8220
8221 return html;
8222 }
8223
8224 if (!s.tree.length) {
8225 box.innerHTML = '<div class="dbx-cms-empty">Keine Content-Struktur vorhanden.</div>';
8226 return;
8227 }
8228
8229 box.innerHTML = s.tree.map(renderNode).join("");
8230 if (search) {
8231 const firstMatch = qs(box, ".dbx-cms-tree-row.is-search-id-match") || qs(box, ".dbx-cms-tree-row.is-search-match");
8232 if (firstMatch) {
8233 requestAnimationFrame(() => {
8234 firstMatch.scrollIntoView({ block: "center", inline: "nearest" });
8235 });
8236 }
8237 }
8238 }
8239
8240 function toggleTreeFolder(root, row, forceCollapsed) {
8241 if (!row) return;
8242 const id = Number(row.getAttribute("data-id") || 0);
8243 if (!id || row.getAttribute("data-type") !== "folder") return;
8244 const collapsed = typeof forceCollapsed === "boolean"
8245 ? forceCollapsed
8246 : !isFolderCollapsed(root, id, false);
8247 setFolderCollapsed(root, id, collapsed);
8248 renderTree(root);
8249 }
8250
8251 function loadTree(root, cfg) {
8252 const s = state(root);
8253 const url = cfgUrl(cfg, "tree");
8254 const box = qs(root, "[data-cms-tree]");
8255 if (!url) {
8256 if (box) box.innerHTML = '<div class="dbx-cms-empty">Tree-URL fehlt.</div>';
8257 return Promise.resolve();
8258 }
8259 if (box) box.innerHTML = '<div class="dbx-cms-empty">Tree wird geladen...</div>';
8260
8261 return fetchJson(apiUrl(url, cmsLngParams(root)))
8262 .then(data => {
8263 s.tree = Array.isArray(data.nodes) ? data.nodes : [];
8264 s.flat = Array.isArray(data.flat) ? data.flat : [];
8265 renderTree(root);
8266
8267 if (!s.selectionRestored) {
8268 s.selectionRestored = true;
8269 if (isViewMode(cfg)) {
8270 const requestedPage = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
8271 if (requestedPage > 0) {
8272 if (root.getAttribute("data-cms-initial-page-loaded") === "1") {
8273 const node = s.flat.find(n => n._type === "page" && Number(n._id) === requestedPage) || {};
8274 s.page = { id: requestedPage, title: node._title || "" };
8275 setSelectedPage(root, requestedPage);
8276 setSelectedType(root, "page");
8277 updateViewPageTitle(root, node._title || "");
8278 root.removeAttribute("data-cms-initial-page-loaded");
8279 revealTreeSelection(root);
8280 return;
8281 }
8282 return loadViewPage(root, cfg, requestedPage);
8283 }
8284 if (s.selectedPage > 0) {
8285 return loadViewPage(root, cfg, s.selectedPage);
8286 }
8287 const firstViewPage = s.flat.find(n => n._type === "page");
8288 if (firstViewPage) return loadViewPage(root, cfg, firstViewPage._id);
8289 return;
8290 }
8291 const requestedEditPage = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
8292 if (requestedEditPage > 0) {
8293 s.selectedPage = requestedEditPage;
8294 s.selectedType = "page";
8295 setSelectedPage(root, requestedEditPage);
8296 setSelectedType(root, "page");
8297 return loadPage(root, cfg, requestedEditPage).then(() => revealTreeSelection(root));
8298 }
8299 if (s.selectedType === "folder" && s.selectedFolder) {
8300 const folder = findNode(root, "folder", s.selectedFolder);
8301 if (folder) {
8302 showFolderEditor(root, folder);
8303 loadMedia(root, cfg);
8304 revealTreeSelection(root);
8305 return;
8306 }
8307 }
8308 if (s.selectedPage > 0) {
8309 return loadPage(root, cfg, s.selectedPage);
8310 }
8311 const firstPage = s.flat.find(n => n._type === "page");
8312 if (firstPage) return loadPage(root, cfg, firstPage._id);
8313 }
8314 })
8315 .catch(err => {
8316 dbx.error("[cms] tree load failed", err);
8317 if (box) {
8318 box.innerHTML = '<div class="dbx-cms-empty">Tree konnte nicht geladen werden.</div>';
8319 }
8320 status(root, "Tree konnte nicht geladen werden.", "error");
8321 });
8322 }
8323
8324 function setSelectValues(select, value) {
8325 if (!select) return;
8326 const values = String(value || "").split(",").map(v => v.trim()).filter(Boolean);
8327 Array.from(select.options).forEach(option => { option.selected = values.includes(option.value); });
8328 syncCmsSelect(select);
8329 select.dispatchEvent(new Event("change", { bubbles: true }));
8330 }
8331
8332 function getSelectValues(select) {
8333 if (!select) return "";
8334 return Array.from(select.selectedOptions).map(option => option.value).join(",");
8335 }
8336
8337 function setFolderField(root, name, value) {
8338 const el = qs(root, `[data-cms-folder-field="${name}"]`);
8339 if (!el) return;
8340 el.value = value == null ? "" : String(value);
8341 syncCmsSelect(el);
8342 if (name === "hero_image_id") renderHeroPreview(root);
8343 if (name === "seo_image_id") renderSeoPreview(root);
8344 }
8345
8346 function getFolderField(root, name) {
8347 const el = qs(root, `[data-cms-folder-field="${name}"]`);
8348 return el ? el.value : "";
8349 }
8350
8351 function findNode(root, type, id) {
8352 const list = state(root).flat || [];
8353 return list.find(node => node._type === type && Number(node._id) === Number(id)) || null;
8354 }
8355
8356 function buildParentOptions(root, currentId, selectedParent) {
8357 const s = state(root);
8358 const select = qs(root, '[data-cms-folder-field="parent_id"]');
8359 if (!select) return;
8360
8361 const forbidden = new Set();
8362 function markForbidden(node) {
8363 if (!node || node._type !== "folder") return;
8364 forbidden.add(Number(node._id));
8365 (node._children || []).forEach(markForbidden);
8366 }
8367 if (currentId > 0) markForbidden(findNode(root, "folder", currentId));
8368
8369 let html = '<option value="0">Root / erste Ebene</option>';
8370 function walk(nodes, depth) {
8371 (nodes || []).forEach(node => {
8372 if (node._type !== "folder") return;
8373 const id = Number(node._id || 0);
8374 if (!forbidden.has(id)) {
8375 const pad = depth > 0 ? Array(depth + 1).join("-- ") : "";
8376 html += `<option value="${escapeHtml(id)}">${escapeHtml(pad + (node._title || "Ordner " + id))}</option>`;
8377 }
8378 walk(node._children || [], depth + 1);
8379 });
8380 }
8381 walk(s.tree || [], 0);
8382 select.innerHTML = html;
8383 select.value = String(selectedParent || 0);
8384 syncCmsSelect(select);
8385 }
8386
8387 function showFolderEditor(root, folder, anchor) {
8388 const panel = qs(root, "[data-cms-folder-editor]");
8389 const title = qs(root, "[data-cms-folder-title]");
8390 const rights = qs(root, "[data-cms-folder-rights]");
8391 const s = state(root);
8392
8393 s.folder = folder || null;
8394 if (!panel || !folder) return;
8395 s.mediaRows = [];
8396 s.heroPreviewRow = null;
8397 root.classList.add("is-folder-editing");
8398 updateHeaderActionTooltips(root);
8399
8400 const id = Number(folder._id || folder.id || 0);
8401 const parentId = Number(folder._parent ?? folder.parent_id ?? s.selectedFolder ?? 0);
8402 const name = folder._title || folder.name || "";
8403 const template = folder._template || folder.template || "";
8404 const folderRights = folder._rights || folder.group_read || (parentId > 0 ? "parent" : "*");
8405 const settingFields = ["hero_template", "hero_image_id", "hero_margin_top", "hero_height", "hero_variant", "hero_sticky", "hero_scroll_layer"];
8406 updateCurrentSelectionTitle(root, "folder", id, name);
8407
8408 panel.hidden = false;
8409 panel.classList.toggle("is-new", id <= 0);
8410 if (title) title.textContent = id > 0 ? "Ordner bearbeiten" : "Neuen Ordner anlegen";
8411 setFolderField(root, "id", id);
8412 setFolderField(root, "name", name);
8413 buildParentOptions(root, id, parentId);
8414 setFolderField(root, "template", template);
8415 setSelectValues(rights, folderRights);
8416 settingFields.forEach(key => {
8417 const value = folder["_" + key] || folder[key] || "parent";
8418 setFolderField(root, key, value);
8419 setField(root, key, value);
8420 });
8421 renderHeroPreview(root);
8422 const deleteButton = qs(panel, "[data-cms-folder-delete]");
8423 if (deleteButton) deleteButton.hidden = id <= 0;
8424
8425 panel.style.removeProperty("left");
8426 panel.style.removeProperty("top");
8427
8428 if (typeof window.multiselect2 === "function") {
8429 window.multiselect2(rights && (rights.id || rights.name) ? (rights.id || rights.name) : "group_read");
8430 }
8431
8432 const first = qs(panel, '[data-cms-folder-field="name"]');
8433 if (first) window.setTimeout(() => first.focus(), 20);
8434 }
8435
8436 function hideFolderEditor(root) {
8437 const panel = qs(root, "[data-cms-folder-editor]");
8438 if (panel) panel.hidden = true;
8439 root.classList.remove("is-folder-editing");
8440 updateHeaderActionTooltips(root);
8441 const pageTitle = getField(root, "title");
8442 updateCurrentSelectionTitle(root, "page", getField(root, "id"), pageTitle);
8443 }
8444
8445 function saveFolder(root, cfg) {
8446 const s = state(root);
8447 const folder = s.folder;
8448 const url = cfgUrl(cfg, "savefolder");
8449 if (s.saving) return Promise.resolve();
8450 if (!folder || !url) {
8451 status(root, "Bitte erst einen Ordner im Tree waehlen.", "error");
8452 return;
8453 }
8454
8455 const id = Number(getFolderField(root, "id") || 0);
8456 const name = getFolderField(root, "name").trim();
8457 if (!name) {
8458 status(root, "Bitte eine Ordner-Bezeichnung eintragen.", "error");
8459 return;
8460 }
8461
8462 const rights = getSelectValues(qs(root, "[data-cms-folder-rights]"));
8463 setSaving(root, true);
8464 return fetchJson(apiUrl(url, cmsLngParams(root)), {
8465 method: "POST",
8466 headers: { "Content-Type": "application/json" },
8467 body: JSON.stringify({
8468 id,
8469 name,
8470 parent_id: Number(getFolderField(root, "parent_id") || 0),
8471 template: getFolderField(root, "template"),
8472 group_read: rights,
8473 hero_template: getField(root, "hero_template") || getFolderField(root, "hero_template") || "parent",
8474 hero_image_id: getField(root, "hero_image_id") || getFolderField(root, "hero_image_id") || "parent",
8475 hero_margin_top: getField(root, "hero_margin_top") || getFolderField(root, "hero_margin_top") || "parent",
8476 hero_height: getField(root, "hero_height") || getFolderField(root, "hero_height") || "parent",
8477 hero_variant: getField(root, "hero_variant") || getFolderField(root, "hero_variant") || "parent",
8478 hero_sticky: getField(root, "hero_sticky") || getFolderField(root, "hero_sticky") || "parent",
8479 hero_scroll_layer: getField(root, "hero_scroll_layer") || getFolderField(root, "hero_scroll_layer") || "parent",
8480 })
8481 }).then(data => {
8482 if (!data || !data.ok) throw new Error("folder save failed");
8483 const synced = Number(data.lng_synced || 0);
8484 const saveStatus = formatLngSaveStatus(
8485 synced > 0 ? "Ordner gespeichert." : "Ordner gespeichert.",
8486 data
8487 );
8488 status(root, saveStatus.text, saveStatus.type);
8489 clearDirtyAfterSave(root);
8490 setSelectedFolder(root, data.id || id || 0);
8491 setSelectedType(root, "folder");
8492 return loadTree(root, cfg).then(() => {
8493 const fresh = findNode(root, "folder", data.id || id);
8494 if (fresh) {
8495 const mediaRows = (s.mediaRows || []).slice();
8496 const heroPreviewRow = s.heroPreviewRow || null;
8497 showFolderEditor(root, fresh);
8498 s.mediaRows = mediaRows;
8499 s.heroPreviewRow = heroPreviewRow;
8500 renderHeroPreview(root);
8501 renderMedia(root);
8502 revealTreeSelection(root);
8503 }
8504 maybeOpenLngProvisionAfterCreate(root, cfg, data);
8505 });
8506 }).catch(err => {
8507 dbx.error("[cms] folder save failed", err);
8508 status(root, "Ordner konnte nicht gespeichert werden.", "error");
8509 }).finally(() => {
8510 setSaving(root, false);
8511 clearCmsLoading(root);
8512 });
8513 }
8514
8515 function deleteFolder(root, cfg) {
8516 const id = Number(getFolderField(root, "id") || 0);
8517 const name = getFolderField(root, "name") || "Ordner";
8518 const url = cfgUrl(cfg, "deletefolder");
8519 if (!id || !url) {
8520 status(root, "Nur gespeicherte Ordner koennen geloescht werden.", "error");
8521 return;
8522 }
8523
8524 return openLngDeleteDialog(root, cfg, "folder", id, name).catch(err => {
8525 const msg = err && err.message ? err.message : "Ordner konnte nicht geloescht werden.";
8526 dbx.error("[cms] folder delete failed", err);
8527 status(root, msg, "error");
8528 });
8529 }
8530
8531 function moveNode(root, cfg, type, id, targetFolder, position) {
8532 const url = cfgUrl(cfg, "movenode");
8533 if (!url || !type || !id || targetFolder < 0) return Promise.resolve();
8534
8535 const payload = {
8536 type,
8537 id,
8538 target_folder: targetFolder
8539 };
8540 if (position && position.before_id) payload.before_id = position.before_id;
8541 if (position && position.after_id) payload.after_id = position.after_id;
8542
8543 return fetchJson(apiUrl(url), {
8544 method: "POST",
8545 headers: { "Content-Type": "application/json" },
8546 body: JSON.stringify(payload)
8547 }).then(data => {
8548 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "move failed");
8549 status(root, "Tree-Eintrag verschoben.", "success");
8550 return loadTree(root, cfg);
8551 }).catch(err => {
8552 dbx.error("[cms] move node failed", err);
8553 status(root, err && err.message ? err.message : "Tree-Eintrag konnte nicht verschoben werden.", "error");
8554 });
8555 }
8556
8557 function syncSelectTitle(el) {
8558 if (!el || el.tagName !== "SELECT") return;
8559 const selected = el.selectedOptions && el.selectedOptions.length ? el.selectedOptions[0] : null;
8560 el.title = selected ? selected.textContent.trim() : "";
8561 }
8562
8563 function selectedOptionText(select) {
8564 const selected = select && select.selectedOptions && select.selectedOptions.length ? select.selectedOptions[0] : null;
8565 return selected ? selected.textContent.trim() : "";
8566 }
8567
8568 function syncCmsSelect(select) {
8569 syncSelectTitle(select);
8570 if (select && typeof select.__dbxCmsSelectRender === "function") {
8571 select.__dbxCmsSelectRender();
8572 }
8573 }
8574
8575 function syncDetailsToggleIcon(details) {
8576 const icon = qs(details, ".dbx-cms-toggle-icon:not(.dbx-cms-toggle-icon-open):not(.dbx-cms-toggle-icon-closed)");
8577 if (icon) {
8578 icon.classList.toggle("bi-chevron-down", !!details.open);
8579 icon.classList.toggle("bi-chevron-right", !details.open);
8580 }
8581 const openIcon = qs(details, ".dbx-cms-toggle-icon-open");
8582 const closedIcon = qs(details, ".dbx-cms-toggle-icon-closed");
8583 if (openIcon) openIcon.style.display = details.open ? "inline-block" : "none";
8584 if (closedIcon) closedIcon.style.display = details.open ? "none" : "inline-block";
8585 }
8586
8587 function initDetailsToggles(root) {
8588 const saved = dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "openPanels", {}) : {};
8589 const openPanels = (saved && typeof saved === "object" && !Array.isArray(saved)) ? saved : {};
8590 qsa(root, ".dbx-cms-page-panel, .dbx-cms-settings-panel").forEach((details, index) => {
8591 const key = details.getAttribute("data-cms-ui-state") || ("panel-" + index);
8592 details.setAttribute("data-cms-ui-state", key);
8593 if (Object.prototype.hasOwnProperty.call(openPanels, key)) {
8594 details.open = !!openPanels[key];
8595 }
8596 syncDetailsToggleIcon(details);
8597 if (details.__dbxCmsToggleReady) return;
8598 details.__dbxCmsToggleReady = true;
8599 details.addEventListener("toggle", () => {
8600 syncDetailsToggleIcon(details);
8601 const state = dbx.uiGet ? dbx.uiGet(LIB, PANEL_UI_ID, "openPanels", {}) : {};
8602 const next = (state && typeof state === "object" && !Array.isArray(state)) ? state : {};
8603 next[key] = !!details.open;
8604 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "openPanels", next);
8605 });
8606 });
8607 }
8608
8609 function closeCmsSelects(root, except) {
8610 qsa(root, ".dbx-cms-select.is-open").forEach(wrapper => {
8611 if (wrapper === except) return;
8612 wrapper.classList.remove("is-open");
8613 const button = qs(wrapper, ".dbx-cms-select-control");
8614 const menu = qs(wrapper, ".dbx-cms-select-menu");
8615 if (button) button.setAttribute("aria-expanded", "false");
8616 if (menu) menu.hidden = true;
8617 });
8618 }
8619
8620 function buildCmsSelect(root, select) {
8621 if (!select || select.multiple || select.dataset.dbxCmsSelectReady === "1") return;
8622
8623 select.dataset.dbxCmsSelectReady = "1";
8624 select.classList.add("dbx-cms-select-source");
8625
8626 const wrapper = document.createElement("div");
8627 wrapper.className = "dbx-cms-select";
8628
8629 const button = document.createElement("button");
8630 button.type = "button";
8631 button.className = "dbx-cms-select-control";
8632 button.setAttribute("aria-haspopup", "listbox");
8633 button.setAttribute("aria-expanded", "false");
8634
8635 const value = document.createElement("span");
8636 value.className = "dbx-cms-select-value";
8637
8638 const icon = document.createElement("i");
8639 icon.className = "bi bi-chevron-down";
8640 icon.setAttribute("aria-hidden", "true");
8641
8642 const menu = document.createElement("div");
8643 menu.className = "dbx-cms-select-menu";
8644 menu.setAttribute("role", "listbox");
8645 menu.hidden = true;
8646
8647 button.appendChild(value);
8648 button.appendChild(icon);
8649 wrapper.appendChild(button);
8650 wrapper.appendChild(menu);
8651 select.insertAdjacentElement("afterend", wrapper);
8652
8653 function open() {
8654 closeCmsSelects(root, wrapper);
8655 wrapper.classList.add("is-open");
8656 button.setAttribute("aria-expanded", "true");
8657 menu.hidden = false;
8658 render();
8659 }
8660
8661 function close() {
8662 wrapper.classList.remove("is-open");
8663 button.setAttribute("aria-expanded", "false");
8664 menu.hidden = true;
8665 }
8666
8667 function render() {
8668 const text = selectedOptionText(select) || "Auswahl";
8669 value.textContent = text;
8670 button.title = text;
8671 button.disabled = select.disabled;
8672 menu.innerHTML = "";
8673
8674 Array.from(select.options).forEach(option => {
8675 if (option.disabled) return;
8676 const row = document.createElement("button");
8677 row.type = "button";
8678 row.className = "dbx-cms-select-option";
8679 row.dataset.value = option.value;
8680 row.setAttribute("role", "option");
8681 row.setAttribute("aria-selected", option.selected ? "true" : "false");
8682 if (option.selected) row.classList.add("is-selected");
8683
8684 const label = document.createElement("span");
8685 label.textContent = option.textContent.trim();
8686 const check = document.createElement("i");
8687 check.className = option.selected ? "bi bi-check2" : "bi";
8688 check.setAttribute("aria-hidden", "true");
8689 row.appendChild(label);
8690 row.appendChild(check);
8691
8692 row.addEventListener("click", event => {
8693 event.preventDefault();
8694 event.stopPropagation();
8695 select.value = option.value;
8696 syncSelectTitle(select);
8697 select.dispatchEvent(new Event("input", { bubbles: true }));
8698 select.dispatchEvent(new Event("change", { bubbles: true }));
8699 render();
8700 close();
8701 button.focus();
8702 });
8703
8704 menu.appendChild(row);
8705 });
8706
8707 syncSelectTitle(select);
8708 }
8709
8710 button.addEventListener("click", event => {
8711 event.preventDefault();
8712 event.stopPropagation();
8713 if (wrapper.classList.contains("is-open")) close();
8714 else open();
8715 });
8716 button.addEventListener("keydown", event => {
8717 if (event.key === "Escape") {
8718 close();
8719 return;
8720 }
8721 if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
8722 event.preventDefault();
8723 open();
8724 }
8725 });
8726 select.addEventListener("change", render);
8727 select.__dbxCmsSelectRender = render;
8728 render();
8729 }
8730
8731 function initCmsSelects(root) {
8732 qsa(root, "select[data-cms-field]:not([multiple]), select[data-cms-folder-field]:not([multiple])").forEach(select => {
8733 buildCmsSelect(root, select);
8734 });
8735 }
8736
8737 function setField(root, name, value) {
8738 const el = qs(root, `[data-cms-field="${name}"]`);
8739 if (!el) return;
8740 if (el.multiple) {
8741 const values = String(value == null ? "" : value).split(",").map(v => v.trim()).filter(Boolean);
8742 Array.from(el.options).forEach(opt => { opt.selected = values.includes(opt.value); });
8743 syncCmsSelect(el);
8744 return;
8745 }
8746 el.value = value == null ? "" : String(value);
8747 syncCmsSelect(el);
8748 }
8749
8750 function getField(root, name) {
8751 const el = qs(root, `[data-cms-field="${name}"]`);
8752 if (!el) return "";
8753 if (el.multiple) {
8754 return Array.from(el.selectedOptions).map(opt => opt.value).join(",");
8755 }
8756 return el.value;
8757 }
8758
8759 function loadViewPage(root, cfg, id, opt) {
8760 opt = opt || {};
8761 const s = state(root);
8762 const url = cfgUrl(cfg, "viewpage");
8763 const box = qs(root, "[data-cms-content-view]");
8764 if (!url || !id) return Promise.resolve();
8765 if (box) box.innerHTML = '<div class="dbx-cms-empty">Seite wird geladen...</div>';
8766
8767 return fetchJson(apiUrl(url, { id }), { footerRuntime: opt.footerRuntime || "" })
8768 .then(data => {
8769 if (!data || !data.ok) throw new Error("bad response");
8770 const pageId = Number(data.id || id);
8771 s.page = { id: pageId, title: data.title || "" };
8772 setSelectedPage(root, pageId);
8773 setSelectedType(root, "page");
8774 if (box) box.innerHTML = data.html || '<div class="dbx-cms-empty">Keine Ansicht vorhanden.</div>';
8775 updateViewPageTitle(root, data.title || "");
8776 revealTreeSelection(root);
8777 })
8778 .catch(err => {
8779 dbx.error("[cms] content view load failed", err);
8780 if (box) box.innerHTML = '<div class="dbx-cms-empty">Seite konnte nicht geladen werden.</div>';
8781 status(root, "Seite konnte nicht geladen werden.", "error");
8782 });
8783 }
8784
8785 function loadPage(root, cfg, id) {
8786 const s = state(root);
8787 const url = cfgUrl(cfg, "page");
8788 const pageId = Number(id || 0);
8789 if (!url || pageId <= 0) return Promise.resolve();
8790
8791 const loadSeq = Number(s.pageLoadSeq || 0) + 1;
8792 s.pageLoadSeq = loadSeq;
8793 s.loading = true;
8794
8795 return fetchJson(apiUrl(url, Object.assign({ id: pageId }, cmsLngParams(root))))
8796 .then(data => {
8797 if (loadSeq !== s.pageLoadSeq) return;
8798 if (!data || !data.ok) throw new Error("bad response");
8799 const row = data.row || {};
8800 s.page = row;
8801 hideFolderEditor(root);
8802 s.mediaRows = [];
8803 s.heroPreviewRow = data.hero_preview_media || null;
8804 s.heroParentPreviewRow = data.hero_parent_preview_media || null;
8805 s.seoPreviewRow = data.seo_preview_media || null;
8806 setSelectedPage(root, row.id || pageId);
8807 setSelectedFolder(root, row.folder || 0);
8808 setSelectedType(root, "page");
8809
8810 ["id", "folder", "title", "permalink", "description", "template", "activ", "hero_template", "hero_image_id", "hero_margin_top", "hero_height", "hero_variant", "hero_sticky", "hero_scroll_layer", "gallery_template", "gallery_visible_count", "gallery_image_size", "gallery_lightbox_width", "gallery_overflow", "gallery_click_behavior"].forEach(key => {
8811 setField(root, key, row[key] || "");
8812 });
8813
8814 try {
8815 setEditorHtml(root, row.content || "");
8816 } catch (err) {
8817 dbx.error("[cms] editor update failed", err);
8818 }
8819
8820 updateCurrentSelectionTitle(root, "page", row.id || pageId, row.title || "Unbenannte Seite");
8821
8822 revealTreeSelection(root);
8823 renderMedia(root, data.media || []);
8824 renderSeoPreview(root);
8825 setDirty(root, false);
8826 s.loading = false;
8827 })
8828 .catch(err => {
8829 if (loadSeq !== s.pageLoadSeq) return;
8830 s.loading = false;
8831 dbx.error("[cms] page load failed", err);
8832 status(root, "Seite konnte nicht geladen werden.", "error");
8833 });
8834 }
8835
8836 function selectTreePage(root, cfg, id) {
8837 const pageId = Number(id || 0);
8838 if (!pageId) return Promise.resolve();
8839 status(root, "Seite wird geladen...", "info");
8840 closeTreePanel(root);
8841 return loadPage(root, cfg, pageId);
8842 }
8843
8844 function selectTreeFolder(root, cfg, row, options) {
8845 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
8846 const id = Number(row.getAttribute("data-id") || 0);
8847 if (!id) return;
8848 setSelectedFolder(root, id);
8849 setSelectedType(root, "folder");
8850 renderTree(root);
8851 if (!options || !options.silent) {
8852 status(root, "Ordner gewaehlt.", "info");
8853 }
8854 }
8855
8856 function openFolderEditorFromRow(root, cfg, row) {
8857 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
8858 const id = Number(row.getAttribute("data-id") || 0);
8859 const folder = findNode(root, "folder", id);
8860 if (!folder) return;
8861 setSelectedFolder(root, id);
8862 setSelectedType(root, "folder");
8863 showFolderEditor(root, folder, row);
8864 loadMedia(root, cfg);
8865 revealTreeSelection(root);
8866 status(root, "Ordner bearbeiten.", "info");
8867 closeTreePanel(root);
8868 }
8869
8870 function activateTreeRow(root, cfg, row) {
8871 if (!row || !root.contains(row)) return;
8872 const type = row.getAttribute("data-type");
8873 const id = Number(row.getAttribute("data-id") || 0);
8874 const stamp = String(type || "") + ":" + String(id || 0);
8875 const s = state(root);
8876 const now = Date.now();
8877 if (s.lastTreeActivate === stamp && now - Number(s.lastTreeActivateAt || 0) < 350) {
8878 return;
8879 }
8880 s.lastTreeActivate = stamp;
8881 s.lastTreeActivateAt = now;
8882
8883 if (isViewMode(cfg)) {
8884 if (type === "page") {
8885 loadViewPage(root, cfg, id, { footerRuntime: "visible" }).finally(() => {
8886 applyTreePanelState(root, true);
8887 forceCollapseTreePanel(root);
8888 if (dbx.uiSet) dbx.uiSet(LIB, PANEL_UI_ID, "treeCollapsed", true);
8889 });
8890 }
8891 return;
8892 }
8893 if (type === "folder") {
8894 selectTreeFolder(root, cfg, row, { silent: true });
8895 return;
8896 }
8897 if (type === "page") {
8898 selectTreePage(root, cfg, id);
8899 }
8900 }
8901
8902 function loadMedia(root, cfg) {
8903 const url = cfgUrl(cfg, "media");
8904 const pageId = Number(getField(root, "id") || 0);
8905 const folderId = root.classList.contains("is-folder-editing") ? Number(getFolderField(root, "id") || 0) : 0;
8906 if (!url || (!pageId && !folderId)) return Promise.resolve();
8907
8908 const params = folderId > 0 ? { folder_id: folderId, usage: 1 } : { content_id: pageId, usage: 1 };
8909 return fetchJson(apiUrl(url, params))
8910 .then(data => {
8911 if (!data || !data.ok) throw new Error("bad response");
8912 renderMedia(root, data.rows || []);
8913 })
8914 .catch(err => {
8915 dbx.warn("[cms] media refresh failed", err);
8916 });
8917 }
8918
8919 function reloadCms(root, cfg) {
8920 const s = state(root);
8921 const wasDirty = !!s.dirty;
8922 const selectedType = root.classList.contains("is-folder-editing") || s.selectedType === "folder"
8923 ? "folder"
8924 : "page";
8925 const folderId = selectedType === "folder"
8926 ? Number(getFolderField(root, "id") || s.selectedFolder || 0)
8927 : 0;
8928 const pageId = selectedType === "page"
8929 ? Number(getField(root, "id") || s.selectedPage || 0)
8930 : 0;
8931 const finish = () => {
8932 setDirty(root, false);
8933 status(
8934 root,
8935 wasDirty
8936 ? "Neu geladen. Nicht gespeicherte Änderungen wurden verworfen."
8937 : "CMS neu geladen.",
8938 "success"
8939 );
8940 };
8941
8942 status(root, "CMS wird neu geladen...", "info");
8943 s.selectionRestored = true;
8944
8945 return loadTree(root, cfg).then(() => {
8946 if (selectedType === "folder" && folderId > 0) {
8947 const folder = findNode(root, "folder", folderId);
8948 if (folder) {
8949 setSelectedFolder(root, folderId);
8950 setSelectedType(root, "folder");
8951 showFolderEditor(root, folder);
8952 revealTreeSelection(root);
8953 return loadMedia(root, cfg).then(finish);
8954 }
8955 }
8956
8957 const currentPageId = pageId > 0 && s.flat.some(node => (
8958 node._type === "page" && Number(node._id) === pageId
8959 )) ? pageId : 0;
8960 const firstPage = currentPageId ? null : s.flat.find(node => node._type === "page");
8961 const reloadPageId = currentPageId || Number(firstPage && firstPage._id || 0);
8962
8963 if (reloadPageId > 0) {
8964 return loadPage(root, cfg, reloadPageId).then(finish);
8965 }
8966
8967 hideFolderEditor(root);
8968 renderMedia(root, []);
8969 finish();
8970 }).catch(err => {
8971 dbx.error("[cms] reload failed", err);
8972 status(root, "CMS konnte nicht neu geladen werden.", "error");
8973 });
8974 }
8975
8976 function collectPage(root) {
8977 const html = serializeCmsMarkers(getEditorHtml(root));
8978 setField(root, "content", html);
8979
8980 return {
8981 id: Number(getField(root, "id") || 0),
8982 folder: Number(getField(root, "folder") || 0),
8983 title: getField(root, "title"),
8984 permalink: getField(root, "permalink"),
8985 description: getField(root, "description"),
8986 template: getField(root, "template"),
8987 hero_template: getField(root, "hero_template") || "parent",
8988 hero_image_id: getField(root, "hero_image_id") || "parent",
8989 hero_margin_top: getField(root, "hero_margin_top") || "parent",
8990 hero_height: getField(root, "hero_height") || "parent",
8991 hero_variant: getField(root, "hero_variant") || "parent",
8992 hero_sticky: getField(root, "hero_sticky") || "parent",
8993 hero_scroll_layer: getField(root, "hero_scroll_layer") || "parent",
8994 gallery_template: "image-gallery",
8995 gallery_visible_count: "3",
8996 gallery_image_size: getField(root, "gallery_image_size") || "original",
8997 gallery_lightbox_width: getField(root, "gallery_lightbox_width") || "100vw",
8998 gallery_overflow: getField(root, "gallery_overflow") || "grid",
8999 gallery_click_behavior: getField(root, "gallery_click_behavior") || "lightbox",
9000 activ: getField(root, "activ"),
9001 content: html,
9002 inline_media_ids: collectInlineMediaIdsFromEditor(root)
9003 };
9004 }
9005
9006 function collectInlineMediaIdsFromEditor(root) {
9007 const surface = editorSurface(root);
9008 const ids = new Set();
9009 const registerId = (value) => {
9010 const id = Number(value || 0);
9011 if (id > 0) ids.add(id);
9012 };
9013 const registerNode = (node) => {
9014 if (!node || !node.getAttribute) return;
9015 registerId(node.getAttribute("data-cms-media-id"));
9016 const src = String(node.getAttribute("src") || "");
9017 const match = src.match(/dbx_mid=([0-9]+)/i);
9018 if (match) registerId(match[1]);
9019 };
9020
9021 if (surface) {
9022 qsa(surface, ".dbx-cms-inline-media[data-cms-media-id], figure.dbx-cms-inline-video-block[data-cms-media-id]").forEach(wrap => {
9023 if (!inlineMediaWrapperHasContent(wrap)) return;
9024 if (qs(wrap, "img[src*='dbx_mid=']")) return;
9025 registerNode(wrap);
9026 });
9027 qsa(surface, "img[data-cms-media-id], video[data-cms-media-id], iframe[data-cms-media-id]").forEach(registerNode);
9028 qsa(surface, "img[src*='dbx_mid=']").forEach(registerNode);
9029 }
9030
9031 return Array.from(ids).filter(Boolean);
9032 }
9033
9034 function inlineMediaIds(html) {
9035 const ids = new Set();
9036 String(html || "").replace(/dbx_mid=([0-9]+)/gi, (_, id) => {
9037 ids.add(Number(id));
9038 return "";
9039 });
9040 String(html || "").replace(/data-cms-media-id=["']?([0-9]+)/gi, (_, id) => {
9041 ids.add(Number(id));
9042 return "";
9043 });
9044 return Array.from(ids).filter(Boolean);
9045 }
9046
9047 function removeInlineMediaFromEditor(root, id) {
9048 id = Number(id || 0);
9049 if (!id) return false;
9050
9051 const html = getEditorHtml(root);
9052 const doc = new DOMParser().parseFromString('<div data-root>' + String(html || "") + '</div>', "text/html");
9053 const wrap = doc.querySelector("[data-root]");
9054 if (!wrap) return false;
9055
9056 let changed = false;
9057 const mediaNodes = Array.from(wrap.querySelectorAll("[data-cms-media-id], img, video, iframe, source"));
9058 mediaNodes.forEach(node => {
9059 if (!node || !node.getAttribute) return;
9060
9061 const dataId = Number(node.getAttribute("data-cms-media-id") || 0);
9062 const src = node.getAttribute("src") || node.getAttribute("href") || "";
9063 const srcMatch = new RegExp("(?:dbx_mid=|data-cms-media-id=)" + id + "(?:[^0-9]|$)", "i").test(src);
9064 if (dataId !== id && !srcMatch) return;
9065
9066 const target = node.closest(".dbx-cms-inline-media, .dbx-cms-inline-media-missing-wrap, figure") || node;
9067 if (target && target.parentNode) {
9068 target.parentNode.removeChild(target);
9069 changed = true;
9070 }
9071 });
9072
9073 if (!changed) return false;
9074
9075 setEditorHtml(root, wrap.innerHTML);
9076 markDirty(root);
9077 return true;
9078 }
9079
9080 function savePage(root, cfg) {
9081 const url = cfgUrl(cfg, "save");
9082 const s = state(root);
9083 if (!url || s.saving) return Promise.resolve();
9084 let saveCommitted = false;
9085 let committedStatus = "Seite gespeichert.";
9086
9087 setSaving(root, true);
9088
9089 return fetchJson(apiUrl(url, cmsLngParams(root)), {
9090 method: "POST",
9091 headers: { "Content-Type": "application/json" },
9092 body: JSON.stringify(collectPage(root)),
9093 timeout: 60000
9094 }).then(data => {
9095 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "save failed");
9096 saveCommitted = true;
9097 const row = data.row || {};
9098 if (row && row.id) {
9099 s.loading = true;
9100 suppressDirtyFor(root, 250);
9101 ["id", "folder", "title", "permalink", "description", "template", "activ", "hero_template", "hero_image_id", "hero_margin_top", "hero_height", "hero_variant", "hero_sticky", "hero_scroll_layer", "gallery_template", "gallery_visible_count", "gallery_image_size", "gallery_lightbox_width", "gallery_overflow", "gallery_click_behavior"].forEach(key => {
9102 if (row[key] !== undefined) setField(root, key, row[key] || "");
9103 });
9104 setSelectedPage(root, row.id || data.id || 0);
9105 setSelectedFolder(root, row.folder || 0);
9106 setSelectedType(root, "page");
9107 s.page = row;
9108 updateCurrentSelectionTitle(root, "page", row.id || data.id || 0, row.title || "Unbenannte Seite");
9109 if (row.content !== undefined) {
9110 try {
9111 setEditorHtml(root, row.content || "");
9112 } catch (err) {
9113 dbx.warn("[cms] editor sync after save failed", err);
9114 }
9115 }
9116 s.loading = false;
9117 }
9118 s.heroPreviewRow = data.hero_preview_media || s.heroPreviewRow || null;
9119 s.heroParentPreviewRow = data.hero_parent_preview_media || s.heroParentPreviewRow || null;
9120 s.seoPreviewRow = data.seo_preview_media || s.seoPreviewRow || null;
9121 if (Array.isArray(data.media)) renderMedia(root, data.media);
9122 renderHeroPreview(root);
9123 renderSeoPreview(root);
9124 const saveStatus = formatLngSaveStatus("Seite gespeichert.", data);
9125 committedStatus = saveStatus.text;
9126 status(root, saveStatus.text, saveStatus.type);
9127 clearDirtyAfterSave(root);
9128 clearCmsLoading(root);
9129 return loadMedia(root, cfg)
9130 .catch(err => {
9131 dbx.warn("[cms] media refresh after save failed", err);
9132 })
9133 .then(() => loadTree(root, cfg))
9134 .catch(err => {
9135 dbx.warn("[cms] refresh after save failed", err);
9136 status(root, "Seite gespeichert. Ansicht konnte nicht aktualisiert werden.", "success");
9137 })
9138 .then(() => {
9139 revealTreeSelection(root);
9140 maybeOpenLngProvisionAfterCreate(root, cfg, data);
9141 return data;
9142 });
9143 }).catch(err => {
9144 s.loading = false;
9145 if (saveCommitted) {
9146 dbx.warn("[cms] post-save refresh failed", err);
9147 status(root, committedStatus + " Ansicht konnte nicht vollstaendig aktualisiert werden.", "success");
9148 clearDirtyAfterSave(root);
9149 } else {
9150 dbx.error("[cms] save failed", err);
9151 status(root, err && err.message ? err.message : "Seite konnte nicht gespeichert werden.", "error");
9152 }
9153 }).finally(() => {
9154 setSaving(root, false);
9155 clearCmsLoading(root);
9156 });
9157 }
9158
9159 function deletePage(root, cfg) {
9160 const id = Number(getField(root, "id") || 0);
9161 const title = getField(root, "title") || "Seite";
9162 const url = cfgUrl(cfg, "deletepage");
9163 if (!id || !url) {
9164 status(root, "Bitte erst eine Seite auswaehlen.", "error");
9165 return Promise.resolve();
9166 }
9167
9168 return openLngDeleteDialog(root, cfg, "page", id, title).catch(err => {
9169 dbx.error("[cms] page delete failed", err);
9170 status(root, err && err.message ? err.message : "Seite konnte nicht geloescht werden.", "error");
9171 });
9172 }
9173
9174 function renderMedia(root, rows) {
9175 const boxes = qsa(root, "[data-cms-media]");
9176 if (!boxes.length) return;
9177 const s = state(root);
9178 if (Array.isArray(rows)) s.mediaRows = rows.slice();
9179 rows = s.mediaRows || [];
9180 renderHeroPreview(root);
9181 renderSeoPreview(root);
9182
9183 syncUploadSlot(root);
9184 const prefixes = { hero: "h", gallery: "g", inline: "i", shop: "s" };
9185 const mediaFilter = String(qs(root, "[data-cms-media-filter]")?.value || state(root).mediaFilter || "all");
9186
9187 boxes.forEach(box => {
9188 const boxSlot = String(box.getAttribute("data-cms-media") || "all");
9189 const visible = (Array.isArray(rows) ? rows : []).filter(row => {
9190 const slot = String(row.slot || "").trim();
9191 if (boxSlot === "gallery" || boxSlot === "inline" || boxSlot === "shop") {
9192 if (slot !== boxSlot) return false;
9193 } else if (boxSlot === "all" && mediaFilter !== "all") {
9194 if (slot !== mediaFilter) return false;
9195 }
9196 if (slot === "hero" && boxSlot !== "all") return false;
9197 return true;
9198 });
9199
9200 if (!visible.length) {
9201 const empty = boxSlot === "gallery"
9202 ? "Keine Medien in der Gallery."
9203 : (boxSlot === "inline"
9204 ? "Keine Medien im Text."
9205 : (mediaFilter === "hero" ? "Kein Hero-Medium zugeordnet." : "Keine Medien in diesem Bereich."));
9206 box.innerHTML = `<div class="dbx-cms-empty">${empty}</div>`;
9207 return;
9208 }
9209
9210 const counters = {};
9211 box.innerHTML = visible.map(row => {
9212 const preview = mediaPreviewHtml(row);
9213 const canEmbed = !!row.url;
9214 const slot = String(row.slot || "inline");
9215 counters[slot] = (counters[slot] || 0) + 1;
9216 const badge = (prefixes[slot] || "m") + counters[slot];
9217 const originLabel = mediaOriginLabel(row);
9218 return `<div class="dbx-cms-media-item" draggable="true" data-media-id="${escapeHtml(row.id || "")}" data-usage-id="${escapeHtml(row.usage_id || row.current_usage_id || "")}" data-media-slot="${escapeHtml(slot)}" data-media-folder="${escapeHtml(row.media_folder || "")}" data-url="${escapeHtml(row.url || "")}" data-thumb-url="${escapeHtml(row.thumb_url || "")}" data-mime="${escapeHtml(row.mime || "")}" data-media-type="${escapeHtml(row.media_type || "")}" data-file-name="${escapeHtml(row.file_name || "")}" data-file-path="${escapeHtml(row.file_path || "")}" data-title="${escapeHtml(row.title || "")}" data-alt="${escapeHtml(row.alt || "")}" data-width="${escapeHtml(row.width || "")}" data-height="${escapeHtml(row.height || "")}">
9219 <span class="dbx-cms-media-preview"><span class="dbx-cms-media-badge">${escapeHtml(badge)}</span>${preview}</span>
9220 <span class="dbx-cms-media-meta">
9221 <span class="dbx-cms-media-name">${escapeHtml(row.title || row.file_name || "Medium")}</span>
9222 <span class="dbx-cms-media-slot">${escapeHtml(originLabel)}</span>
9223 </span>
9224 <span class="dbx-cms-media-actions">
9225 ${canEmbed ? '<button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-embed title="Medium direkt in den Editor einfuegen"><i class="bi bi-image"></i></button>' : ''}
9226 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-remove title="Zuordnung aus dieser Seite entfernen"><i class="bi bi-trash"></i></button>
9227 </span>
9228 </div>`;
9229 }).join("");
9230 setupMediaLazyImages(box);
9231 });
9232 }
9233
9234 function removeMedia(root, cfg, id, usageId, slot) {
9235 const url = cfgUrl(cfg, "removemedia");
9236 if (!url || (!id && !usageId)) return Promise.resolve();
9237 const pageId = Number(getField(root, "id") || 0);
9238 const folderId = root.classList.contains("is-folder-editing")
9239 ? Number(getFolderField(root, "id") || 0)
9240 : Number(getField(root, "folder") || 0);
9241 const payload = {
9242 id: Number(id || 0),
9243 usage_id: Number(usageId || 0),
9244 content_id: pageId,
9245 folder_id: folderId || 0,
9246 slot: slot || ""
9247 };
9248 return fetchJson(apiUrl(url), {
9249 method: "POST",
9250 headers: { "Content-Type": "application/json" },
9251 body: JSON.stringify(payload)
9252 })
9253 .then(data => {
9254 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "remove failed");
9255 if (slot === "inline") removeInlineMediaFromEditor(root, id);
9256 if (Array.isArray(data.media || data.rows)) renderMedia(root, data.media || data.rows);
9257 status(root, "Medien-Zuordnung entfernt.", "success");
9258 return Array.isArray(data.media || data.rows) ? Promise.resolve(data) : loadMedia(root, cfg);
9259 })
9260 .catch(err => {
9261 dbx.error("[cms] media remove failed", err);
9262 status(root, "Medien-Zuordnung konnte nicht entfernt werden.", "error");
9263 });
9264 }
9265
9266 function deleteMedia(root, cfg, id) {
9267 const url = cfgUrl(cfg, "deletemedia");
9268 if (!url || !id) return Promise.resolve();
9269
9270 return fetchJson(apiUrl(url, { id }), {
9271 method: "POST",
9272 headers: { "Content-Type": "application/json" },
9273 body: JSON.stringify({ id: Number(id) })
9274 }).then(data => {
9275 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "delete failed");
9276 status(root, "Mediendatei geloescht.", "success");
9277 return loadMedia(root, cfg);
9278 }).catch(err => {
9279 dbx.error("[cms] media delete failed", err);
9280 status(root, "Mediendatei konnte nicht geloescht werden.", "error");
9281 });
9282 }
9283
9284 function autoRatioValue(changed, other, originalWidth, originalHeight) {
9285 const value = Number(changed?.value || 0);
9286 if (!changed || !other || !value || !originalWidth || !originalHeight) return;
9287 if (changed.hasAttribute("data-cms-media-edit-width")) {
9288 other.value = Math.max(1, Math.round(value * originalHeight / originalWidth));
9289 } else {
9290 other.value = Math.max(1, Math.round(value * originalWidth / originalHeight));
9291 }
9292 }
9293
9294 function mediaEditNeedsRebuild(modal) {
9295 return !modal || !!qs(modal, ".dbx-cms-media-edit-actions");
9296 }
9297
9298 function fitMediaEditDialog(modal) {
9299 const maxW = Math.min(1380, Math.floor(window.innerWidth * 0.96));
9300 const maxH = Math.min(1010, Math.floor(window.innerHeight * 0.94));
9301 const width = Math.min(760, maxW);
9302 const height = Math.min(640, maxH);
9303 modal.style.width = width + "px";
9304 modal.style.height = height + "px";
9305 modal.style.maxWidth = maxW + "px";
9306 modal.style.maxHeight = maxH + "px";
9307 }
9308
9309 function ensureMediaEditDialog(root) {
9310 const s = state(root);
9311 let modal = s.mediaEditDialog || qs(root, "[data-cms-media-edit]");
9312 if (modal && !document.documentElement.contains(modal)) {
9313 modal = null;
9314 s.mediaEditDialog = null;
9315 }
9316 if (mediaEditNeedsRebuild(modal)) {
9317 if (modal) modal.remove();
9318 modal = null;
9319 s.mediaEditDialog = null;
9320 }
9321 if (modal) {
9322 if (modal.parentNode !== document.body) document.body.appendChild(modal);
9323 modal.__dbxCmsRoot = root;
9324 s.mediaEditDialog = modal;
9325 return modal;
9326 }
9327 modal = document.createElement("div");
9328 modal.className = "dbx-cms-media-edit";
9329 modal.setAttribute("data-cms-media-edit", "1");
9330 modal.__dbxCmsRoot = root;
9331 modal.hidden = true;
9332 modal.innerHTML = `
9333 <div class="dbx-cms-media-edit-head">
9334 <strong><i class="bi bi-crop"></i> Bild bearbeiten</strong>
9335 <button type="button" class="btn btn-outline-secondary btn-sm" data-cms-media-edit-close title="Schliessen"><i class="bi bi-x-lg"></i></button>
9336 </div>
9337 <div class="dbx-cms-media-edit-body">
9338 <div class="dbx-cms-media-edit-preview" data-cms-media-edit-preview>
9339 <div class="dbx-cms-media-edit-stage" data-cms-media-edit-stage>
9340 <img src="" alt="" draggable="false" data-cms-media-edit-image>
9341 <span class="dbx-cms-media-edit-selection" data-cms-media-edit-selection hidden>
9342 <span class="dbx-cms-media-edit-selection-size" data-cms-media-edit-selection-size></span>
9343 </span>
9344 </div>
9345 <span class="dbx-cms-media-edit-info" data-cms-media-edit-info>X 0 px · Y 0 px · 0 x 0 px</span>
9346 </div>
9347 <div class="dbx-cms-media-edit-grid">
9348 <label>X <input type="number" class="form-control form-control-sm" data-cms-media-edit-x value="0"></label>
9349 <label>Y <input type="number" class="form-control form-control-sm" data-cms-media-edit-y value="0"></label>
9350 <label>Breite <input type="number" class="form-control form-control-sm" data-cms-media-edit-width></label>
9351 <label>Hoehe <input type="number" class="form-control form-control-sm" data-cms-media-edit-height></label>
9352 <label class="dbx-cms-media-edit-ratio"><input type="checkbox" data-cms-media-edit-ratio checked> Ratio Resize</label>
9353 <div class="dbx-cms-media-edit-side-actions">
9354 <button type="button" class="btn btn-outline-primary btn-sm" data-cms-media-edit-resize><i class="bi bi-arrows-angle-contract"></i><span>Resize</span></button>
9355 <button type="button" class="btn btn-primary btn-sm" data-cms-media-edit-crop><i class="bi bi-crop"></i><span>Zuschneiden</span></button>
9356 <span class="dbx-cms-media-edit-side-separator" aria-hidden="true"></span>
9357 <span class="dbx-cms-media-edit-hint">
9358 <span>Mit der Maus den gewuenschten Bildausschnitt waehlen.</span>
9359 <button type="button" class="btn btn-danger btn-sm" data-cms-media-edit-crop-apply>
9360 <i class="bi bi-crop"></i><span>Ausschnitt uebernehmen</span>
9361 </button>
9362 </span>
9363 </div>
9364 </div>
9365 </div>`;
9366 document.body.appendChild(modal);
9367 s.mediaEditDialog = modal;
9368 return modal;
9369 }
9370
9371 function mediaEditImageSize(modal) {
9372 const img = qs(modal, "[data-cms-media-edit-image]");
9373 const row = modal.__dbxCmsEditRow || {};
9374 return {
9375 width: Number(img?.naturalWidth || row.width || 0),
9376 height: Number(img?.naturalHeight || row.height || 0)
9377 };
9378 }
9379
9380 function clampMediaCrop(value, min, max) {
9381 return Math.max(min, Math.min(max, value));
9382 }
9383
9384 function renderMediaCropSelection(modal) {
9385 const preview = qs(modal, "[data-cms-media-edit-preview]");
9386 const stage = qs(modal, "[data-cms-media-edit-stage]");
9387 const img = qs(modal, "[data-cms-media-edit-image]");
9388 const selection = qs(modal, "[data-cms-media-edit-selection]");
9389 const info = qs(modal, "[data-cms-media-edit-info]");
9390 if (!preview || !stage || !img || !selection || !img.complete) {
9391 if (info) info.textContent = "X 0 px · Y 0 px · 0 x 0 px";
9392 return;
9393 }
9394
9395 const natural = mediaEditImageSize(modal);
9396 const imageRect = img.getBoundingClientRect();
9397 const stageRect = stage.getBoundingClientRect();
9398 if (!natural.width || !natural.height || !imageRect.width || !imageRect.height) return;
9399
9400 const x = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-x]")?.value || 0), 0, natural.width - 1);
9401 const y = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-y]")?.value || 0), 0, natural.height - 1);
9402 const width = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-width]")?.value || natural.width), 1, natural.width - x);
9403 const height = clampMediaCrop(Number(qs(modal, "[data-cms-media-edit-height]")?.value || natural.height), 1, natural.height - y);
9404 const scaleX = imageRect.width / natural.width;
9405 const scaleY = imageRect.height / natural.height;
9406 const infoText = "X " + Math.round(x) + " px · Y " + Math.round(y) + " px · "
9407 + Math.round(width) + " x " + Math.round(height) + " px";
9408 if (info) info.textContent = infoText;
9409
9410 selection.style.left = (imageRect.left - stageRect.left + x * scaleX) + "px";
9411 selection.style.top = (imageRect.top - stageRect.top + y * scaleY) + "px";
9412 selection.style.width = (width * scaleX) + "px";
9413 selection.style.height = (height * scaleY) + "px";
9414
9415 const size = qs(selection, "[data-cms-media-edit-selection-size]");
9416 const isFullFrame = x === 0 && y === 0 && width >= natural.width && height >= natural.height;
9417 if (!modal.__dbxCmsCropActive && isFullFrame) {
9418 selection.hidden = true;
9419 if (size) size.textContent = "";
9420 return;
9421 }
9422 selection.hidden = false;
9423
9424 if (size) size.textContent = Math.round(width) + " × " + Math.round(height);
9425 }
9426
9427 function setMediaCropFields(modal, crop) {
9428 modal.__dbxCmsCropActive = true;
9429 const natural = mediaEditImageSize(modal);
9430 if (!natural.width || !natural.height) return;
9431 const x = clampMediaCrop(Math.round(Math.min(crop.x1, crop.x2)), 0, natural.width - 1);
9432 const y = clampMediaCrop(Math.round(Math.min(crop.y1, crop.y2)), 0, natural.height - 1);
9433 const right = clampMediaCrop(Math.round(Math.max(crop.x1, crop.x2)), x + 1, natural.width);
9434 const bottom = clampMediaCrop(Math.round(Math.max(crop.y1, crop.y2)), y + 1, natural.height);
9435 const width = qs(modal, "[data-cms-media-edit-width]");
9436 const height = qs(modal, "[data-cms-media-edit-height]");
9437 const inputX = qs(modal, "[data-cms-media-edit-x]");
9438 const inputY = qs(modal, "[data-cms-media-edit-y]");
9439 if (inputX) inputX.value = x;
9440 if (inputY) inputY.value = y;
9441 if (width) width.value = right - x;
9442 if (height) height.value = bottom - y;
9443 renderMediaCropSelection(modal);
9444 }
9445
9446 function mediaCropPoint(modal, event) {
9447 const img = qs(modal, "[data-cms-media-edit-image]");
9448 const natural = mediaEditImageSize(modal);
9449 if (!img || !natural.width || !natural.height) return null;
9450 const rect = img.getBoundingClientRect();
9451 if (!rect.width || !rect.height) return null;
9452 return {
9453 x: clampMediaCrop((event.clientX - rect.left) * natural.width / rect.width, 0, natural.width),
9454 y: clampMediaCrop((event.clientY - rect.top) * natural.height / rect.height, 0, natural.height),
9455 inside: event.clientX >= rect.left && event.clientX <= rect.right
9456 && event.clientY >= rect.top && event.clientY <= rect.bottom
9457 };
9458 }
9459
9460 function mediaEditPayload(modal, action) {
9461 const row = modal.__dbxCmsEditRow || {};
9462 return {
9463 action: action,
9464 id: Number(row.id || 0),
9465 width: Number(qs(modal, "[data-cms-media-edit-width]")?.value || 0),
9466 height: Number(qs(modal, "[data-cms-media-edit-height]")?.value || 0),
9467 ratio: qs(modal, "[data-cms-media-edit-ratio]")?.checked ? 1 : 0,
9468 x: Number(qs(modal, "[data-cms-media-edit-x]")?.value || 0),
9469 y: Number(qs(modal, "[data-cms-media-edit-y]")?.value || 0)
9470 };
9471 }
9472
9473 function resetMediaEditSelection(modal) {
9474 const x = qs(modal, "[data-cms-media-edit-x]");
9475 const y = qs(modal, "[data-cms-media-edit-y]");
9476 const w = qs(modal, "[data-cms-media-edit-width]");
9477 const h = qs(modal, "[data-cms-media-edit-height]");
9478 const selection = qs(modal, "[data-cms-media-edit-selection]");
9479 const natural = mediaEditImageSize(modal);
9480 modal.__dbxCmsCropActive = false;
9481 if (x) x.value = "0";
9482 if (y) y.value = "0";
9483 if (w && natural.width) w.value = natural.width;
9484 if (h && natural.height) h.value = natural.height;
9485 if (selection) selection.hidden = true;
9486 renderMediaCropSelection(modal);
9487 }
9488
9489 function previewMediaCrop(root, modal) {
9490 const img = qs(modal, "[data-cms-media-edit-image]");
9491 const selection = qs(modal, "[data-cms-media-edit-selection]");
9492 const payload = mediaEditPayload(modal, "crop");
9493 const natural = mediaEditImageSize(modal);
9494 if (!payload.id) {
9495 status(root, "Kein Bild ausgewaehlt.", "error");
9496 return false;
9497 }
9498 if (!modal.__dbxCmsCropActive) {
9499 status(root, "Bitte zuerst einen Bildausschnitt waehlen.", "error");
9500 return false;
9501 }
9502 if (!img || !img.complete || !natural.width || !natural.height || payload.width < 1 || payload.height < 1) {
9503 status(root, "Bitte einen gueltigen Ausschnitt waehlen.", "error");
9504 return false;
9505 }
9506 const x = clampMediaCrop(Math.round(payload.x), 0, natural.width - 1);
9507 const y = clampMediaCrop(Math.round(payload.y), 0, natural.height - 1);
9508 const width = clampMediaCrop(Math.round(payload.width), 1, natural.width - x);
9509 const height = clampMediaCrop(Math.round(payload.height), 1, natural.height - y);
9510 const canvas = document.createElement("canvas");
9511 canvas.width = width;
9512 canvas.height = height;
9513 const ctx = canvas.getContext("2d");
9514 if (!ctx) {
9515 status(root, "Vorschau konnte nicht erstellt werden.", "error");
9516 return false;
9517 }
9518 try {
9519 ctx.drawImage(img, x, y, width, height, 0, 0, width, height);
9520 } catch (err) {
9521 dbx.error("[cms] crop preview failed", err);
9522 status(root, "Vorschau konnte nicht erstellt werden.", "error");
9523 return false;
9524 }
9525 const previous = modal.__dbxCmsPendingCrop || { x: 0, y: 0 };
9526 modal.__dbxCmsPendingCrop = {
9527 id: payload.id,
9528 x: Math.round(Number(previous.x || 0) + x),
9529 y: Math.round(Number(previous.y || 0) + y),
9530 width,
9531 height
9532 };
9533 img.onload = () => {
9534 resetMediaEditSelection(modal);
9535 fitMediaEditDialog(modal);
9536 };
9537 img.src = canvas.toDataURL("image/png");
9538 if (selection) selection.hidden = true;
9539 status(root, "Ausschnitt als Vorschau erstellt. Mit Ausschnitt uebernehmen speichern.", "info");
9540 return true;
9541 }
9542
9543 function refreshMediaEditPreview(modal, row) {
9544 if (!modal || !row) return;
9545 const img = qs(modal, "[data-cms-media-edit-image]");
9546 const w = qs(modal, "[data-cms-media-edit-width]");
9547 const h = qs(modal, "[data-cms-media-edit-height]");
9548 const x = qs(modal, "[data-cms-media-edit-x]");
9549 const y = qs(modal, "[data-cms-media-edit-y]");
9550 const selection = qs(modal, "[data-cms-media-edit-selection]");
9551 modal.__dbxCmsEditRow = row;
9552 modal.__dbxCmsPendingCrop = null;
9553 modal.__dbxCmsCropActive = false;
9554 if (x) x.value = "0";
9555 if (y) y.value = "0";
9556 if (selection) selection.hidden = true;
9557 const baseUrl = row.url || row.thumb_url || "";
9558 const url = baseUrl ? apiUrl(baseUrl) : "";
9559 if (w) w.value = row.width || "";
9560 if (h) h.value = row.height || "";
9561 if (!img || !url) return;
9562 img.onload = () => {
9563 const natural = mediaEditImageSize(modal);
9564 if (w && natural.width) w.value = natural.width;
9565 if (h && natural.height) h.value = natural.height;
9566 if (selection) selection.hidden = true;
9567 fitMediaEditDialog(modal);
9568 renderMediaCropSelection(modal);
9569 };
9570 img.src = url;
9571 }
9572
9573 function reopenMediaBrowserAfterEdit(root, browser) {
9574 if (!browser || browser.hidden) return;
9575 openMediaBrowser(browser.__dbxCmsRoot || root, browserCfg(browser), {
9576 mode: browser.__dbxCmsMediaMode || "editor",
9577 slot: browser.__dbxCmsAssignSlot || currentMediaSlot(root),
9578 mediaFolder: browser.__dbxCmsMediaFolder || "",
9579 formDataExtra: browser.__dbxCmsFormDataExtra || null,
9580 afterAssign: browser.__dbxCmsAfterAssign
9581 });
9582 }
9583
9584 function commitMediaEditAction(root, cfg, modal, action, options) {
9585 options = options || {};
9586 let payload = mediaEditPayload(modal, action);
9587 if (!payload.id) {
9588 status(root, "Kein Bild ausgewaehlt.", "error");
9589 return Promise.resolve(false);
9590 }
9591 if (action === "resize" && modal.__dbxCmsPendingCrop) {
9592 status(root, "Bitte den Ausschnitt zuerst uebernehmen oder den Dialog neu oeffnen.", "warning");
9593 return Promise.resolve(false);
9594 }
9595 if (action === "crop" && modal.__dbxCmsPendingCrop) {
9596 payload = Object.assign({}, payload, {
9597 x: Number(modal.__dbxCmsPendingCrop.x || 0),
9598 y: Number(modal.__dbxCmsPendingCrop.y || 0),
9599 width: Number(modal.__dbxCmsPendingCrop.width || 0),
9600 height: Number(modal.__dbxCmsPendingCrop.height || 0)
9601 });
9602 }
9603 if (action === "crop" && !modal.__dbxCmsPendingCrop && !modal.__dbxCmsCropActive) {
9604 status(root, "Bitte zuerst einen Bildausschnitt waehlen.", "error");
9605 return Promise.resolve(false);
9606 }
9607 if (action === "crop" && (payload.width < 1 || payload.height < 1)) {
9608 status(root, "Bitte einen gueltigen Ausschnitt waehlen.", "error");
9609 return Promise.resolve(false);
9610 }
9611 const successMsg = action === "crop"
9612 ? "Bild zugeschnitten."
9613 : "Bild bearbeitet.";
9614 return editMedia(root, cfg, payload, {
9615 reload: false,
9616 silent: false,
9617 successMsg
9618 }).then(data => {
9619 if (!data || !data.ok) return false;
9620 const updated = Array.isArray(data.rows) && data.rows[0] ? data.rows[0] : null;
9621 if (!updated) return false;
9622 const browserModal = qs(document, "[data-cms-media-browser]");
9623 if (browserModal) patchMediaBrowserRow(browserModal, updated);
9624 loadMedia(root, cfg);
9625 if (options.closeAfter) {
9626 modal.hidden = true;
9627 reopenMediaBrowserAfterEdit(root, qs(document, "[data-cms-media-browser]"));
9628 return true;
9629 }
9630 refreshMediaEditPreview(modal, updated);
9631 return true;
9632 });
9633 }
9634
9635 function bindMediaEditEvents(root, modal) {
9636 if (!modal || modal.__dbxCmsEventsBound) return;
9637 modal.__dbxCmsEventsBound = true;
9638
9639 // Direkt am Dialog binden. Der Medienbrowser lebt in einem openWin-Fenster
9640 // und weitere UI-Layer koennen die delegierten Root-Events abfangen.
9641 modal.addEventListener("click", e => {
9642 const close = closestElement(e.target, "[data-cms-media-edit-close]");
9643 if (close && modal.contains(close)) {
9644 e.preventDefault();
9645 e.stopPropagation();
9646 modal.hidden = true;
9647 return;
9648 }
9649
9650 const resize = closestElement(e.target, "[data-cms-media-edit-resize]");
9651 const cropApply = closestElement(e.target, "[data-cms-media-edit-crop-apply]");
9652 const cropSave = closestElement(e.target, "[data-cms-media-edit-crop]");
9653 const cfg = modal.__dbxCmsCfg || {};
9654
9655 if (cropApply && modal.contains(cropApply)) {
9656 e.preventDefault();
9657 e.stopPropagation();
9658 commitMediaEditAction(root, cfg, modal, "crop", { closeAfter: false });
9659 return;
9660 }
9661
9662 if (cropSave && modal.contains(cropSave)) {
9663 e.preventDefault();
9664 e.stopPropagation();
9665 previewMediaCrop(root, modal);
9666 return;
9667 }
9668
9669 if (resize && modal.contains(resize)) {
9670 e.preventDefault();
9671 e.stopPropagation();
9672 commitMediaEditAction(root, cfg, modal, "resize", { closeAfter: false });
9673 }
9674 });
9675
9676 modal.addEventListener("input", e => {
9677 const editInput = closestElement(e.target, "[data-cms-media-edit-x], [data-cms-media-edit-y], [data-cms-media-edit-width], [data-cms-media-edit-height]");
9678 if (!editInput || !modal.contains(editInput)) return;
9679 modal.__dbxCmsCropActive = true;
9680 if (editInput.hasAttribute("data-cms-media-edit-x") || editInput.hasAttribute("data-cms-media-edit-y")) {
9681 renderMediaCropSelection(modal);
9682 return;
9683 }
9684 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
9685 if (ratio && ratio.checked && !modal.__dbxCmsCropSelecting) {
9686 const row = modal.__dbxCmsEditRow || {};
9687 const w = qs(modal, "[data-cms-media-edit-width]");
9688 const h = qs(modal, "[data-cms-media-edit-height]");
9689 autoRatioValue(editInput, editInput === w ? h : w, Number(row.width || 0), Number(row.height || 0));
9690 }
9691 renderMediaCropSelection(modal);
9692 });
9693
9694 const preview = qs(modal, "[data-cms-media-edit-preview]");
9695 if (preview) {
9696 preview.addEventListener("pointerdown", e => {
9697 if (e.button !== undefined && e.button !== 0) return;
9698 const point = mediaCropPoint(modal, e);
9699 if (!point || !point.inside) return;
9700 e.preventDefault();
9701 e.stopPropagation();
9702 modal.__dbxCmsCropSelecting = {
9703 pointerId: e.pointerId,
9704 x1: point.x,
9705 y1: point.y,
9706 x2: point.x,
9707 y2: point.y
9708 };
9709 if (preview.setPointerCapture && e.pointerId !== undefined) {
9710 preview.setPointerCapture(e.pointerId);
9711 }
9712 setMediaCropFields(modal, modal.__dbxCmsCropSelecting);
9713 });
9714
9715 preview.addEventListener("pointermove", e => {
9716 const crop = modal.__dbxCmsCropSelecting;
9717 if (!crop || (e.pointerId !== undefined && crop.pointerId !== e.pointerId)) return;
9718 const point = mediaCropPoint(modal, e);
9719 if (!point) return;
9720 e.preventDefault();
9721 crop.x2 = point.x;
9722 crop.y2 = point.y;
9723 setMediaCropFields(modal, crop);
9724 });
9725
9726 const finishSelection = e => {
9727 const crop = modal.__dbxCmsCropSelecting;
9728 if (!crop || (e.pointerId !== undefined && crop.pointerId !== e.pointerId)) return;
9729 const point = mediaCropPoint(modal, e);
9730 if (point) {
9731 crop.x2 = point.x;
9732 crop.y2 = point.y;
9733 setMediaCropFields(modal, crop);
9734 }
9735 if (preview.releasePointerCapture && e.pointerId !== undefined && preview.hasPointerCapture?.(e.pointerId)) {
9736 preview.releasePointerCapture(e.pointerId);
9737 }
9738 modal.__dbxCmsCropSelecting = null;
9739 };
9740 preview.addEventListener("pointerup", finishSelection);
9741 preview.addEventListener("pointercancel", finishSelection);
9742 }
9743
9744 window.addEventListener("resize", () => {
9745 if (!modal.hidden) {
9746 fitMediaEditDialog(modal);
9747 renderMediaCropSelection(modal);
9748 }
9749 });
9750 }
9751
9752 function openMediaEdit(root, cfg, row) {
9753 if (!row || !canEditImage(row)) {
9754 status(root, "Nur Rasterbilder koennen bearbeitet werden.", "error");
9755 return;
9756 }
9757 const modal = ensureMediaEditDialog(root);
9758 bindMediaEditEvents(root, modal);
9759 // Der Medienbrowser kann aus einem CMS-openWin heraus ein weiteres openWin oeffnen.
9760 // Der Bildeditor liegt deshalb als Body-Layer ausserhalb des CMS-Fenster-Stacking-Contexts.
9761 const windowLevels = qsa(document, ".dbx-window")
9762 .map(win => Number(window.getComputedStyle(win).zIndex || 0))
9763 .filter(Number.isFinite);
9764 modal.style.zIndex = String(Math.max(3200, ...windowLevels) + 10);
9765 modal.__dbxCmsCfg = cfg || {};
9766 modal.__dbxCmsEditRow = row;
9767 modal.__dbxCmsPendingCrop = null;
9768 modal.__dbxCmsCropActive = false;
9769 const img = qs(modal, "[data-cms-media-edit-image]");
9770 const w = qs(modal, "[data-cms-media-edit-width]");
9771 const h = qs(modal, "[data-cms-media-edit-height]");
9772 const x = qs(modal, "[data-cms-media-edit-x]");
9773 const y = qs(modal, "[data-cms-media-edit-y]");
9774 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
9775 if (img) {
9776 img.onload = () => {
9777 const natural = mediaEditImageSize(modal);
9778 if (w && natural.width) w.value = natural.width;
9779 if (h && natural.height) h.value = natural.height;
9780 fitMediaEditDialog(modal);
9781 renderMediaCropSelection(modal);
9782 };
9783 img.src = apiUrl(row.url || row.thumb_url || "");
9784 }
9785 if (w) w.value = row.width || "";
9786 if (h) h.value = row.height || "";
9787 if (x) x.value = "0";
9788 if (y) y.value = "0";
9789 if (ratio) ratio.checked = true;
9790 modal.hidden = false;
9791 if (img && img.complete) {
9792 fitMediaEditDialog(modal);
9793 renderMediaCropSelection(modal);
9794 }
9795 }
9796
9797 function editMedia(root, cfg, payload, options) {
9798 options = options || {};
9799 const url = cfgUrl(cfg, "editmedia");
9800 if (!url) {
9801 status(root, "Medienbearbeitung ist nicht konfiguriert.", "error");
9802 return Promise.resolve();
9803 }
9804 return fetchJson(apiUrl(url), {
9805 method: "POST",
9806 headers: { "Content-Type": "application/json" },
9807 body: JSON.stringify(payload || {})
9808 }).then(data => {
9809 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "edit failed");
9810 if (options.silent !== true) status(root, options.successMsg || "Bild bearbeitet.", "success");
9811 if (options.reload === false) return data;
9812 return loadMedia(root, cfg).then(() => data);
9813 }).catch(err => {
9814 dbx.error("[cms] media edit failed", err);
9815 if (options.silent !== true) status(root, err && err.message ? err.message : "Bild konnte nicht bearbeitet werden.", "error");
9816 return false;
9817 });
9818 }
9819
9820 function mediaRowsForResize(root, scope, modal) {
9821 if (modal) {
9822 if (scope === "all") {
9823 return mediaBrowserAllRows(modal).filter(canEditImage);
9824 }
9825 if (scope === "visible") {
9826 return mediaBrowserRows(modal).filter(canEditImage);
9827 }
9828 return selectedMediaBrowserImageRows(modal);
9829 }
9830
9831 const rows = state(root).mediaRows || [];
9832 if (scope === "all") {
9833 return rows.filter(canEditImage);
9834 }
9835 if (scope === "visible") {
9836 const visibleIds = qsa(root, ".dbx-cms-media-item").map(el => Number(el.getAttribute("data-media-id") || 0)).filter(Boolean);
9837 return rows.filter(row => visibleIds.includes(Number(row.id || 0)) && canEditImage(row));
9838 }
9839 const ids = qsa(root, "[data-cms-media-select]:checked").map(el => Number(el.value || 0)).filter(Boolean);
9840 return rows.filter(row => ids.includes(Number(row.id || 0)) && canEditImage(row));
9841 }
9842
9843 function bulkResizeMedia(root, cfg, scope, modal) {
9844 scope = scope || "selected";
9845 const rows = mediaRowsForResize(root, scope, modal);
9846 const ids = rows.map(row => Number(row.id || 0)).filter(Boolean);
9847 const host = batchControlHost(modal) || modal || root;
9848 const width = Number(qs(host, "[data-cms-bulk-resize-width]")?.value || 0);
9849 const height = Number(qs(host, "[data-cms-bulk-resize-height]")?.value || 0);
9850 const ratio = qs(host, "[data-cms-bulk-resize-ratio]")?.checked !== false;
9851 if (!ids.length) {
9852 status(root, scope === "selected" ? "Bitte erst Bilder auswaehlen." : "Keine bearbeitbaren Bilder gefunden.", "error");
9853 return;
9854 }
9855 if (!width && !height) {
9856 status(root, "Bitte Breite oder Hoehe fuer Resize eintragen.", "error");
9857 return;
9858 }
9859 let chain = Promise.resolve({ ok: 0, failed: 0 });
9860 ids.forEach(id => {
9861 chain = chain.then(result => editMedia(root, cfg, {
9862 action: "resize",
9863 id,
9864 width,
9865 height,
9866 ratio: ratio ? 1 : 0
9867 }, {
9868 reload: false,
9869 silent: true
9870 }).then(ok => {
9871 if (ok) result.ok++;
9872 else result.failed++;
9873 status(root, "Resize laeuft: " + result.ok + " von " + ids.length + " Bildern bearbeitet.", "info");
9874 return result;
9875 }));
9876 });
9877
9878 chain.then(result => {
9879 if (!result.ok) {
9880 status(root, "Keine Bilder konnten resized werden.", "error");
9881 return;
9882 }
9883 status(root, result.failed ? (result.ok + " Bilder resized, " + result.failed + " fehlgeschlagen.") : (result.ok + " Bilder resized."), result.failed ? "error" : "success");
9884 loadMedia(root, cfg);
9885 if (!modal || modal.hidden) return;
9886 openMediaBrowser(modal.__dbxCmsRoot || root, browserCfg(modal), {
9887 mode: modal.__dbxCmsMediaMode || "editor",
9888 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
9889 mediaFolder: modal.__dbxCmsMediaFolder || "",
9890 formDataExtra: modal.__dbxCmsFormDataExtra || null,
9891 afterAssign: modal.__dbxCmsAfterAssign
9892 });
9893 });
9894 }
9895
9896 function assignMedia(root, cfg, row, slot) {
9897 const url = cfgUrl(cfg, "assignmedia");
9898 const isFolderEditing = root.classList.contains("is-folder-editing");
9899 let pageId = Number(getField(root, "id") || 0);
9900 const folderId = isFolderEditing
9901 ? Number(getFolderField(root, "id") || 0)
9902 : Number(getField(root, "folder") || 0);
9903 const mediaId = Number(row && row.id || 0);
9904 const targetSlot = slot || currentMediaSlot(root);
9905 if (isFolderEditing && targetSlot === "hero") pageId = 0;
9906 if (!url || !mediaId || (!pageId && !(folderId && targetSlot === "hero"))) {
9907 status(root, "Bitte erst eine Seite und ein Bild waehlen.", "error");
9908 return Promise.resolve();
9909 }
9910
9911 return fetchJson(apiUrl(url), {
9912 method: "POST",
9913 headers: { "Content-Type": "application/json" },
9914 body: JSON.stringify({
9915 id: mediaId,
9916 content_id: pageId,
9917 folder_id: folderId,
9918 slot: targetSlot
9919 })
9920 }).then(data => {
9921 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "assign failed");
9922 status(root, "Bild der Seite zugeordnet.", "success");
9923 const assignedRow = mediaRowWithUsage(data.row || row, data.usage || {}, targetSlot);
9924 return loadMedia(root, cfg).then(() => {
9925 upsertLocalMediaRow(root, assignedRow);
9926 return assignedRow;
9927 });
9928 }).catch(err => {
9929 dbx.error("[cms] media assign failed", err);
9930 status(root, "Bild konnte nicht zugeordnet werden.", "error");
9931 return null;
9932 });
9933 }
9934
9935 function saveMediaOrder(root, cfg, list) {
9936 const url = cfgUrl(cfg, "sortmedia");
9937 const pageId = Number(getField(root, "id") || 0);
9938 if (!url || !pageId) return Promise.resolve();
9939 const scope = list || root;
9940 const ids = qsa(scope, ".dbx-cms-media-item").map(item => Number(item.getAttribute("data-usage-id") || item.getAttribute("data-media-id") || 0)).filter(Boolean);
9941 if (!ids.length) return Promise.resolve();
9942 const slot = String((list && list.getAttribute("data-cms-media")) || "");
9943
9944 return fetchJson(apiUrl(url), {
9945 method: "POST",
9946 headers: { "Content-Type": "application/json" },
9947 body: JSON.stringify({
9948 content_id: pageId,
9949 slot: slot || "all",
9950 ids
9951 })
9952 }).then(data => {
9953 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "sort failed");
9954 status(root, "Medien sortiert.", "success");
9955 return loadMedia(root, cfg);
9956 }).catch(err => {
9957 dbx.error("[cms] media sort failed", err);
9958 status(root, "Medien konnten nicht sortiert werden.", "error");
9959 });
9960 }
9961
9962 function setMediaSlot(root, cfg, id, slot) {
9963 const url = cfgUrl(cfg, "setmediaslot");
9964 const pageId = Number(getField(root, "id") || 0);
9965 if (!url || !id || !slot) return Promise.resolve();
9966 const item = qs(root, `.dbx-cms-media-item[data-media-id="${String(id).replace(/"/g, '\\"')}"]`);
9967 const usageId = Number(item?.getAttribute("data-usage-id") || 0);
9968
9969 return fetchJson(apiUrl(url), {
9970 method: "POST",
9971 headers: { "Content-Type": "application/json" },
9972 body: JSON.stringify({
9973 id: Number(id),
9974 usage_id: usageId,
9975 content_id: pageId,
9976 slot
9977 })
9978 }).then(data => {
9979 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "slot failed");
9980 status(root, "Medium zugeordnet.", "success");
9981 const updatedRow = data.row ? mediaRowWithUsage(data.row, data.usage || {}, slot) : null;
9982 return loadMedia(root, cfg).then(() => {
9983 if (updatedRow) upsertLocalMediaRow(root, updatedRow);
9984 return data;
9985 });
9986 }).catch(err => {
9987 dbx.error("[cms] media slot failed", err);
9988 status(root, "Medium konnte nicht zugeordnet werden.", "error");
9989 return loadMedia(root, cfg).then(() => null);
9990 }).finally(() => {
9991 clearCmsLoading(root);
9992 window.setTimeout(() => clearCmsLoading(root), 50);
9993 });
9994 }
9995
9996 function addExternalVideo(root, cfg, form, options) {
9997 options = options || {};
9998 const url = cfgUrl(cfg, "externalvideo");
9999 const externalUrl = String(qs(form, 'input[name="url"]')?.value || "").trim();
10000 const isFolderEditing = !!closestElement(form, "[data-cms-folder-panel]");
10001 const pageId = isFolderEditing ? 0 : Number(getField(root, "id") || 0);
10002 const folderId = isFolderEditing
10003 ? Number(getFolderField(root, "id") || 0)
10004 : Number(getField(root, "folder") || 0);
10005 const slotInput = qs(form, 'input[name="slot"]');
10006 const slot = options.slot || (slotInput && slotInput.value ? slotInput.value : currentMediaSlot(root));
10007 if (!url) {
10008 status(root, "Externe Videos sind nicht konfiguriert.", "error");
10009 return Promise.resolve(null);
10010 }
10011 if (!externalUrl) {
10012 status(root, "Bitte eine YouTube-URL eintragen.", "error");
10013 return Promise.resolve(null);
10014 }
10015 if (!pageId && !(folderId && slot === "hero")) {
10016 status(root, "Bitte erst eine Seite speichern/auswaehlen.", "error");
10017 return Promise.resolve(null);
10018 }
10019
10020 return fetchJson(apiUrl(url), {
10021 method: "POST",
10022 headers: { "Content-Type": "application/json" },
10023 body: JSON.stringify({
10024 provider: "youtube",
10025 url: externalUrl,
10026 content_id: pageId,
10027 folder_id: folderId || 0,
10028 slot,
10029 media_folder: "youtube"
10030 })
10031 }).then(data => {
10032 if (!data || !data.ok || !data.row) throw new Error(data && data.msg ? data.msg : "external video failed");
10033 status(root, "Externes Video hinzugefuegt.", "success");
10034 const assignedRow = mediaRowWithUsage(data.row, data.usage || {}, slot);
10035 if (options.insertExternal) insertMediaRow(root, assignedRow);
10036 form.reset();
10037 return loadMedia(root, cfg).then(() => {
10038 upsertLocalMediaRow(root, assignedRow);
10039 if (typeof options.afterExternal === "function") options.afterExternal(Object.assign({}, data, { row: assignedRow }));
10040 return Object.assign({}, data, { row: assignedRow });
10041 });
10042 }).catch(err => {
10043 dbx.error("[cms] external video failed", err);
10044 status(root, err && err.message ? err.message : "Externes Video konnte nicht hinzugefuegt werden.", "error");
10045 return null;
10046 });
10047 }
10048
10049 function uploadMedia(root, cfg, form, options) {
10050 options = options || {};
10051 const url = cfgUrl(cfg, "upload");
10052 const moduleUploadUrl = String(url || "").indexOf("modul_images_upload") >= 0;
10053 options.moduleUpload = moduleUploadUrl;
10054 const isFolderEditing = root.classList.contains("is-folder-editing");
10055 let pageId = Number(getField(root, "id") || 0);
10056 const folderId = isFolderEditing
10057 ? Number(getFolderField(root, "id") || 0)
10058 : Number(getField(root, "folder") || 0);
10059 const slotInput = qs(form, 'input[name="slot"]');
10060 const slot = slotInput && slotInput.value ? slotInput.value : currentMediaSlot(root);
10061 if (isFolderEditing && slot === "hero") pageId = 0;
10062 const browserModal = state(root).mediaBrowser;
10063 const browserOpen = browserModal && !browserModal.hidden;
10064 if (!url) {
10065 status(root, "Upload ist nicht konfiguriert.", "error");
10066 return;
10067 }
10068 if (!options.pickMode && !options.moduleUpload && !browserOpen && !pageId && !(folderId && slot === "hero")) {
10069 status(root, "Bitte erst eine Seite speichern/auswaehlen.", "error");
10070 return;
10071 }
10072 const files = selectedUploadFiles(form);
10073 if (!files.length) {
10074 status(root, "Bitte zuerst eine Datei auswaehlen oder in die Upload-Zone ziehen.", "error");
10075 updateUploadLabel(form);
10076 return;
10077 }
10078 const selectedUploadFolder = qs(form, "[data-cms-upload-folder]")?.value || "";
10079 const maxUpload = Number(cfg && cfg.uploadmax || 0);
10080 if (maxUpload > 0) {
10081 const tooLarge = files.find(file => Number(file.size || 0) > maxUpload);
10082 if (tooLarge) {
10083 status(root, "Datei ist zu gross: " + tooLarge.name + " (" + formatBytes(tooLarge.size) + "). Erlaubt sind maximal " + formatBytes(maxUpload) + ".", "error");
10084 return;
10085 }
10086 }
10087
10088 const uploadOne = function (file) {
10089 const body = new FormData(form);
10090 body.delete("file");
10091 body.append("file", file, file.name);
10092 if (!options.moduleUpload) {
10093 body.set("content_id", pageId);
10094 body.set("folder_id", folderId || "0");
10095 }
10096 const uploadFolderSelect = qs(form, "[data-cms-upload-folder]");
10097 if (!options.moduleUpload) {
10098 if (String(file.type || "").startsWith("video/")) {
10099 const videoFolder = firstMediaFolderOption(uploadFolderSelect, "videos") || firstMediaFolderOption(uploadFolderSelect, "video");
10100 if (videoFolder && !/^(videos|video)(\/|$)/.test(String(body.get("media_folder") || ""))) {
10101 body.set("media_folder", videoFolder);
10102 }
10103 } else if (String(file.type || "").startsWith("image/")) {
10104 const imageFolder = firstMediaFolderOption(uploadFolderSelect, "img/");
10105 if (imageFolder && String(body.get("media_folder") || "").indexOf("img/") !== 0) {
10106 body.set("media_folder", imageFolder);
10107 }
10108 }
10109 }
10110 if (form.hasAttribute("data-cms-upload")) {
10111 body.set("slot", slot);
10112 }
10113 Object.keys(options.formDataExtra || {}).forEach(key => {
10114 if (options.formDataExtra[key] != null) {
10115 body.set(key, options.formDataExtra[key]);
10116 }
10117 });
10118
10119 return fetchJson(apiUrl(url), {
10120 method: "POST",
10121 body: body,
10122 timeout: 60000
10123 }).then(data => {
10124 if (!data || !data.ok) throw new Error(data && data.msg ? data.msg : "upload failed");
10125 data.upload_folder = String(body.get("media_folder") || "");
10126 return data;
10127 });
10128 };
10129
10130 files.reduce((chain, file) => {
10131 return chain.then(results => uploadOne(file).then(data => {
10132 results.push(data);
10133 return results;
10134 }));
10135 }, Promise.resolve([]))
10136 .then(data => {
10137 const rows = data.map(item => item && item.row ? mediaRowWithUsage(item.row, item.usage || {}, slot) : null).filter(Boolean);
10138 const moduleUpload = data.some(item => item && Array.isArray(item.items));
10139 const uploadMsg = rows.length > 1
10140 ? rows.length + " Medien hochgeladen."
10141 : (moduleUpload ? "Modulbild gespeichert." : "Medium hochgeladen.");
10142 status(root, uploadMsg, "success");
10143 if (options.insertUploaded) rows.forEach(row => insertMediaRow(root, row));
10144 form.reset();
10145 const uploadFolderAfterReset = qs(form, "[data-cms-upload-folder]");
10146 const refreshUploadFolder = data.find(item => item && item.upload_folder)?.upload_folder
10147 || (rows[0] && rows[0].media_folder)
10148 || selectedUploadFolder
10149 || "";
10150 if (uploadFolderAfterReset && refreshUploadFolder && Array.from(uploadFolderAfterReset.options).some(option => option.value === refreshUploadFolder)) {
10151 uploadFolderAfterReset.value = refreshUploadFolder;
10152 }
10153 updateUploadLabel(form);
10154 const modal = state(root).mediaBrowser;
10155 const browserOpen = modal && !modal.hidden;
10156 let reload;
10157 if (moduleUpload && options.pickMode) {
10158 reload = Promise.resolve();
10159 } else if (browserOpen) {
10160 reload = Promise.resolve();
10161 } else {
10162 reload = loadMedia(root, cfg);
10163 }
10164 reload.then(() => rows.forEach(row => upsertLocalMediaRow(root, row)));
10165 if (typeof options.afterUpload === "function") {
10166 reload.then(() => options.afterUpload({
10167 ok: 1,
10168 success: true,
10169 row: rows[0] || null,
10170 rows: rows,
10171 responses: data,
10172 uploadFolder: refreshUploadFolder
10173 }));
10174 }
10175 return reload;
10176 })
10177 .catch(err => {
10178 dbx.error("[cms] upload failed", err);
10179 const message = err && err.message ? String(err.message) : "";
10180 const uploadErr = message === "Decoding failed"
10181 ? "Upload fehlgeschlagen. Die Serverantwort konnte nicht gelesen werden; bitte Upload-Limit und Dateigroesse pruefen."
10182 : (message || "Upload fehlgeschlagen.");
10183 status(root, uploadErr, "error");
10184 })
10185 .finally(() => {
10186 clearCmsLoading(root);
10187 });
10188 }
10189
10190 function insertMarker(root, marker, label) {
10191 const editor = qs(root, "[data-cms-editor]");
10192 if (!editor) return;
10193 if (marker === "dbx:split") marker = "dbx:col2";
10194 insertEditorMarkerElement(root, marker, label);
10195 }
10196
10197 function bind(root, cfg) {
10198 initDetailsToggles(root);
10199 initCmsSelects(root);
10200 bindAdminTreeOutsideClose(root, cfg || {});
10201 bindViewTreeHover(root, cfg || {});
10202 bindCmsKeyboardShortcuts(root, cfg || {});
10203 updateHeaderActionTooltips(root);
10204
10205 if (!root.__dbxCmsMediaProcessEvents && dbx.event && typeof dbx.event.on === "function") {
10206 root.__dbxCmsMediaProcessEvents = true;
10207 dbx.event.on("process:after", data => {
10208 const proc = data && data.root;
10209 if (!proc || !proc.getAttribute) return;
10210 const modal = closestElement(proc, "[data-cms-media-browser]");
10211 if (!modal || !root.contains(modal)) return;
10212 if (proc.getAttribute("data-process-status") !== "finished" || proc.__dbxCmsFinishedHandled) return;
10213 proc.__dbxCmsFinishedHandled = true;
10214 status(root, "Medienwartung abgeschlossen.", "success");
10215 openMediaBrowser(root, cfg, {
10216 mode: modal.__dbxCmsMediaMode || "editor",
10217 slot: modal.__dbxCmsAssignSlot || currentMediaSlot(root),
10218 mediaFolder: modal.__dbxCmsMediaFolder || "",
10219 formDataExtra: modal.__dbxCmsFormDataExtra || null,
10220 afterAssign: modal.__dbxCmsAfterAssign
10221 });
10222 });
10223 }
10224
10225 if (!root.__dbxCmsTreePointerBound) {
10226 root.__dbxCmsTreePointerBound = true;
10227 let treePress = null;
10228 root.addEventListener("pointerdown", e => {
10229 if (e.button !== 0) return;
10230 const row = closestElement(e.target, ".dbx-cms-tree-row");
10231 if (!row || !root.contains(row) || closestElement(e.target, "[data-cms-tree-toggle]") || closestElement(e.target, "[data-cms-folder-edit-btn]")) return;
10232 treePress = { row, x: e.clientX, y: e.clientY, pointerId: e.pointerId, dragIntent: false };
10233 root.__dbxCmsTreePress = treePress;
10234 }, true);
10235 root.addEventListener("pointermove", e => {
10236 if (!treePress || treePress.pointerId !== e.pointerId) return;
10237 if (Math.abs(e.clientX - treePress.x) > 8 || Math.abs(e.clientY - treePress.y) > 8) {
10238 treePress.dragIntent = true;
10239 }
10240 }, true);
10241 root.addEventListener("pointerup", e => {
10242 if (e.button !== 0 || !treePress || treePress.pointerId !== e.pointerId) return;
10243 const start = treePress;
10244 treePress = null;
10245 root.__dbxCmsTreePress = null;
10246 const row = closestElement(e.target, ".dbx-cms-tree-row");
10247 if (!row || row !== start.row || row.classList.contains("is-dragging")) return;
10248 if (Math.abs(e.clientX - start.x) > 8 || Math.abs(e.clientY - start.y) > 8) return;
10249 activateTreeRow(root, cfg, row);
10250 }, true);
10251 root.addEventListener("pointercancel", e => {
10252 if (!treePress || treePress.pointerId !== e.pointerId) return;
10253 treePress = null;
10254 root.__dbxCmsTreePress = null;
10255 }, true);
10256 }
10257
10258 root.addEventListener("click", e => {
10259 if (!closestElement(e.target, ".dbx-cms-select")) {
10260 closeCmsSelects(root);
10261 }
10262
10263 const row = closestElement(e.target, ".dbx-cms-tree-row");
10264 if (row && root.contains(row)) {
10265 const editBtn = closestElement(e.target, "[data-cms-folder-edit-btn]");
10266 if (editBtn && root.contains(editBtn)) {
10267 e.preventDefault();
10268 e.stopPropagation();
10269 openFolderEditorFromRow(root, cfg, row);
10270 return;
10271 }
10272 const rightsEdit = closestElement(e.target, "[data-cms-folder-edit]");
10273 if (rightsEdit && root.contains(rightsEdit) && row.getAttribute("data-type") === "folder") {
10274 e.preventDefault();
10275 e.stopPropagation();
10276 openFolderEditorFromRow(root, cfg, row);
10277 return;
10278 }
10279 const toggle = closestElement(e.target, "[data-cms-tree-toggle]");
10280 if (toggle && root.contains(toggle)) {
10281 e.preventDefault();
10282 e.stopPropagation();
10283 toggleTreeFolder(root, row);
10284 return;
10285 }
10286 if (row.getAttribute("data-type") === "folder") {
10287 selectTreeFolder(root, cfg, row);
10288 return;
10289 }
10290 activateTreeRow(root, cfg, row);
10291 return;
10292 }
10293
10294 const folderClose = closestElement(e.target, "[data-cms-folder-close]");
10295 if (folderClose && root.contains(folderClose)) {
10296 hideFolderEditor(root);
10297 return;
10298 }
10299
10300 const action = closestElement(e.target, "[data-cms-action]");
10301 if (action && root.contains(action)) {
10302 e.preventDefault();
10303 e.stopPropagation();
10304 if (e.stopImmediatePropagation) e.stopImmediatePropagation();
10305
10306 const name = action.getAttribute("data-cms-action");
10307 if (name === "toggle-tree-panel") toggleTreePanel(root);
10308 if (name === "toggle-right-panel") toggleRightPanel(root, action);
10309 if (name === "save") saveCurrentCms(root, cfg);
10310 if (name === "save-settings") saveCurrentCms(root, cfg);
10311 if (name === "delete") deleteCurrentCms(root, cfg);
10312 if (name === "delete-page") deletePage(root, cfg);
10313 if (name === "save-folder") saveFolder(root, cfg);
10314 if (name === "delete-folder") deleteFolder(root, cfg);
10315 if (name === "reload") reloadCms(root, cfg);
10316 if (name === "open-admin") openContentAdmin(root);
10317 if (name === "duplicate-page") duplicateCurrentPage(root, cfg);
10318 if (name === "new-page") {
10319 const folder = state(root).selectedFolder || Number(getField(root, "folder") || 0);
10320 fetchJson(apiUrl(cfgUrl(cfg, "newpage"), { folder })).then(data => {
10321 if (data && data.ok) {
10322 return loadTree(root, cfg).then(() => loadPage(root, cfg, data.id)).then(() => {
10323 maybeOpenLngProvisionAfterCreate(root, cfg, data);
10324 });
10325 }
10326 throw new Error(data && data.msg ? data.msg : "new page failed");
10327 }).catch(err => {
10328 dbx.error("[cms] new page failed", err);
10329 status(root, err && err.message ? err.message : "Seite konnte nicht angelegt werden.", "error");
10330 });
10331 }
10332 if (name === "new-folder") {
10333 const parent = state(root).selectedFolder || Number(getField(root, "folder") || 0) || 0;
10334 status(root, "Bezeichnung fuer neuen Ordner eintragen und speichern.", "info");
10335 showFolderEditor(root, {
10336 _id: 0,
10337 _title: "",
10338 _parent: parent,
10339 _rights: parent > 0 ? "parent" : "*",
10340 _template: ""
10341 });
10342 }
10343 if (name === "preview") {
10344 openPreview(root);
10345 }
10346 if (name === "lng-provision") {
10347 openLngProvisionDialog(root, cfg);
10348 }
10349 if (name === "lng-reset-sync") {
10350 resetLngSync(root, cfg);
10351 }
10352 if (name === "assign-media") {
10353 openMediaBrowser(root, cfg, { mode: "assign", slot: action.getAttribute("data-cms-slot") || currentMediaSlot(root) });
10354 }
10355 if (name === "bulk-resize-media") {
10356 bulkResizeMedia(root, cfg, action.getAttribute("data-cms-resize-scope") || "selected");
10357 }
10358 if (name === "assign-hero-media") {
10359 openMediaBrowser(root, cfg, {
10360 mode: "assign",
10361 slot: "hero",
10362 singlePick: true,
10363 afterAssign(row) {
10364 if (!row || !row.id) return;
10365 state(root).heroPreviewRow = row;
10366 setField(root, "hero_image_id", row.id);
10367 if (!getField(root, "hero_template") || getField(root, "hero_template") === "parent" || getField(root, "hero_template") === "none") {
10368 setField(root, "hero_template", "image-hero");
10369 }
10370 markDirty(root);
10371 renderHeroPreview(root);
10372 renderMedia(root);
10373 status(root, "Hero-Bild ausgewaehlt.", "success");
10374 }
10375 });
10376 }
10377 if (name === "assign-seo-media") {
10378 openMediaBrowser(root, cfg, {
10379 mode: "assign",
10380 slot: "seo",
10381 afterAssign(row) {
10382 if (!row || !row.id) return;
10383 state(root).seoPreviewRow = row;
10384 setField(root, "seo_image_id", row.id);
10385 markDirty(root);
10386 renderSeoPreview(root);
10387 status(root, "OG-Bild ausgewaehlt.", "success");
10388 }
10389 });
10390 }
10391 if (name === "clear-seo-media") {
10392 state(root).seoPreviewRow = null;
10393 setField(root, "seo_image_id", 0);
10394 markDirty(root);
10395 renderSeoPreview(root);
10396 status(root, "OG-Bild entfernt.", "success");
10397 }
10398 return;
10399 }
10400
10401 const browserClose = closestElement(e.target, "[data-cms-media-browser-close]");
10402 if (browserClose && root.contains(browserClose)) {
10403 const modal = closestElement(browserClose, "[data-cms-media-browser]");
10404 if (modal) modal.hidden = true;
10405 clearCmsLoading(root);
10406 return;
10407 }
10408
10409 const browserMaintenance = closestElement(e.target, "[data-cms-media-maintenance]");
10410 if (browserMaintenance && root.contains(browserMaintenance)) {
10411 const batchPanel = closestElement(browserMaintenance, "[data-cms-media-batch-window]");
10412 const browserModal = batchPanel && batchPanel.__dbxCmsBrowserModal
10413 ? batchPanel.__dbxCmsBrowserModal
10414 : closestElement(browserMaintenance, "[data-cms-media-browser]");
10415 renderMediaMaintenanceHome(root, cfg, browserModal, batchPanel || null);
10416 return;
10417 }
10418
10419 const processStart = closestElement(e.target, "[data-cms-media-process-start]");
10420 if (processStart && root.contains(processStart)) {
10421 const batchPanel = closestElement(processStart, "[data-cms-media-batch-window]");
10422 const browserModal = batchPanel && batchPanel.__dbxCmsBrowserModal
10423 ? batchPanel.__dbxCmsBrowserModal
10424 : closestElement(processStart, "[data-cms-media-browser]");
10425 startMediaMaintenance(root, cfg, browserModal, batchPanel || null);
10426 return;
10427 }
10428
10429 const unusedAction = closestElement(e.target, "[data-cms-media-unused-action]");
10430 if (unusedAction && root.contains(unusedAction)) {
10431 const browserModal = closestElement(unusedAction, "[data-cms-media-browser]");
10432 executeUnusedMediaMaintenance(root, cfg, browserModal, unusedAction.getAttribute("data-cms-media-unused-action"));
10433 return;
10434 }
10435
10436 const processClose = closestElement(e.target, "[data-cms-media-process-close]");
10437 if (processClose && root.contains(processClose)) {
10438 const panel = closestElement(processClose, "[data-cms-media-process-panel]");
10439 if (panel) {
10440 panel.hidden = true;
10441 panel.innerHTML = "";
10442 }
10443 const modal = closestElement(processClose, "[data-cms-media-browser]");
10444 if (modal) modal.classList.remove("is-process-open");
10445 clearCmsLoading(root);
10446 return;
10447 }
10448
10449 const browserConfirm = closestElement(e.target, "[data-cms-media-browser-confirm]");
10450 if (browserConfirm && root.contains(browserConfirm)) {
10451 const modal = closestElement(browserConfirm, "[data-cms-media-browser]");
10452 if (modal && modal.__dbxCmsMediaMode === "pick") {
10453 confirmPickMediaBrowser(root, modal);
10454 return;
10455 }
10456 const slot = modal && modal.__dbxCmsAssignSlot || currentMediaSlot(root);
10457 const rows = modal ? selectedMediaBrowserRows(modal) : [];
10458 if (!rows.length) {
10459 status(root, "Bitte mindestens ein Bild auswaehlen.", "error");
10460 return;
10461 }
10462
10463 let chain = Promise.resolve();
10464 rows.forEach(row => {
10465 chain = chain.then(() => assignMedia(root, cfg, row, slot).then(assignedRow => {
10466 if (!assignedRow) return;
10467 if (slot === "inline") {
10468 insertMediaRow(root, assignedRow);
10469 setLocalMediaSlot(root, assignedRow.id, "inline");
10470 }
10471 if (modal && typeof modal.__dbxCmsAfterAssign === "function") {
10472 modal.__dbxCmsAfterAssign(assignedRow);
10473 }
10474 }));
10475 });
10476 chain.then(() => {
10477 if (modal) {
10478 modal.hidden = true;
10479 if (modal.__dbxCmsWindowId && dbx.openWin && typeof dbx.openWin.close === "function") {
10480 dbx.openWin.close(modal.__dbxCmsWindowId);
10481 }
10482 }
10483 clearCmsLoading(root);
10484 status(root, "Auswahl uebernommen.", "success");
10485 });
10486 return;
10487 }
10488
10489 const browserDelete = closestElement(e.target, "[data-cms-media-browser-delete]");
10490 if (browserDelete && root.contains(browserDelete)) {
10491 const item = closestElement(browserDelete, "[data-cms-media-browser-item]");
10492 const modal = closestElement(browserDelete, "[data-cms-media-browser]");
10493 const mode = modal && modal.__dbxCmsMediaMode === "assign" ? "assign" : "editor";
10494 deleteMedia(root, cfg, Number(item?.getAttribute("data-media-id") || 0))
10495 .then(() => openMediaBrowser(root, cfg, { mode }));
10496 return;
10497 }
10498
10499 const browserEdit = closestElement(e.target, "[data-cms-media-browser-edit]");
10500 if (browserEdit && root.contains(browserEdit)) {
10501 const item = closestElement(browserEdit, "[data-cms-media-browser-item]");
10502 openMediaEdit(root, cfg, mediaRowFromItem(item));
10503 return;
10504 }
10505
10506 const browserPick = closestElement(e.target, "[data-cms-media-browser-pick]");
10507 if (browserPick && root.contains(browserPick)) {
10508 const item = closestElement(browserPick, "[data-cms-media-browser-item]") || browserPick;
10509 const mediaRow = mediaRowFromItem(item);
10510 const modal = closestElement(browserPick, "[data-cms-media-browser]");
10511 const mode = modal && modal.__dbxCmsMediaMode ? modal.__dbxCmsMediaMode : "editor";
10512 if (mode === "pick" || mode === "assign") {
10513 if (modal) toggleMediaBrowserSelection(modal, item);
10514 return;
10515 }
10516 assignMedia(root, cfg, mediaRow, "inline").then(assignedRow => {
10517 if (!assignedRow) return;
10518 insertMediaRow(root, assignedRow);
10519 setLocalMediaSlot(root, assignedRow.id, "inline");
10520 if (modal) modal.hidden = true;
10521 clearCmsLoading(root);
10522 });
10523 return;
10524 }
10525
10526 const cmd = closestElement(e.target, "[data-cms-cmd]");
10527 if (cmd && root.contains(cmd)) {
10528 execEditorCommand(root, cmd.getAttribute("data-cms-cmd"));
10529 return;
10530 }
10531
10532 const marker = closestElement(e.target, "[data-cms-marker]");
10533 if (marker && root.contains(marker)) {
10534 insertMarker(root, marker.getAttribute("data-cms-marker"));
10535 return;
10536 }
10537
10538 const mediaRemove = closestElement(e.target, "[data-cms-media-remove]");
10539 if (mediaRemove && root.contains(mediaRemove)) {
10540 const item = closestElement(mediaRemove, ".dbx-cms-media-item");
10541 const id = Number(item?.getAttribute("data-media-id") || 0);
10542 const usageId = Number(item?.getAttribute("data-usage-id") || 0);
10543 const slot = String(item?.getAttribute("data-media-slot") || "");
10544 removeMedia(root, cfg, id, usageId, slot);
10545 return;
10546 }
10547
10548 const mediaEdit = closestElement(e.target, "[data-cms-media-edit-one]");
10549 if (mediaEdit && root.contains(mediaEdit)) {
10550 const item = closestElement(mediaEdit, ".dbx-cms-media-item");
10551 openMediaEdit(root, cfg, mediaRowFromItem(item));
10552 return;
10553 }
10554
10555 const toolsToggle = closestElement(e.target, "[data-cms-media-tools-toggle]");
10556 if (toolsToggle && root.contains(toolsToggle)) {
10557 const menu = qs(root, "[data-cms-media-tools-menu]");
10558 if (menu) menu.hidden = !menu.hidden;
10559 return;
10560 }
10561
10562 if (!closestElement(e.target, ".dbx-cms-media-tools")) {
10563 const menu = qs(root, "[data-cms-media-tools-menu]");
10564 if (menu) menu.hidden = true;
10565 }
10566
10567 const bulkResize = closestElement(e.target, "[data-cms-action='bulk-resize-media']");
10568 if (bulkResize && root.contains(bulkResize)) {
10569 bulkResizeMedia(root, cfg, bulkResize.getAttribute("data-cms-resize-scope") || "selected");
10570 return;
10571 }
10572
10573 const editModal = closestElement(e.target, "[data-cms-media-edit]");
10574 if (editModal && root.contains(editModal)) {
10575 if (closestElement(e.target, "[data-cms-media-edit-close]")) {
10576 editModal.hidden = true;
10577 return;
10578 }
10579 const resize = closestElement(e.target, "[data-cms-media-edit-resize]");
10580 const cropApply = closestElement(e.target, "[data-cms-media-edit-crop-apply]");
10581 const cropSave = closestElement(e.target, "[data-cms-media-edit-crop]");
10582 if (cropApply) {
10583 commitMediaEditAction(root, cfg, editModal, "crop", { closeAfter: false });
10584 return;
10585 }
10586 if (cropSave) {
10587 previewMediaCrop(root, editModal);
10588 return;
10589 }
10590 if (resize) {
10591 commitMediaEditAction(root, cfg, editModal, "resize", { closeAfter: false });
10592 return;
10593 }
10594 }
10595
10596 const videoOptionsModal = closestElement(e.target, "[data-cms-video-options]");
10597 if (videoOptionsModal && root.contains(videoOptionsModal)) {
10598 if (closestElement(e.target, "[data-cms-video-options-close]")) {
10599 closeInlineVideoOptionsWindow(videoOptionsModal);
10600 return;
10601 }
10602 if (closestElement(e.target, "[data-cms-video-options-apply]")) {
10603 applyInlineVideoOptions(root, videoOptionsModal);
10604 return;
10605 }
10606 }
10607
10608 const inlineVideoOptionsOpen = closestElement(e.target, "[data-cms-inline-video-options-open]");
10609 if (inlineVideoOptionsOpen && root.contains(inlineVideoOptionsOpen)) {
10610 const video = inlineVideoTarget(root, inlineVideoOptionsOpen);
10611 if (video) {
10612 e.preventDefault();
10613 e.stopPropagation();
10614 openInlineVideoOptions(root, video);
10615 return;
10616 }
10617 }
10618
10619 const inlineVideoPlay = inlineVideoEventTarget(root, e);
10620 if (inlineVideoPlay && root.contains(inlineVideoPlay) && !closestElement(e.target, ".dbx-cms-context-menu")) {
10621 if (!closestElement(e.target, "[data-cms-inline-video-options-open]")
10622 && (closestElement(e.target, ".dbx-cms-inline-video-play") || !inlineVideoPlay.querySelector(".dbx-cms-inline-video-player, iframe, video"))) {
10623 e.preventDefault();
10624 e.stopPropagation();
10625 playInlineVideoBlock(root, inlineVideoPlay);
10626 return;
10627 }
10628 if (!closestElement(e.target, "[data-cms-inline-video-options-open]") && !isInlineVideoResizeHandleEvent(inlineVideoPlay, e)) {
10629 e.preventDefault();
10630 e.stopPropagation();
10631 openInlineVideoOptions(root, inlineVideoPlay);
10632 return;
10633 }
10634 }
10635
10636 const mediaInsert = closestElement(e.target, "[data-cms-media-insert]");
10637 if (mediaInsert && root.contains(mediaInsert)) {
10638 const item = closestElement(mediaInsert, ".dbx-cms-media-item");
10639 const url = item?.getAttribute("data-url") || "";
10640 if (url) insertMarker(root, "media:" + url);
10641 }
10642
10643 const mediaEmbed = closestElement(e.target, "[data-cms-media-embed]");
10644 if (mediaEmbed && root.contains(mediaEmbed)) {
10645 const item = closestElement(mediaEmbed, ".dbx-cms-media-item");
10646 const mediaRow = mediaRowFromItem(item);
10647 insertMediaRow(root, mediaRow);
10648 setLocalMediaSlot(root, mediaRow.id, "inline");
10649 return;
10650 }
10651 });
10652
10653 const search = qs(root, "[data-cms-search]");
10654 if (search) {
10655 bindTreeSearchClear(root, search);
10656 search.addEventListener("input", () => renderTree(root));
10657 }
10658
10659 qsa(root, "[data-cms-upload], [data-cms-hero-upload]").forEach(upload => {
10660 if (upload.__dbxCmsUploadBound) return;
10661 upload.__dbxCmsUploadBound = true;
10662 upload.addEventListener("submit", e => {
10663 e.preventDefault();
10664 if (upload.hasAttribute("data-cms-hero-upload")) {
10665 uploadMedia(root, cfg, upload, {
10666 afterUpload(data) {
10667 if (!data || !data.row || !data.row.id) return;
10668 state(root).heroPreviewRow = data.row;
10669 setField(root, "hero_image_id", data.row.id);
10670 if (!getField(root, "hero_template") || getField(root, "hero_template") === "parent" || getField(root, "hero_template") === "none") {
10671 setField(root, "hero_template", "image-hero");
10672 }
10673 markDirty(root);
10674 renderHeroPreview(root);
10675 status(root, "Hero-Bild hochgeladen.", "success");
10676 }
10677 });
10678 return;
10679 }
10680 uploadMedia(root, cfg, upload);
10681 });
10682 upload.addEventListener("change", () => updateUploadLabel(upload));
10683 });
10684
10685 qsa(root, "[data-cms-external-video]").forEach(form => {
10686 if (form.__dbxCmsExternalVideoBound) return;
10687 form.__dbxCmsExternalVideoBound = true;
10688 form.addEventListener("submit", e => {
10689 e.preventDefault();
10690 addExternalVideo(root, cfg, form);
10691 });
10692 });
10693
10694 root.addEventListener("keydown", e => {
10695 const row = closestElement(e.target, ".dbx-cms-tree-row");
10696 if (!row || !root.contains(row) || row.getAttribute("data-type") !== "folder") return;
10697 if (e.key === "ArrowLeft") {
10698 e.preventDefault();
10699 toggleTreeFolder(root, row, true);
10700 } else if (e.key === "ArrowRight") {
10701 e.preventDefault();
10702 toggleTreeFolder(root, row, false);
10703 }
10704 });
10705
10706 root.addEventListener("change", e => {
10707 const uploadForm = closestElement(e.target, "[data-cms-upload], [data-cms-hero-upload], [data-cms-browser-upload]");
10708 if (uploadForm && root.contains(uploadForm)) updateUploadLabel(uploadForm);
10709 const mediaFilter = closestElement(e.target, "[data-cms-media-filter]");
10710 if (mediaFilter && root.contains(mediaFilter)) {
10711 state(root).mediaFilter = mediaFilter.value || "all";
10712 syncUploadSlot(root);
10713 renderMedia(root);
10714 }
10715 const ratioInput = closestElement(e.target, "[data-cms-bulk-resize-ratio]");
10716 if (ratioInput && root.contains(ratioInput)) {
10717 status(root, ratioInput.checked ? "Resize behaelt das Seitenverhaeltnis." : "Resize nutzt exakte Breite und Hoehe.", "info");
10718 }
10719 const heroTemplate = closestElement(e.target, '[data-cms-field="hero_template"], [data-cms-folder-field="hero_template"]');
10720 if (heroTemplate && root.contains(heroTemplate)) {
10721 applyHeroTemplateChoice(root, heroTemplate);
10722 }
10723 });
10724
10725 root.addEventListener("input", e => {
10726 const editInput = closestElement(e.target, "[data-cms-media-edit-width], [data-cms-media-edit-height]");
10727 if (editInput && root.contains(editInput)) {
10728 const modal = closestElement(editInput, "[data-cms-media-edit]");
10729 const ratio = qs(modal, "[data-cms-media-edit-ratio]");
10730 if (!modal || !ratio || !ratio.checked) return;
10731 const row = modal.__dbxCmsEditRow || {};
10732 const w = qs(modal, "[data-cms-media-edit-width]");
10733 const h = qs(modal, "[data-cms-media-edit-height]");
10734 autoRatioValue(editInput, editInput === w ? h : w, Number(row.width || 0), Number(row.height || 0));
10735 }
10736 });
10737
10738 root.addEventListener("dragover", e => {
10739 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
10740 if (!dropzone || !root.contains(dropzone)) return;
10741 e.preventDefault();
10742 dropzone.classList.add("is-dragover");
10743 });
10744
10745 root.addEventListener("dragleave", e => {
10746 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
10747 if (!dropzone || !root.contains(dropzone)) return;
10748 dropzone.classList.remove("is-dragover");
10749 });
10750
10751 root.addEventListener("drop", e => {
10752 const dropzone = closestElement(e.target, "[data-cms-dropzone]");
10753 if (!dropzone || !root.contains(dropzone)) return;
10754 e.preventDefault();
10755 dropzone.classList.remove("is-dragover");
10756 const form = closestElement(dropzone, "form");
10757 const files = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length ? e.dataTransfer.files : null;
10758 if (!form || !files || !files.length) return;
10759 if (!setUploadFiles(form, files)) {
10760 status(root, "Datei bitte ueber die Dateiauswahl waehlen.", "error");
10761 }
10762 });
10763
10764 qsa(root, "[data-cms-field]").forEach(el => {
10765 if (el.__dbxCmsDirtyBound) return;
10766 el.__dbxCmsDirtyBound = true;
10767 syncCmsSelect(el);
10768 el.addEventListener("input", () => markDirty(root));
10769 el.addEventListener("change", () => {
10770 syncCmsSelect(el);
10771 if (el.getAttribute("data-cms-field") === "hero_template") applyHeroTemplateChoice(root, el);
10772 markDirty(root);
10773 });
10774 });
10775
10776 root.addEventListener("dragstart", e => {
10777 const media = closestElement(e.target, ".dbx-cms-media-item");
10778 if (media && root.contains(media)) {
10779 e.dataTransfer.effectAllowed = "move";
10780 e.dataTransfer.setData("application/x-dbx-cms-media", media.getAttribute("data-media-id") || "");
10781 media.classList.add("is-dragging");
10782 return;
10783 }
10784
10785 const row = closestElement(e.target, ".dbx-cms-tree-row");
10786 if (!row || !root.contains(row)) return;
10787 const treePress = root.__dbxCmsTreePress;
10788 root.__dbxCmsTreeDrag = treePress && treePress.row === row ? {
10789 row,
10790 x: treePress.x,
10791 y: treePress.y,
10792 dragIntent: treePress.dragIntent,
10793 dropped: false
10794 } : null;
10795 e.dataTransfer.effectAllowed = "move";
10796 e.dataTransfer.setData("application/x-dbx-cms-node", JSON.stringify({
10797 type: row.getAttribute("data-type"),
10798 id: Number(row.getAttribute("data-id") || 0)
10799 }));
10800 row.classList.add("is-dragging");
10801 });
10802
10803 root.addEventListener("dragend", e => {
10804 clearEditorDropMarks(root);
10805 state(root).dragMarker = null;
10806 state(root).pointerDragMarker = null;
10807
10808 const media = closestElement(e.target, ".dbx-cms-media-item");
10809 if (media) media.classList.remove("is-dragging");
10810 qsa(root, ".dbx-cms-media-item.is-drop-before,.dbx-cms-media-item.is-drop-after").forEach(el => el.classList.remove("is-drop-before", "is-drop-after"));
10811
10812 const row = closestElement(e.target, ".dbx-cms-tree-row");
10813 if (row) row.classList.remove("is-dragging");
10814 qsa(root, ".dbx-cms-tree-row.is-drop-target,.dbx-cms-tree-row.is-drop-before,.dbx-cms-tree-row.is-drop-after").forEach(el => {
10815 el.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
10816 });
10817
10818 const treeDrag = root.__dbxCmsTreeDrag;
10819 root.__dbxCmsTreeDrag = null;
10820 if (row && treeDrag && treeDrag.row === row && !treeDrag.dropped && !treeDrag.dragIntent) {
10821 const moved = Math.abs((e.clientX || treeDrag.x) - treeDrag.x) > 8 || Math.abs((e.clientY || treeDrag.y) - treeDrag.y) > 8;
10822 if (!moved) activateTreeRow(root, cfg, row);
10823 }
10824 });
10825
10826 root.addEventListener("dragover", e => {
10827 const media = closestElement(e.target, ".dbx-cms-media-item");
10828 if (media && root.contains(media) && Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-media")) {
10829 e.preventDefault();
10830 e.dataTransfer.dropEffect = "move";
10831 qsa(root, ".dbx-cms-media-item.is-drop-before,.dbx-cms-media-item.is-drop-after").forEach(el => {
10832 if (el !== media) el.classList.remove("is-drop-before", "is-drop-after");
10833 });
10834 const rect = media.getBoundingClientRect();
10835 const before = e.clientY < rect.top + rect.height / 2;
10836 media.classList.toggle("is-drop-before", before);
10837 media.classList.toggle("is-drop-after", !before);
10838 return;
10839 }
10840
10841 const row = closestElement(e.target, ".dbx-cms-tree-row");
10842 if (!row || !root.contains(row)) return;
10843 const type = row.getAttribute("data-type");
10844 const hasData = Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-node");
10845 if (!hasData) return;
10846 let data = null;
10847 try {
10848 data = JSON.parse(e.dataTransfer.getData("application/x-dbx-cms-node") || "{}");
10849 } catch (_) {}
10850 if (!data || !data.type || !data.id) return;
10851 if (root.__dbxCmsTreeDrag) root.__dbxCmsTreeDrag.dragIntent = true;
10852 if (data.type === "folder" && type !== "folder") return;
10853
10854 e.preventDefault();
10855 e.dataTransfer.dropEffect = "move";
10856 qsa(root, ".dbx-cms-tree-row.is-drop-target,.dbx-cms-tree-row.is-drop-before,.dbx-cms-tree-row.is-drop-after").forEach(el => {
10857 if (el !== row) el.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
10858 });
10859
10860 const rect = row.getBoundingClientRect();
10861 const y = rect.height ? (e.clientY - rect.top) / rect.height : 0.5;
10862 if (type === "page") {
10863 const before = y < 0.5;
10864 row.classList.toggle("is-drop-before", before);
10865 row.classList.toggle("is-drop-after", !before);
10866 } else if (data.type === "folder") {
10867 const before = y < 0.28;
10868 const after = y > 0.72;
10869 row.classList.toggle("is-drop-before", before);
10870 row.classList.toggle("is-drop-after", after);
10871 row.classList.toggle("is-drop-target", !before && !after);
10872 } else {
10873 row.classList.add("is-drop-target");
10874 }
10875 });
10876
10877 root.addEventListener("dragleave", e => {
10878 const surface = editorSurface(root);
10879 if (surface && !surface.contains(e.relatedTarget)) {
10880 clearEditorDropMarks(root);
10881 }
10882
10883 const media = closestElement(e.target, ".dbx-cms-media-item");
10884 if (media && !media.contains(e.relatedTarget)) media.classList.remove("is-drop-before", "is-drop-after");
10885
10886 const row = closestElement(e.target, ".dbx-cms-tree-row");
10887 if (row && !row.contains(e.relatedTarget)) row.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
10888 });
10889
10890 root.addEventListener("drop", e => {
10891 const media = closestElement(e.target, ".dbx-cms-media-item");
10892 if (media && root.contains(media) && Array.from(e.dataTransfer.types || []).includes("application/x-dbx-cms-media")) {
10893 e.preventDefault();
10894 const fromId = e.dataTransfer.getData("application/x-dbx-cms-media");
10895 const from = qsa(root, ".dbx-cms-media-item").find(item => item.getAttribute("data-media-id") === fromId);
10896 if (!from || from === media) return;
10897 if (from.parentElement !== media.parentElement) return;
10898 const rect = media.getBoundingClientRect();
10899 const before = e.clientY < rect.top + rect.height / 2;
10900 media.classList.remove("is-drop-before", "is-drop-after");
10901 if (before) media.parentElement.insertBefore(from, media);
10902 else media.parentElement.insertBefore(from, media.nextSibling);
10903 saveMediaOrder(root, cfg, media.parentElement);
10904 return;
10905 }
10906
10907 const row = closestElement(e.target, ".dbx-cms-tree-row");
10908 if (!row || !root.contains(row)) return;
10909 e.preventDefault();
10910 if (root.__dbxCmsTreeDrag) {
10911 root.__dbxCmsTreeDrag.dropped = true;
10912 root.__dbxCmsTreeDrag.dragIntent = true;
10913 }
10914 row.classList.remove("is-drop-target", "is-drop-before", "is-drop-after");
10915
10916 let data = null;
10917 try {
10918 data = JSON.parse(e.dataTransfer.getData("application/x-dbx-cms-node") || "{}");
10919 } catch (_) {}
10920 if (!data || !data.type || !data.id) return;
10921
10922 const targetType = row.getAttribute("data-type");
10923 const targetId = Number(row.getAttribute("data-id") || 0);
10924 let targetFolder = targetType === "folder" ? targetId : Number(row.getAttribute("data-folder") || 0);
10925 const position = {};
10926
10927 if (targetType === "folder" && data.type === "folder") {
10928 const rect = row.getBoundingClientRect();
10929 const y = rect.height ? (e.clientY - rect.top) / rect.height : 0.5;
10930 if (y < 0.28 || y > 0.72) {
10931 targetFolder = Number(row.getAttribute("data-folder") || 0);
10932 if (y < 0.28) position.before_id = targetId;
10933 else position.after_id = targetId;
10934 }
10935 } else if (targetType === "page" && data.type === "page") {
10936 const rect = row.getBoundingClientRect();
10937 if (e.clientY < rect.top + rect.height / 2) position.before_id = targetId;
10938 else position.after_id = targetId;
10939 } else if (targetType !== "folder") {
10940 return;
10941 }
10942
10943 if (Number(data.id) === targetId && (position.before_id || position.after_id)) {
10944 return;
10945 }
10946
10947 if (data.type === "folder" && Number(data.id) === targetFolder) {
10948 status(root, "Ordner kann nicht in sich selbst verschoben werden.", "error");
10949 return;
10950 }
10951
10952 moveNode(root, cfg, data.type, Number(data.id), targetFolder, position);
10953 });
10954 }
10955
10956 bindGlobalCmsEditorEvents();
10957 bindInlineVideoOptionsDocumentEvents();
10958
10959 function bindTreeRuntimeEnhancements(root) {
10960 qsa(root || document, ".dbx-cms[data-dbx]").forEach(el => {
10961 const cfg = cmsConfig(el);
10962 bindAdminTreeOutsideClose(el, cfg || {});
10963 bindViewTreeHover(el, cfg || {});
10964 });
10965 }
10966
10967 const cmsFeature = {
10968 scope: "element",
10969 priority: "mid",
10970 css: [
10971 ["css", "design", "c-cms.css"],
10972 ["css", "design", "c-form.css"],
10973 ["css", "design", "c-grid.css"]
10974 ],
10975 js: [
10976 ["js", "lib", "ajax.js"],
10977 ["js", "lib", "form.js"]
10978 ],
10979
10980 init(el, cfg) {
10981 if (!el || el.__dbxCmsReady) return;
10982 el.__dbxCmsReady = true;
10983 const initialCid = Number(cfg && cfg.cid ? cfg.cid : 0) || 0;
10984 const initialFid = Number(cfg && cfg.fid ? cfg.fid : 0) || 0;
10985 if (initialFid > 0) {
10986 state(el).selectedFolder = initialFid;
10987 state(el).selectedPage = 0;
10988 state(el).selectedType = "folder";
10989 } else if (initialCid > 0) {
10990 state(el).selectedPage = initialCid;
10991 state(el).selectedType = "page";
10992 }
10993 bindStickyHeaderOffset(el);
10994 bindTreeFlyoutPosition(el);
10995 initTreePanelState(el, cfg || {});
10996 initRightPanelState(el);
10997 bindTreeRuntimeEnhancements(el);
10998 if (!isViewMode(cfg || {})) {
10999 ensureJodit().then(ok => {
11000 if (ok) {
11001 initEditor(el, cfg || {});
11002 } else {
11003 dbx.warn("[cms] Jodit not loaded, fallback contenteditable active");
11004 }
11005 });
11006 }
11007 try {
11008 bind(el, cfg || {});
11009 } catch (err) {
11010 dbx.error("[cms] bind failed", err);
11011 }
11012 loadTree(el, cfg || {});
11013 },
11014
11015 rescan(root) {
11016 qsa(root || document, ".dbx-cms[data-dbx]").forEach(el => {
11017 if (el.__dbxCmsReady) return;
11018 const cfgList = dbx.parseData(el.getAttribute("data-dbx"));
11019 const cfg = cfgList.find(item => item.lib === LIB) || {};
11020 this.init(el, cfg);
11021 });
11022 }
11023 };
11024
11025 dbx.cmsMediaBrowser = {
11026 open: openMediaBrowser
11027 };
11028
11029 dbx.feature.register(LIB, cmsFeature);
11030
11031})(window, document);