Files
Francesco Lorenzo D'Amico ec8f55555e
Deploy Staging / deploy (push) Successful in 1m15s
Fix character-counter leak into saved values and hero/heading layout
2026-09-11 14:38:09 +02:00

818 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Seiten-Editor fuer /Admin?edit=true
//
// Der Inhalt wird nicht in einer JavaScript-Kopie gehalten, sondern jedes Mal
// direkt aus dem DOM gelesen. Die Partials markieren dafuer:
// data-section="hero" ein Bereich
// data-field="kicker" ein Textfeld (Element-Text oder Wert eines <input>)
// data-multiline="true" Zeilenumbrueche im Text erhalten
// data-object="button" ein verschachteltes Objekt
// data-list="items" eine Liste, deren Eintraege data-item tragen
// data-string-list="true" Liste aus reinen Texten statt Objekten
// data-image="image" ein Bild, Pfad steht in data-value
// data-flag="wide" eine Checkbox fuer einen Ja/Nein-Wert
// data-file="documents" Knopf, der eine Datei hochlaedt und den Pfad ins Feld schreibt
// data-max="500" Zeichengrenze eines contenteditable-Feldes, aus
// ContentLimits gerendert (siehe Models/SiteContent)
//
// Dadurch braucht es keine Index-Verwaltung: nach Verschieben oder Loeschen
// ergibt sich die Reihenfolge wieder aus der Reihenfolge im DOM.
(function () {
"use strict";
var root = document.getElementById("page-editor");
if (!root || !root.classList.contains("pe-editing")) {
return;
}
var statusBox = document.getElementById("pe-status");
var fileInput = document.getElementById("pe-file-input");
var loginBox = document.getElementById("pe-login");
var loginError = document.getElementById("pe-login-error");
var loginSubmit = document.getElementById("pe-login-submit");
var tokenField = document.querySelector('input[name="__RequestVerificationToken"]');
var token = tokenField ? tokenField.value : "";
var saveTimer = null;
var pendingRetry = null;
// ---------- Status ----------
function setStatus(text, state) {
if (!statusBox) return;
statusBox.textContent = text;
statusBox.className = "pe-status" + (state ? " is-" + state : "");
}
// ---------- Aus dem DOM lesen ----------
// Das Element, zu dem ein Feld gehoert: der naechste Eintrag, das naechste
// Objekt oder der Bereich darueber.
function ownerOf(node) {
return node.parentElement.closest("[data-item], [data-object], [data-section]");
}
function ownedBy(container, selector) {
return Array.prototype.filter.call(
container.querySelectorAll(selector),
function (node) { return ownerOf(node) === container; });
}
function itemsOf(listElement) {
return Array.prototype.filter.call(
listElement.querySelectorAll("[data-item]"),
function (node) { return node.parentElement.closest("[data-list]") === listElement; });
}
// Gleiche Normalisierung wie beim Speichern: bei einzeiligen Feldern
// werden mehrere Leer- bzw. Zeilenumbruchzeichen zu einem Leerzeichen.
// Der Zeichenzaehler misst dieselbe Laenge, damit er nicht etwas anderes
// anzeigt als das, was tatsaechlich gespeichert wird.
function normalizeText(text, multiline) {
if (!multiline) {
text = text.replace(/\s+/g, " ");
}
return text.trim();
}
// Liest den sichtbaren Text eines Feldes. Der Zeichenzaehler (.pe-charcount)
// steht bei den meisten Feldern als Geschwister ausserhalb -- bei der
// hervorgehobenen letzten Hero-Titelzeile aber technisch bedingt *innerhalb*
// desselben Elements, das hier ausgelesen wird (Feld und Listen-Eintrag sind
// dort zwei verschachtelte Elemente statt eines). Ohne dieses Ausblenden
// wuerde "3 / 50" als echter Text mitgespeichert. Kurzes Verstecken statt
// eines Klons, damit innerText zuverlaessig auf dem echten, gerenderten
// Element berechnet wird (auf einem loesgeloesten Klon liefert innerText in
// manchen Browsern keine verlaesslichen Werte).
function readValue(node) {
if (node.tagName === "INPUT" || node.tagName === "TEXTAREA") {
return node.value;
}
var counters = node.querySelectorAll(".pe-charcount");
var previousDisplay = [];
counters.forEach(function (counter, i) {
previousDisplay[i] = counter.style.display;
counter.style.display = "none";
});
var text = node.innerText;
counters.forEach(function (counter, i) {
counter.style.display = previousDisplay[i];
});
return normalizeText(text, node.dataset.multiline === "true");
}
function collect(container) {
var result = {};
ownedBy(container, "[data-field]").forEach(function (node) {
result[node.dataset.field] = readValue(node);
});
ownedBy(container, "[data-image]").forEach(function (node) {
result[node.dataset.image] = node.dataset.value || "";
});
ownedBy(container, "[data-flag]").forEach(function (node) {
result[node.dataset.flag] = node.checked === true;
});
ownedBy(container, "[data-object]").forEach(function (node) {
result[node.dataset.object] = collect(node);
});
// Listen: nur die, die direkt zu diesem Container gehoeren
Array.prototype.filter.call(
container.querySelectorAll("[data-list]"),
function (node) { return ownerOf(node) === container; }
).forEach(function (listElement) {
result[listElement.dataset.list] = collectList(listElement);
});
return result;
}
function collectList(listElement) {
var asStrings = listElement.dataset.stringList === "true";
return itemsOf(listElement).map(function (item) {
return asStrings ? readValue(item) : collect(item);
});
}
function collectAll() {
var content = {};
root.querySelectorAll("[data-section]").forEach(function (section) {
content[section.dataset.section] = collect(section);
});
return content;
}
// ---------- Zeichenzaehler ----------
//
// Jedes contenteditable-Feld mit data-max bekommt einen Zaehler
// "aktuell / maximal" direkt danach und wird an genau dieser Grenze
// gekappt -- die Zahl kommt serverseitig aus ContentLimits und ist
// dieselbe, die auch [StringLength] beim Speichern durchsetzt.
//
// Ein MutationObserver haelt das auch fuer Felder aktuell, die erst nach
// dem Laden entstehen (neuer Listeneintrag, neu geladener Bereich nach
// saveAndRefresh/putAndRefresh) -- ohne dass jede Stelle, die das DOM
// aendert, den Zaehler von Hand anstossen muesste.
function maxOf(field) {
var max = parseInt(field.dataset.max, 10);
return isNaN(max) ? null : max;
}
function ensureCounter(field) {
var next = field.nextElementSibling;
if (next && next.classList.contains("pe-charcount")) {
return next;
}
var counter = document.createElement("span");
counter.className = "pe-charcount";
field.insertAdjacentElement("afterend", counter);
return counter;
}
function updateCounter(field) {
var max = maxOf(field);
if (max === null) return;
var length = normalizeText(field.innerText, field.dataset.multiline === "true").length;
var counter = ensureCounter(field);
counter.textContent = length + " / " + max;
counter.classList.toggle("is-warning", length >= max * 0.8 && length < max);
counter.classList.toggle("is-limit", length >= max);
}
// Kappt den Rohtext an der Grenze. Bewusst am rohen innerText statt am
// normalisierten Wert, damit die gespeicherte (normalisierte) Laenge nie
// ueber dem serverseitigen Limit liegen kann.
function enforceMax(field) {
var max = maxOf(field);
if (max === null) return;
var raw = field.innerText;
if (raw.length <= max) return;
field.innerText = raw.slice(0, max);
placeCaretAtEnd(field);
}
function placeCaretAtEnd(field) {
var range = document.createRange();
range.selectNodeContents(field);
range.collapse(false);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
var charCountObserver = new MutationObserver(function () {
refreshAllCounters();
});
function refreshAllCounters() {
// Waehrend des eigenen Umbaus nicht auf die dabei entstehenden
// Aenderungen (neue Zaehler-Spans) reagieren -- sonst beobachtet
// sich der Observer selbst und laeuft endlos.
charCountObserver.disconnect();
root.querySelectorAll('[contenteditable="true"][data-max]').forEach(updateCounter);
charCountObserver.observe(root, { childList: true, subtree: true });
}
// ---------- Fehler einordnen ----------
// Wird eine abgelaufene Sitzung erkannt, wirft diese Funktion einen Fehler
// mit sessionExpired = true; der Aufrufer blendet dann die Anmeldung ein.
// Bei allen anderen Fehlern kommt eine lesbare Meldung heraus statt eines
// JSON-Parserfehlers -- 401 und 413 liefern naemlich einen leeren Body.
function describeError(response) {
if (response.status === 401) {
var expired = new Error("Sitzung abgelaufen");
expired.sessionExpired = true;
return Promise.resolve(expired);
}
if (response.status === 413) {
return Promise.resolve(new Error("Die Datei ist zu gross."));
}
return response.text().then(function (body) {
// Eine zu grosse Datei meldet ASP.NET als Validierungsfehler mit
// englischem Text -- daraus wird hier eine verstaendliche Meldung.
if (/request body too large|exceeded the request limit/i.test(body)) {
return new Error("Die Datei ist zu gross.");
}
if (body) {
try {
var parsed = JSON.parse(body);
// Eigene Meldungen des Controllers
if (parsed && parsed.error) {
return new Error(parsed.error);
}
// ProblemDetails: erste Fehlermeldung herausziehen. Der
// Schluessel (z. B. "Skills.Items[2].Text") sagt, welches
// Feld betroffen ist -- reportError springt damit direkt
// dorthin, statt nur eine allgemeine Meldung zu zeigen.
if (parsed && parsed.errors) {
for (var key in parsed.errors) {
var messages = parsed.errors[key];
if (messages && messages.length) {
var validationError = new Error(messages[0]);
validationError.fieldPath = key;
return validationError;
}
}
}
} catch (ignored) {
// Kein JSON -- dann bleibt es bei der allgemeinen Meldung.
}
}
return new Error("Fehler " + response.status);
});
}
function reportError(error) {
if (error.sessionExpired) {
requireLogin(error.retry);
return;
}
setStatus("Nicht gespeichert – " + error.message, "error");
if (error.fieldPath) {
highlightField(error.fieldPath);
}
}
// ---------- Zum fehlerhaften Feld springen ----------
//
// Wandelt einen Validierungs-Schluessel wie "Skills.Items[2].Text" oder
// "Projects.Items[4].Details[0].Heading" in den passenden DOM-Knoten um.
// Die Segmente entsprechen genau den data-section/-list/-item/-object/
// -field-Attributen, nur in PascalCase statt camelCase -- derselbe Weg,
// den collect() in die andere Richtung geht.
function decodeSegment(raw) {
var match = raw.match(/^([^[\]]+)(?:\[(\d+)\])?$/);
if (!match) return null;
var name = match[1].charAt(0).toLowerCase() + match[1].slice(1);
var index = match[2] !== undefined ? parseInt(match[2], 10) : null;
return { name: name, index: index };
}
function locateField(fieldPath) {
var segments = fieldPath.split(".").map(decodeSegment);
if (segments.some(function (s) { return s === null; })) return null;
var first = segments.shift();
var node = root.querySelector('[data-section="' + first.name + '"]');
for (var i = 0; node && i < segments.length; i++) {
var seg = segments[i];
if (seg.index !== null) {
// Liste: erst den Listencontainer im aktuellen Knoten finden,
// dann den Eintrag an der gemeldeten Stelle.
var list = ownedBy(node, '[data-list="' + seg.name + '"]')[0];
node = list ? (itemsOf(list)[seg.index] || list) : null;
} else {
// Feld oder verschachteltes Objekt.
node = ownedBy(node, '[data-field="' + seg.name + '"]')[0] ||
ownedBy(node, '[data-object="' + seg.name + '"]')[0] ||
null;
}
}
return node;
}
function highlightField(fieldPath) {
var node = locateField(fieldPath);
if (!node) return;
node.scrollIntoView({ behavior: "smooth", block: "center" });
node.classList.add("pe-field-error");
window.setTimeout(function () { node.classList.remove("pe-field-error"); }, 2500);
if (node.getAttribute("contenteditable") === "true") {
node.focus();
}
}
// ---------- Speichern ----------
// Schickt einen fertigen Inhalt. Schlaegt es an der Sitzung fehl, merkt sich
// der Fehler genau diesen Inhalt, damit nach dem Anmelden derselbe Stand
// erneut gesendet wird -- auch eine gerade hinzugefuegte Karte.
function putContent(content) {
setStatus("Speichern …", "saving");
return fetch("/api/v1/pagecontent", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"RequestVerificationToken": token
},
body: JSON.stringify(content)
}).then(function (response) {
if (!response.ok) {
return describeError(response).then(function (error) { throw error; });
}
setStatus("Gespeichert " + new Date().toLocaleTimeString("de-CH"), "saved");
});
}
function save() {
return putContent(collectAll()).catch(function (error) {
error.retry = save;
reportError(error);
throw error;
});
}
function saveSoon() {
setStatus("Nicht gespeichert …");
window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(save, 600);
}
// ---------- Bereich neu rendern ----------
// Nach einer Strukturaenderung laesst der Server den Bereich neu rendern.
// Das haelt Reihenfolge, Kachel-Varianten und Zeilenaufteilung korrekt,
// ohne die ganze Seite neu zu laden.
function refreshSection(name) {
return fetch("/Admin?handler=Section&name=" + encodeURIComponent(name), {
headers: { "X-Requested-With": "fetch" }
}).then(function (response) {
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
return response.text();
}).then(function (html) {
var current = root.querySelector('[data-section="' + name + '"]');
if (!current) return;
var holder = document.createElement("div");
holder.innerHTML = html;
var replacement = holder.querySelector('[data-section="' + name + '"]');
if (replacement) {
current.replaceWith(replacement);
}
});
}
// Speichern und danach den betroffenen Bereich neu aufbauen.
function saveAndRefresh(sectionName) {
window.clearTimeout(saveTimer);
save().then(function () {
return refreshSection(sectionName);
}).catch(function () {
// Fehlermeldung steht bereits im Status.
});
}
function sectionNameOf(node) {
var section = node.closest("[data-section]");
return section ? section.dataset.section : null;
}
// ---------- Eingaben ----------
root.addEventListener("input", function (event) {
var target = event.target;
var field = target.closest("[contenteditable='true']");
if (field) {
if (field.dataset.max) {
enforceMax(field);
updateCounter(field);
}
saveSoon();
return;
}
if (target.matches("input[data-field], textarea[data-field]")) {
// Icon-Vorschau sofort mitziehen
if (target.matches("input[data-field='icon']")) {
var item = target.closest("[data-item]");
var icon = item && item.querySelector(".techno-service-icon i, .contact-us-icon i");
if (icon) {
icon.className = target.value;
}
}
saveSoon();
}
});
root.addEventListener("change", function (event) {
if (event.target.matches("input[data-flag]")) {
saveAndRefresh(sectionNameOf(event.target));
}
});
// Beim Verlassen eines Feldes sofort sichern statt auf den Timer zu warten.
root.addEventListener("focusout", function (event) {
if (event.target.closest("[contenteditable='true']") && saveTimer) {
window.clearTimeout(saveTimer);
save().catch(function () { });
}
});
// Zeilenumbrueche nur dort zulassen, wo das Feld mehrzeilig ist.
root.addEventListener("keydown", function (event) {
if (event.key !== "Enter") return;
var field = event.target.closest("[contenteditable='true']");
if (field && field.dataset.multiline !== "true") {
event.preventDefault();
}
});
// Formatierten Text beim Einfuegen auf reinen Text reduzieren.
root.addEventListener("paste", function (event) {
if (!event.target.closest("[contenteditable='true']")) return;
event.preventDefault();
var text = (event.clipboardData || window.clipboardData).getData("text/plain");
document.execCommand("insertText", false, text);
});
// ---------- Listen: hinzufuegen, verschieben, loeschen ----------
root.addEventListener("click", function (event) {
var addButton = event.target.closest(".pe-add");
if (addButton) {
event.preventDefault();
addItem(addButton);
return;
}
var controlButton = event.target.closest(".pe-item-controls button");
if (controlButton) {
event.preventDefault();
handleItemControl(controlButton);
return;
}
var fileButton = event.target.closest(".pe-file");
if (fileButton) {
event.preventDefault();
pickFile(fileButton);
return;
}
var image = event.target.closest("[data-image]");
if (image) {
event.preventDefault();
pickImage(image);
}
});
function listForButton(button) {
// Der Knopf steht innerhalb des Listencontainers oder direkt daneben.
var name = button.dataset.add;
var section = button.closest("[data-section]");
var candidates = section.querySelectorAll('[data-list="' + name + '"]');
for (var i = 0; i < candidates.length; i++) {
if (candidates[i].contains(button) ||
candidates[i].parentElement === button.parentElement ||
button.closest("[data-item]") === candidates[i].closest("[data-item]")) {
return candidates[i];
}
}
return candidates[0] || null;
}
function addItem(button) {
var listElement = listForButton(button);
if (!listElement) return;
var content = collectAll();
var target = resolveList(content, listElement);
if (!target) return;
// Ein leeres Objekt genuegt: der Server fuellt beim Einlesen die
// Standardwerte aus dem C#-Modell ein.
target.array.push(listElement.dataset.stringList === "true" ? "" : {});
putAndRefresh(content, sectionNameOf(button));
}
function handleItemControl(button) {
var item = button.closest("[data-item]");
var listElement = item.parentElement.closest("[data-list]");
if (!listElement) return;
var index = itemsOf(listElement).indexOf(item);
if (index < 0) return;
var content = collectAll();
var target = resolveList(content, listElement);
if (!target) return;
var array = target.array;
if (button.classList.contains("pe-delete")) {
array.splice(index, 1);
} else if (button.classList.contains("pe-move-up") && index > 0) {
array.splice(index - 1, 0, array.splice(index, 1)[0]);
} else if (button.classList.contains("pe-move-down") && index < array.length - 1) {
array.splice(index + 1, 0, array.splice(index, 1)[0]);
} else {
return;
}
putAndRefresh(content, sectionNameOf(button));
}
// Findet im eingesammelten Objekt die Liste, die zu einem DOM-Container gehoert.
// Der Weg wird ueber dieselbe Zugehoerigkeit gebildet wie beim Einsammeln,
// damit ineinander liegende Listen korrekt aufgeloest werden.
function resolveList(content, listElement) {
var path = [listElement.dataset.list];
var owner = ownerOf(listElement);
while (owner && owner.dataset.section === undefined) {
if (owner.dataset.item !== undefined) {
var parentList = owner.parentElement.closest("[data-list]");
if (!parentList) return null;
path.unshift(itemsOf(parentList).indexOf(owner));
path.unshift(parentList.dataset.list);
owner = ownerOf(parentList);
} else if (owner.dataset.object !== undefined) {
path.unshift(owner.dataset.object);
owner = ownerOf(owner);
} else {
return null;
}
}
if (!owner) return null;
path.unshift(owner.dataset.section);
var current = content;
for (var i = 0; i < path.length; i++) {
current = current[path[i]];
if (current === undefined || current === null) return null;
}
return Array.isArray(current) ? { array: current } : null;
}
function putAndRefresh(content, sectionName) {
window.clearTimeout(saveTimer);
putContent(content).then(function () {
return refreshSection(sectionName);
}).catch(function (error) {
error.retry = function () { putAndRefresh(content, sectionName); };
reportError(error);
});
}
// ---------- Bilder und Dokumente ----------
// Merkt sich, was nach dem Hochladen aktualisiert werden soll.
var pendingTarget = null;
function pickImage(image) {
if (!fileInput) return;
pendingTarget = { kind: "image", element: image, endpoint: "images" };
fileInput.accept = "image/*";
fileInput.value = "";
fileInput.click();
}
function pickFile(button) {
if (!fileInput) return;
var row = button.closest(".pe-file-row");
var field = row && row.querySelector("input[data-field]");
if (!field) return;
pendingTarget = {
kind: "file",
element: field,
endpoint: button.dataset.file || "documents",
item: button.closest("[data-item]")
};
fileInput.accept = button.dataset.accept || "";
fileInput.value = "";
fileInput.click();
}
function upload(file, target) {
var data = new FormData();
data.append("file", file);
setStatus((target.kind === "image" ? "Bild" : "Datei") + " wird hochgeladen …", "saving");
return fetch("/api/v1/pagecontent/" + target.endpoint, {
method: "POST",
headers: { "RequestVerificationToken": token },
body: data
}).then(function (response) {
if (!response.ok) {
return describeError(response).then(function (error) { throw error; });
}
return response.json();
}).then(function (body) {
if (target.kind === "image") {
target.element.src = body.url;
target.element.dataset.value = body.url;
} else {
target.element.value = body.url;
// Der sichtbare Link soll sofort auf die neue Datei zeigen.
var link = target.item && target.item.querySelector(".circular-btn a");
if (link) {
link.href = body.url;
}
}
return save();
}).catch(function (error) {
error.retry = function () { upload(file, target); };
reportError(error);
});
}
if (fileInput) {
fileInput.addEventListener("change", function () {
var file = fileInput.files && fileInput.files[0];
var target = pendingTarget;
pendingTarget = null;
if (!file || !target) return;
upload(file, target);
});
}
// ---------- Erneut anmelden, ohne die Seite zu verlassen ----------
// Bei einer abgelaufenen Sitzung wird hier angemeldet und danach genau die
// Aktion wiederholt, die fehlgeschlagen ist. Die Seite bleibt stehen, alle
// Eingaben bleiben erhalten.
function requireLogin(retry) {
if (!loginBox) {
setStatus("Sitzung abgelaufen – bitte Seite neu laden", "error");
return;
}
pendingRetry = retry || null;
setStatus("Sitzung abgelaufen", "error");
loginError.hidden = true;
loginBox.hidden = false;
var user = document.getElementById("pe-login-user");
if (user) {
user.focus();
}
}
// Holt ein frisches Antiforgery-Token von der Login-Seite: das Token der
// Editor-Seite gehoert zur inzwischen abgelaufenen Anmeldung.
function submitLogin() {
var user = document.getElementById("pe-login-user");
var password = document.getElementById("pe-login-pass");
if (!user.value || !password.value) {
showLoginError("Bitte Benutzername und Passwort eingeben.");
return;
}
loginSubmit.disabled = true;
loginError.hidden = true;
fetch("/Login", { headers: { "X-Requested-With": "fetch" } })
.then(function (response) { return response.text(); })
.then(function (html) {
var match = html.match(/name="__RequestVerificationToken"[^>]*?value="([^"]+)"/);
if (!match) {
throw new Error("Login-Seite nicht lesbar");
}
var body = new URLSearchParams();
body.set("Username", user.value);
body.set("Password", password.value);
body.set("__RequestVerificationToken", match[1]);
return fetch("/Login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
redirect: "manual"
});
})
.then(function (response) {
// Erfolg beantwortet der Server mit einer Weiterleitung; bei
// falschen Daten kommt die Login-Seite mit Status 200 zurueck.
if (response.type !== "opaqueredirect" && response.status !== 302) {
throw new Error("Benutzername oder Passwort ist falsch.");
}
loginBox.hidden = true;
password.value = "";
loginSubmit.disabled = false;
var retry = pendingRetry;
pendingRetry = null;
if (retry) {
retry();
} else {
setStatus("Wieder angemeldet", "saved");
}
})
.catch(function (error) {
loginSubmit.disabled = false;
showLoginError(error.message);
});
}
function showLoginError(message) {
if (!loginError) return;
loginError.textContent = message;
loginError.hidden = false;
}
if (loginSubmit) {
loginSubmit.addEventListener("click", submitLogin);
}
if (loginBox) {
loginBox.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault();
submitLogin();
}
});
}
// ---------- Ungespeicherte Aenderungen ----------
window.addEventListener("beforeunload", function (event) {
if (saveTimer) {
event.preventDefault();
event.returnValue = "";
}
});
// Zaehler fuer alle beim Laden vorhandenen Felder anzeigen und den
// Observer fuer spaeter hinzukommende Felder scharf schalten.
refreshAllCounters();
setStatus("Bereit");
})();