(function () { const params = new URLSearchParams(window.location.search); if (params.get("edit") !== "1") return; const state = { content: null, textTargets: new Map(), imageTargets: new Map(), sharedTextTargets: new Map(), dirtyText: new Map(), dirtyImages: new Map(), undoStack: [], autoSaveTimer: null, saving: false, toolbar: null, statusEl: null, authToken: null, ownerEmail: "", }; const apiBase = computeApiBasePath(); const TOKEN_STORAGE_KEY = "ikfreunde_editor_token"; const SECTION_ROOTS = { hero: "main section.module-hero-teaser", page_header: "main section.page-header", projects: "main section.module-projects-teaser", services: "main section:has(.services-teaser__content)", team: "main section.text-image", awards: "main section:has(.awards-teaser__content)", contact: "main section.contact-teaser", clients: "main section:has(.clients-teaser__content)", }; const SHARED_ROOTS = { navigation: "header.page-head", cookie_layer: "#cookie-layer", footer: "footer.site-footer", }; init().catch((error) => { console.error("[WYSIWYG] init failed", error); alert("WYSIWYG init failed. Check console."); }); async function init() { injectCssClass(); createToolbar(); await handleResetTokenFlow(); const ready = await ensureEditorSession(); if (!ready) { setStatus("Viewer mode (not authorized)"); return; } const response = await fetch(`${apiBase}/api/content`, { cache: "no-store" }); if (!response.ok) throw new Error(`Failed to load ${apiBase}/api/content`); state.content = await response.json(); mapSectionTextTargets(); mapSharedTextTargets(); mapSectionImageTargets(); mapSharedImageTargets(); setStatus("Edit mode ready"); } function injectCssClass() { document.documentElement.classList.add("ce-edit-mode"); } function createToolbar() { const toolbar = document.createElement("div"); toolbar.className = "ce-toolbar"; toolbar.innerHTML = [ 'Editor Mode', '', '', '', '', 'Loading…', ].join(" "); document.body.appendChild(toolbar); state.toolbar = toolbar; state.statusEl = toolbar.querySelector("[data-ce-status]"); toolbar.querySelector("[data-ce-save]").addEventListener("click", () => saveNow()); toolbar.querySelector("[data-ce-undo]").addEventListener("click", () => undoLast()); toolbar.querySelector("[data-ce-reset]").addEventListener("click", () => triggerResetRequest()); toolbar.querySelector("[data-ce-logout]").addEventListener("click", () => logoutEditor()); } function setStatus(message) { if (state.statusEl) state.statusEl.textContent = message; } async function handleResetTokenFlow() { const resetToken = params.get("reset_token"); if (!resetToken) return; const newPassword = window.prompt("Reset token erkannt. Neues Passwort (mind. 8 Zeichen):"); if (!newPassword) return; if (newPassword.length < 8) { alert("Passwort zu kurz."); return; } const response = await fetch(`${apiBase}/api/editor/reset/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: resetToken, newPassword }), }); if (!response.ok) { const text = await response.text(); alert(`Reset fehlgeschlagen: ${text}`); return; } const url = new URL(window.location.href); url.searchParams.delete("reset_token"); history.replaceState({}, "", url.toString()); alert("Passwort wurde zurückgesetzt. Bitte neu einloggen."); } async function ensureEditorSession() { state.authToken = getStoredToken(); let status = await fetchEditorStatus(); if (!status.claimed) { const claimed = await runClaimOnboarding(); if (!claimed) return false; status = await fetchEditorStatus(); } if (!status.authenticated) { const loggedIn = await runLoginPrompt(status.owner_email || ""); if (!loggedIn) return false; status = await fetchEditorStatus(); if (!status.authenticated) return false; } state.ownerEmail = status.owner_email || ""; return true; } async function fetchEditorStatus() { const response = await fetch(`${apiBase}/api/editor/status`, { headers: authHeaders(), cache: "no-store", }); if (!response.ok) throw new Error("Failed to load editor status."); return response.json(); } async function runClaimOnboarding() { const email = window.prompt("Ersteinrichtung: Editor E-Mail"); if (!email) return false; const password = window.prompt("Ersteinrichtung: Editor Passwort (mind. 8 Zeichen)"); if (!password || password.length < 8) { alert("Passwort zu kurz."); return false; } const response = await fetch(`${apiBase}/api/editor/claim`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (!response.ok) { const text = await response.text(); alert(`Claim fehlgeschlagen: ${text}`); return false; } const data = await response.json(); if (data.token) { state.authToken = data.token; setStoredToken(data.token); } return true; } async function runLoginPrompt(defaultEmail) { const email = window.prompt("Editor Login E-Mail", defaultEmail || ""); if (!email) return false; const password = window.prompt("Editor Passwort"); if (!password) return false; const response = await fetch(`${apiBase}/api/editor/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (!response.ok) { const wantsReset = window.confirm( "Login fehlgeschlagen. Passwort-Reset anfordern (Token wird in content/.editor-reset.json erzeugt)?" ); if (wantsReset) { await triggerResetRequest(email); } return false; } const data = await response.json(); if (data.token) { state.authToken = data.token; setStoredToken(data.token); } return true; } async function triggerResetRequest(prefilledEmail) { const email = prefilledEmail || window.prompt("Passwort-Reset: E-Mail"); if (!email) return; const response = await fetch(`${apiBase}/api/editor/reset/request`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); if (!response.ok) { alert("Reset konnte nicht angefordert werden."); return; } alert( "Reset angefordert. Prüfe im Container die Datei content/.editor-reset.json und nutze den reset_url Link." ); } async function logoutEditor() { try { await fetch(`${apiBase}/api/editor/logout`, { method: "POST", headers: authHeaders(), }); } catch (_e) { // ignore network failure here } clearStoredToken(); state.authToken = null; alert("Abgemeldet. Seite neu laden für Viewer-Modus."); } function authHeaders() { const headers = {}; if (state.authToken) { headers.Authorization = `Bearer ${state.authToken}`; } return headers; } function getStoredToken() { try { return window.sessionStorage.getItem(TOKEN_STORAGE_KEY); } catch (_e) { return null; } } function setStoredToken(token) { try { window.sessionStorage.setItem(TOKEN_STORAGE_KEY, token); } catch (_e) { // ignore storage failures } } function clearStoredToken() { try { window.sessionStorage.removeItem(TOKEN_STORAGE_KEY); } catch (_e) { // ignore storage failures } } function mapSectionTextTargets() { const sections = state.content.sections || {}; const textImageSections = Array.from(document.querySelectorAll("main section.text-image")); Object.keys(sections).forEach((sectionName) => { const sectionData = sections[sectionName]; if (!sectionData || !sectionData.texts) return; let root = null; if (sectionName === "team") root = textImageSections[0] || null; else if (sectionName === "partners") root = textImageSections[1] || null; else root = queryFirstCompat(SECTION_ROOTS[sectionName]); if (!root) return; const nodes = collectVisibleTextNodes(root); const counters = {}; nodes.forEach((node) => { const parent = node.parentElement; if (!parent) return; const tag = parent.tagName.toLowerCase(); counters[tag] = (counters[tag] || 0) + 1; const key = `${tag}_${String(counters[tag]).padStart(3, "0")}`; if (!(key in sectionData.texts)) return; const id = `section:${sectionName}:${key}`; const initialValue = normalizeText(node.nodeValue || ""); const wrapped = wrapTextNode(node, id); if (!wrapped) return; const target = { id, scope: "section", section: sectionName, key, el: wrapped, savedValue: initialValue, currentValue: initialValue, }; state.textTargets.set(id, target); wireTextTarget(target); }); }); } function mapSharedTextTargets() { const shared = state.content.shared || {}; Object.entries(SHARED_ROOTS).forEach(([area, selector]) => { const root = queryFirstCompat(selector); const areaData = shared[area]; if (!root || !areaData || !Array.isArray(areaData.text_keys)) return; const nodes = collectVisibleTextNodes(root); nodes.forEach((node, index) => { const key = areaData.text_keys[index]; if (!key) return; const initial = normalizeText(node.nodeValue || ""); const id = `shared:${key}`; const wrapped = wrapTextNode(node, id); if (!wrapped) return; const target = { id, scope: "shared", area, key, el: wrapped, savedValue: initial, currentValue: initial, }; if (!state.sharedTextTargets.has(key)) state.sharedTextTargets.set(key, []); state.sharedTextTargets.get(key).push(target); state.textTargets.set(`${id}:${area}:${index}`, target); wireTextTarget(target); }); }); } function mapSectionImageTargets() { const sections = state.content.sections || {}; const textImageSections = Array.from(document.querySelectorAll("main section.text-image")); Object.keys(sections).forEach((sectionName) => { const sectionData = sections[sectionName]; if (!sectionData || !sectionData.images) return; let root = null; if (sectionName === "team") root = textImageSections[0] || null; else if (sectionName === "partners") root = textImageSections[1] || null; else root = queryFirstCompat(SECTION_ROOTS[sectionName]); if (!root) return; const images = Array.from(root.querySelectorAll("img[src]")); images.forEach((img, i) => { const key = `img_${String(i + 1).padStart(3, "0")}`; if (!(key in sectionData.images)) return; const id = `section:${sectionName}:${key}`; const target = { id, scope: "section", section: sectionName, key, el: img, savedValue: { src: img.getAttribute("src") || "", alt: img.getAttribute("alt") || "", }, currentValue: { src: img.getAttribute("src") || "", alt: img.getAttribute("alt") || "", }, }; state.imageTargets.set(id, target); wireImageTarget(target); }); }); } function mapSharedImageTargets() { const shared = state.content.shared || {}; Object.entries(SHARED_ROOTS).forEach(([area, selector]) => { const root = queryFirstCompat(selector); const areaData = shared[area]; if (!root || !areaData || !areaData.images) return; const images = Array.from(root.querySelectorAll("img[src]")); images.forEach((img, i) => { const key = `img_${String(i + 1).padStart(3, "0")}`; if (!(key in areaData.images)) return; const id = `shared:${area}:${key}`; const target = { id, scope: "shared", area, key, el: img, savedValue: { src: img.getAttribute("src") || "", alt: img.getAttribute("alt") || "", }, currentValue: { src: img.getAttribute("src") || "", alt: img.getAttribute("alt") || "", }, }; state.imageTargets.set(id, target); wireImageTarget(target); }); }); } function wireTextTarget(target) { target.el.classList.add("ce-text-target"); target.el.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); }); target.el.addEventListener("dblclick", (event) => { event.preventDefault(); event.stopPropagation(); startInlineEdit(target); }); } function startInlineEdit(target) { const el = target.el; if (el.isContentEditable) return; el.setAttribute("contenteditable", "true"); el.classList.add("ce-text-editing"); placeCaretAtEnd(el); const onKeyDown = (e) => { if (e.key === "Enter") { e.preventDefault(); el.blur(); } else if (e.key === "Escape") { e.preventDefault(); el.textContent = target.currentValue; el.blur(); } }; const onBlur = () => { el.removeAttribute("contenteditable"); el.classList.remove("ce-text-editing"); el.removeEventListener("keydown", onKeyDown); el.removeEventListener("blur", onBlur); const nextValue = normalizeText(el.textContent || ""); applyTextChange(target, nextValue, true); queueAutoSave(); }; el.addEventListener("keydown", onKeyDown); el.addEventListener("blur", onBlur); } function applyTextChange(target, nextValue, pushUndo) { const prevValue = target.currentValue; if (nextValue === prevValue) return; if (pushUndo) { state.undoStack.push({ type: "text", id: target.id, prevValue, nextValue }); } if (target.scope === "shared") { const group = state.sharedTextTargets.get(target.key) || []; group.forEach((t) => { t.currentValue = nextValue; t.el.textContent = nextValue; }); state.dirtyText.set(`shared:${target.key}`, nextValue); } else { target.currentValue = nextValue; target.el.textContent = nextValue; state.dirtyText.set(target.id, nextValue); } markDirtyStatus(); } function wireImageTarget(target) { target.el.classList.add("ce-image-target"); target.el.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); openImageOverlay(target); }); } function openImageOverlay(target) { closeImageOverlay(); const overlay = document.createElement("div"); overlay.className = "ce-image-overlay"; overlay.innerHTML = [ '
", ].join(""); const srcInput = overlay.querySelector("[data-ce-src]"); const altInput = overlay.querySelector("[data-ce-alt]"); srcInput.value = target.currentValue.src; altInput.value = target.currentValue.alt; overlay.querySelector("[data-ce-cancel]").addEventListener("click", () => closeImageOverlay()); overlay.querySelector("[data-ce-apply]").addEventListener("click", async () => { const nextSrc = srcInput.value.trim(); const nextAlt = normalizeText(altInput.value || ""); const allow = await checkImageRatioWarning(target.currentValue.src, nextSrc); if (!allow) return; applyImageChange(target, { src: nextSrc || target.currentValue.src, alt: nextAlt }, true); closeImageOverlay(); queueAutoSave(); }); document.body.appendChild(overlay); } function closeImageOverlay() { const existing = document.querySelector(".ce-image-overlay"); if (existing) existing.remove(); } async function checkImageRatioWarning(currentSrc, nextSrc) { if (!nextSrc || nextSrc === currentSrc) return true; try { const currentRatio = await loadImageRatio(currentSrc); const nextRatio = await loadImageRatio(nextSrc); if (!currentRatio || !nextRatio) return true; const diff = Math.abs(nextRatio - currentRatio) / currentRatio; if (diff > 0.15) { return window.confirm( "Warning: the new image ratio differs by more than 15%. Save anyway?" ); } return true; } catch (_e) { return true; } } function loadImageRatio(src) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { if (!img.naturalWidth || !img.naturalHeight) return resolve(null); resolve(img.naturalWidth / img.naturalHeight); }; img.onerror = () => reject(new Error("image load failed")); img.src = src; }); } function applyImageChange(target, nextValue, pushUndo) { const prevValue = { ...target.currentValue }; if (prevValue.src === nextValue.src && prevValue.alt === nextValue.alt) return; if (pushUndo) { state.undoStack.push({ type: "image", id: target.id, prevValue, nextValue: { ...nextValue }, }); } target.currentValue = { ...nextValue }; target.el.setAttribute("src", nextValue.src); target.el.setAttribute("alt", nextValue.alt); state.dirtyImages.set(target.id, { ...nextValue }); markDirtyStatus(); } function undoLast() { const action = state.undoStack.pop(); if (!action) return; if (action.type === "text") { const target = findTextTargetById(action.id); if (!target) return; applyTextChange(target, action.prevValue, false); return; } if (action.type === "image") { const target = state.imageTargets.get(action.id); if (!target) return; applyImageChange(target, action.prevValue, false); } } function findTextTargetById(id) { for (const target of state.textTargets.values()) { if (target.id === id) return target; } return null; } function queueAutoSave() { clearTimeout(state.autoSaveTimer); state.autoSaveTimer = setTimeout(() => saveNow(), 500); } async function saveNow() { if (state.saving) return; if (state.dirtyText.size === 0 && state.dirtyImages.size === 0) { setStatus("No changes"); return; } state.saving = true; setStatus("Saving…"); const textChanges = []; const imageChanges = []; for (const [id, value] of state.dirtyText.entries()) { if (id.startsWith("shared:")) { textChanges.push({ scope: "shared", key: id.replace("shared:", ""), value }); } else { const [, section, key] = id.split(":"); textChanges.push({ scope: "section", section, key, value }); } } for (const [id, value] of state.dirtyImages.entries()) { const parts = id.split(":"); if (parts[0] === "shared") { imageChanges.push({ scope: "shared", area: parts[1], key: parts[2], ...value }); } else { imageChanges.push({ scope: "section", section: parts[1], key: parts[2], ...value }); } } try { const response = await fetch(`${apiBase}/api/save`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ textChanges, imageChanges }), }); if (!response.ok) { const text = await response.text(); throw new Error(text || "Save failed"); } markSaved(); setStatus("Saved"); } catch (error) { console.error("[WYSIWYG] save error", error); setStatus("Save failed"); alert("Save failed. Check console."); } finally { state.saving = false; } } function markSaved() { for (const target of state.textTargets.values()) { target.savedValue = target.currentValue; } for (const target of state.imageTargets.values()) { target.savedValue = { ...target.currentValue }; } state.dirtyText.clear(); state.dirtyImages.clear(); } function markDirtyStatus() { const total = state.dirtyText.size + state.dirtyImages.size; setStatus(total > 0 ? `${total} unsaved change(s)` : "No changes"); } function collectVisibleTextNodes(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node || !node.parentElement) return NodeFilter.FILTER_REJECT; const parentTag = node.parentElement.tagName.toLowerCase(); if (["script", "style", "noscript", "template", "svg", "path", "defs"].includes(parentTag)) { return NodeFilter.FILTER_REJECT; } if (normalizeText(node.nodeValue || "") === "") return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }); const nodes = []; let current = walker.nextNode(); while (current) { nodes.push(current); current = walker.nextNode(); } return nodes; } function wrapTextNode(node, id) { if (!node.parentNode) return null; const span = document.createElement("span"); span.className = "ce-inline-text"; span.dataset.ceTextId = id; span.textContent = node.nodeValue || ""; node.parentNode.replaceChild(span, node); return span; } function placeCaretAtEnd(el) { el.focus(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel = window.getSelection(); if (!sel) return; sel.removeAllRanges(); sel.addRange(range); } function normalizeText(value) { return String(value || "").replace(/\s+/g, " ").trim(); } function queryFirstCompat(selector) { if (!selector) return null; if (selector.includes(":has(")) { const base = selector.slice(0, selector.indexOf(":has(")); const inner = selector.slice(selector.indexOf(":has(") + 5, -1); const candidates = Array.from(document.querySelectorAll(base)); return candidates.find((el) => el.querySelector(inner)) || null; } return document.querySelector(selector); } function computeApiBasePath() { const path = window.location.pathname || "/"; const parts = path.split("/").filter(Boolean); if (parts.length === 0) return ""; return `/${parts[0]}`; } })();