Restructure repository into administration and website areas
This commit is contained in:
793
website/editor/wysiwyg-editor.js
Normal file
793
website/editor/wysiwyg-editor.js
Normal file
@@ -0,0 +1,793 @@
|
||||
(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 = [
|
||||
'<strong>Editor Mode</strong>',
|
||||
'<button type="button" data-ce-save>Save</button>',
|
||||
'<button type="button" data-ce-undo>Undo</button>',
|
||||
'<button type="button" data-ce-reset>Reset</button>',
|
||||
'<button type="button" data-ce-logout>Logout</button>',
|
||||
'<span class="ce-status" data-ce-status>Loading…</span>',
|
||||
].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 website/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 website/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 = [
|
||||
'<div class="ce-image-overlay-card">',
|
||||
"<h4>Image Content</h4>",
|
||||
'<label>src <input type="text" data-ce-src /></label>',
|
||||
'<label>alt <input type="text" data-ce-alt /></label>',
|
||||
'<div class="ce-image-overlay-actions">',
|
||||
'<button type="button" data-ce-cancel>Cancel</button>',
|
||||
'<button type="button" data-ce-apply>Apply</button>',
|
||||
"</div>",
|
||||
"</div>",
|
||||
].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]}`;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user