Der Performance-Check (pruef-tempo.mjs, neu) fand als größte Einzel- ressource ein 1,1-MB-Profilbild, ausgeliefert an jeden Besucher der Startseite und von stimmen.html -- angezeigt wird es handgroß. Ursache: Das öffentliche Stimmen-Formular (stimmen.js) lud Profilbilder ROH hoch. Die Bildaufbereitung vom 27.08. bekam nur die Verwaltung, nicht das öffentliche Formular. So landete das Kamera-Foto in voller Auflösung auf dem Server. - bild-vorbereiten.js (neu): die Verkleinerungs-Funktion, jetzt einmal und parametrisierbar (maxBreite). Verwaltung nutzt weiter 1600px, Avatare 512px. Test pruef-bild-vorbereiten.mjs: 8/8, u.a. 512er-Avatar ~40 KB statt >1 MB. - stimmen.js: verkleinert vor dem Upload (window.bildVorbereiten mit maxBreite 512); fällt die Funktion aus, wird das Original genommen -- kein Upload darf daran scheitern. - stimmen.html: lädt bild-vorbereiten.js; veralteten Kommentar richtiggestellt (behauptete "kein Upload", obwohl es seit 21.08. einen gibt -- mit Bremse, Typ-/Größenlimit, Freigabe-Pflicht). Zwei neue Checks als bleibende Absicherung mit committet: pruef-links.mjs (41 interne Ziele, 0 kaputt) und pruef-assets.mjs (67 Ressourcen, 0 fehlen). NOCH OFFEN: verwaltung.html hat noch eine eigene, identische Inline-Kopie der Funktion -- die Zusammenführung ist ein eigener, testbarer Schritt (das Inline-Skript dort ist groß und die Verwaltung hinter dem Gate schwer live zu testen). Das bestehende 1,1-MB-Bild auf dem Server bleibt, bis es neu hochgeladen/ersetzt wird. Co-Authored-By: Claude Opus 5 <[email protected]>
195 lines
9.3 KiB
JavaScript
195 lines
9.3 KiB
JavaScript
/* =====================================================================
|
||
stimmen.js — NUR für stimmen.html (20.08.2026, Nutzer-Wunsch: "eine
|
||
richtig geile kachel ... wo die leute mit namen und tiktok namen mir
|
||
eine nachricht schreiben können die ich in die verwaltung kriege").
|
||
Formular schickt eine Einreichung ab (landet als Entwurf, siehe
|
||
server-internal/routes/testimonials.js), zweiter Teil lädt die bereits
|
||
freigegebenen Stimmen und zeigt sie an — bleibt komplett unsichtbar,
|
||
solange keine einzige freigegeben wurde. ===================================================================== */
|
||
(function () {
|
||
const API_BASE_URL = "https://postfach.dogfather-universe.com";
|
||
|
||
/* ---------- Profilbild-Auswahl (21.08.2026) ----------
|
||
Baut die Kacheln aus der gemeinsamen Liste in data-stimmen-avatare.js
|
||
auf. Bewusst echte <button>-Elemente statt <div>: so ist die Auswahl
|
||
auch mit der Tastatur bedienbar und Screenreader melden sie als
|
||
Schaltfläche. "" = bewusst kein Bild (erste Kachel). */
|
||
let gewaehlterAvatar = "";
|
||
const wahlEl = document.getElementById("stimmen-avatar-wahl");
|
||
if (wahlEl) {
|
||
const liste = window.STIMMEN_AVATARE || [];
|
||
const kachel = (id, inhalt, titel) =>
|
||
`<button type="button" class="stimmen-avatar-kachel" data-avatar="${id}" role="radio" aria-checked="false" title="${titel}" aria-label="${titel}">${inhalt}</button>`;
|
||
|
||
/* Letzte Kachel: eigenes Bild (21.08.2026, Nutzer-Wunsch "und die
|
||
auswahl wo die leute selbst ein bild rein setzen können"). Die
|
||
versteckte Datei-Auswahl liegt gleich daneben; die Kachel selbst
|
||
löst sie nur aus, damit die Optik zu den anderen Kacheln passt. */
|
||
wahlEl.innerHTML =
|
||
kachel("", `<span class="stimmen-avatar-keins" aria-hidden="true">✦</span>`, "Kein Bild") +
|
||
liste.map((a) => kachel(a.id, `<img src="${a.datei}" alt="" loading="lazy" />`, a.name)).join("") +
|
||
`<button type="button" class="stimmen-avatar-kachel stimmen-avatar-eigenes" id="stimmen-avatar-eigenes"
|
||
role="radio" aria-checked="false" title="Eigenes Bild hochladen" aria-label="Eigenes Bild hochladen">
|
||
<span class="stimmen-avatar-plus" aria-hidden="true">+</span>
|
||
</button>
|
||
<input type="file" id="stimmen-avatar-datei" accept="image/jpeg,image/png,image/webp" hidden />`;
|
||
|
||
const eigenesBtn = document.getElementById("stimmen-avatar-eigenes");
|
||
const dateiInput = document.getElementById("stimmen-avatar-datei");
|
||
let eigenesBildUrl = "";
|
||
|
||
const markiere = () => {
|
||
wahlEl.querySelectorAll(".stimmen-avatar-kachel").forEach((b) => {
|
||
// Die "eigenes Bild"-Kachel hat kein data-avatar; sie gilt als
|
||
// gewählt, wenn die aktuelle Auswahl die hochgeladene Adresse ist.
|
||
const wert = b === eigenesBtn ? eigenesBildUrl : b.dataset.avatar;
|
||
const aktiv = !!gewaehlterAvatar === !!wert && wert === gewaehlterAvatar;
|
||
b.classList.toggle("aktiv", aktiv);
|
||
b.setAttribute("aria-checked", aktiv ? "true" : "false");
|
||
});
|
||
};
|
||
|
||
wahlEl.addEventListener("click", (e) => {
|
||
const btn = e.target.closest(".stimmen-avatar-kachel");
|
||
if (!btn) return;
|
||
if (btn === eigenesBtn) {
|
||
// Schon hochgeladen -> einfach wieder auswählen, sonst Dateidialog.
|
||
if (eigenesBildUrl) { gewaehlterAvatar = eigenesBildUrl; markiere(); }
|
||
else dateiInput.click();
|
||
return;
|
||
}
|
||
gewaehlterAvatar = btn.dataset.avatar;
|
||
markiere();
|
||
});
|
||
|
||
dateiInput.addEventListener("change", async () => {
|
||
const datei = dateiInput.files && dateiInput.files[0];
|
||
if (!datei) return;
|
||
const fehlerEl = document.getElementById("stimmen-fehler");
|
||
eigenesBtn.classList.add("laedt");
|
||
try {
|
||
const form = new FormData();
|
||
/* Vor dem Upload verkleinern (bild-vorbereiten.js). Ein Profilbild
|
||
erscheint nur handgroß -- 512px sind großzügig und machen aus
|
||
einem Handy-Foto von mehreren MB rund 30-50 KB. Ohne diesen
|
||
Schritt landete bis 28.08.2026 z.B. ein 1,1-MB-Avatar auf dem
|
||
Server und wurde jedem Startseiten-Besucher ausgeliefert.
|
||
Fällt window.bildVorbereiten aus (Skript nicht geladen), wird
|
||
die Originaldatei genommen -- kein Upload darf daran scheitern. */
|
||
const fertig = window.bildVorbereiten
|
||
? await window.bildVorbereiten(datei, { maxBreite: 512 })
|
||
: datei;
|
||
form.append("image", fertig);
|
||
const res = await fetch(`${API_BASE_URL}/testimonials/upload-avatar`, { method: "POST", body: form });
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
if (fehlerEl) { fehlerEl.textContent = data.error || "Das Bild konnte nicht hochgeladen werden."; fehlerEl.classList.add("zeige"); }
|
||
return;
|
||
}
|
||
eigenesBildUrl = data.url;
|
||
gewaehlterAvatar = data.url;
|
||
eigenesBtn.innerHTML = `<img src="${data.url}" alt="" />`;
|
||
markiere();
|
||
} catch {
|
||
if (fehlerEl) { fehlerEl.textContent = "Das Bild konnte nicht hochgeladen werden (keine Verbindung)."; fehlerEl.classList.add("zeige"); }
|
||
} finally {
|
||
eigenesBtn.classList.remove("laedt");
|
||
dateiInput.value = ""; // erlaubt, dieselbe Datei erneut zu wählen
|
||
}
|
||
});
|
||
|
||
markiere();
|
||
}
|
||
|
||
const formular = document.getElementById("stimmen-formular");
|
||
if (formular) {
|
||
const erfolgEl = document.getElementById("stimmen-erfolg");
|
||
const fehlerEl = document.getElementById("stimmen-fehler");
|
||
formular.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
erfolgEl.classList.remove("zeige");
|
||
fehlerEl.classList.remove("zeige");
|
||
|
||
const name = document.getElementById("st-name").value.trim();
|
||
const tiktokUsername = document.getElementById("st-tiktok").value.trim();
|
||
const nachricht = document.getElementById("st-nachricht").value.trim();
|
||
|
||
const absendenBtn = formular.querySelector("button[type=submit]");
|
||
absendenBtn.disabled = true;
|
||
try {
|
||
const res = await fetch(`${API_BASE_URL}/testimonials/submit`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name, tiktokUsername, nachricht, avatar: gewaehlterAvatar || null }),
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
fehlerEl.textContent = data.error || "Einreichen hat leider nicht geklappt. Bitte versuch es nochmal.";
|
||
fehlerEl.classList.add("zeige");
|
||
return;
|
||
}
|
||
formular.hidden = true;
|
||
erfolgEl.classList.add("zeige");
|
||
} catch {
|
||
fehlerEl.textContent = "Verbindung zum Server fehlgeschlagen. Bitte versuch es nochmal.";
|
||
fehlerEl.classList.add("zeige");
|
||
} finally {
|
||
absendenBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return String(text ?? "").replace(/[&<>"']/g, (z) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[z]));
|
||
}
|
||
|
||
/* Erstes sichtbares Zeichen des Namens fürs Avatar-Medaillon. [...name]
|
||
statt name[0], damit auch Namen, die mit einem Emoji beginnen, nicht
|
||
mitten in einem Zeichen abgeschnitten werden (ein Emoji besteht in
|
||
JavaScript aus mehreren "Zeichen" — name[0] lieferte sonst nur ein
|
||
kaputtes Halb-Zeichen). */
|
||
function erstesZeichen(name) {
|
||
const zeichen = [...String(name || "").trim()];
|
||
return zeichen.length ? zeichen[0].toUpperCase() : "?";
|
||
}
|
||
|
||
function ladeEchteStimmen() {
|
||
fetch(`${API_BASE_URL}/testimonials`, { cache: "no-store" })
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
const liste = data && data.ok ? data.testimonials : [];
|
||
if (!liste.length) return; // Abschnitt bleibt hidden, kein Platzhalter mehr
|
||
const root = document.getElementById("stimmen-echte-root");
|
||
const grid = document.getElementById("stimmen-echte-grid");
|
||
grid.innerHTML = liste
|
||
.map(
|
||
(t) => `<div class="stimmen-karte">
|
||
<p class="stimmen-karte-text">${escapeHtml(t.nachricht)}</p>
|
||
<div class="stimmen-karte-fuss">
|
||
${
|
||
/* Gewähltes Bild, sonst wie bisher der Anfangsbuchstabe.
|
||
escapeHtml auch auf der Bildadresse: sie stammt zwar aus
|
||
unserer eigenen Liste bzw. vom Server, aber sie landet
|
||
hier in einem HTML-Attribut — da gehört sie grundsätzlich
|
||
entschärft, unabhängig von der Herkunft. */
|
||
(window.stimmeAvatarBild && window.stimmeAvatarBild(t.avatar))
|
||
? `<span class="stimmen-karte-avatar stimmen-karte-avatar-bild" aria-hidden="true"><img src="${escapeHtml(window.stimmeAvatarBild(t.avatar))}" alt="" loading="lazy" /></span>`
|
||
: `<span class="stimmen-karte-avatar" aria-hidden="true">${escapeHtml(erstesZeichen(t.name))}</span>`
|
||
}
|
||
<span class="stimmen-karte-namen">
|
||
<span class="stimmen-karte-name">${escapeHtml(t.name)}</span>
|
||
${t.tiktokUsername ? `<span class="stimmen-karte-tiktok">@${escapeHtml(t.tiktokUsername)}</span>` : ""}
|
||
</span>
|
||
</div>
|
||
</div>`
|
||
)
|
||
.join("");
|
||
root.hidden = false;
|
||
})
|
||
.catch(() => {
|
||
/* Abschnitt bleibt einfach unsichtbar -- kein Fehler nötig. */
|
||
});
|
||
}
|
||
ladeEchteStimmen();
|
||
})();
|