Restructure repository into administration and website areas

This commit is contained in:
2026-03-06 15:00:13 +01:00
parent 7c49898b29
commit ed192d79c2
75 changed files with 179 additions and 157 deletions

View File

@@ -0,0 +1,120 @@
.ce-toolbar {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 99999;
background: #1f2937;
color: #fff;
padding: 10px 12px;
border-radius: 10px;
display: flex;
align-items: center;
gap: 8px;
font-family: sans-serif;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.ce-toolbar button {
border: 0;
border-radius: 6px;
background: #2563eb;
color: #fff;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
}
.ce-status {
color: #d1d5db;
min-width: 120px;
}
.ce-inline-text {
outline: 1px dashed transparent;
outline-offset: 2px;
}
.ce-inline-text:hover {
outline-color: #60a5fa;
cursor: text;
}
.ce-text-editing {
outline-color: #f59e0b !important;
background: rgba(245, 158, 11, 0.12);
}
.ce-image-target {
outline: 2px solid transparent;
outline-offset: 2px;
}
.ce-image-target:hover {
outline-color: #34d399;
cursor: pointer;
}
.ce-image-overlay {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(17, 24, 39, 0.45);
display: flex;
align-items: center;
justify-content: center;
}
.ce-image-overlay-card {
width: min(460px, calc(100vw - 40px));
background: #fff;
border-radius: 10px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
font-family: sans-serif;
}
.ce-image-overlay-card h4 {
margin: 0;
font-size: 16px;
}
.ce-image-overlay-card label {
font-size: 12px;
color: #374151;
display: flex;
flex-direction: column;
gap: 6px;
}
.ce-image-overlay-card input {
padding: 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
}
.ce-image-overlay-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.ce-image-overlay-actions button {
border: 0;
border-radius: 6px;
padding: 8px 10px;
cursor: pointer;
}
.ce-image-overlay-actions [data-ce-cancel] {
background: #e5e7eb;
color: #111827;
}
.ce-image-overlay-actions [data-ce-apply] {
background: #059669;
color: #fff;
}

View 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]}`;
}
})();