818 lines
29 KiB
JavaScript
818 lines
29 KiB
JavaScript
// 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");
|
||
})();
|