Arbeit der parallelen Session gesichert (Profilbilder für Stimmen, Diene-Zugang)

Wie beim vorherigen Mal lag das nur als Arbeitskopie auf dem Server, nicht
in git. Unverändert übernommen, bevor darauf aufgebaut wird:
- Profilbild-Auswahl für die Stimmen (data-stimmen-avatare.js neu,
  stimmen.js, i18n-stimmen.js, stimmen.html, verwaltung.html, main.css)
- Dritte Zugangs-Kachel für Diene (gate.html, server/gate.js)

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-21 18:17:47 +02:00
co-authored by Claude Opus 5
parent d0613f86c1
commit e2bd436084
8 changed files with 324 additions and 3 deletions
+55
View File
@@ -4107,6 +4107,61 @@ body.start-seite .music-toggle:focus-visible {
background: linear-gradient(135deg, #ffd0e8, #a8e4f2); background: linear-gradient(135deg, #ffd0e8, #a8e4f2);
box-shadow: 0 0 16px rgba(255, 154, 208, .3); box-shadow: 0 0 16px rgba(255, 154, 208, .3);
} }
/* Gewähltes Profilbild statt Anfangsbuchstabe (21.08.2026). Erbt Größe und
runde Form von .stimmen-karte-avatar oben, ersetzt nur Füllung/Farbe. */
.stimmen-karte-avatar-bild {
padding: 0;
overflow: hidden;
background: rgba(255, 255, 255, .05);
box-shadow: 0 0 14px rgba(212, 175, 55, .25);
}
.stimmen-karte-avatar-bild img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* ---------- Profilbild-Auswahl im Einreichen-Formular (21.08.2026) ----------
Nutzer-Wunsch: "paar geile bilder zum aussuchen ... das soll denen auch
als möglichkeit gegeben sein". Kachelraster, das sich der Breite anpasst;
die getroffene Wahl bekommt einen goldenen Ring statt nur einer leichten
Aufhellung — sonst ist auf dunklem Grund kaum erkennbar, was gewählt ist. */
.stimmen-avatar-titel {
display: block;
font-size: .82rem; font-weight: 700; letter-spacing: .04em;
text-transform: uppercase; color: var(--text-muted);
margin-bottom: .25rem;
}
.stimmen-avatar-hinweis { margin: 0 0 .7rem; }
.stimmen-avatar-wahl {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(58px, 1fr));
gap: .6rem;
}
.stimmen-avatar-kachel {
position: relative;
aspect-ratio: 1;
padding: 0;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
background: rgba(255, 255, 255, .04);
border: 2px solid rgba(255, 255, 255, .12);
transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
}
.stimmen-avatar-kachel img { width: 100%; height: 100%; object-fit: cover; display: block; }
.stimmen-avatar-keins {
display: flex; align-items: center; justify-content: center;
width: 100%; height: 100%;
font-size: 1.15rem; color: var(--text-muted);
}
.stimmen-avatar-kachel:hover { transform: translateY(-2px); border-color: rgba(255, 233, 168, .6); }
.stimmen-avatar-kachel.aktiv {
border-color: #ffe9a8;
box-shadow: 0 0 0 2px rgba(212, 175, 55, .45), 0 0 18px rgba(212, 175, 55, .4);
}
.stimmen-avatar-kachel:focus-visible { outline: 2px solid #ffe9a8; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.stimmen-avatar-kachel { transition: none; }
.stimmen-avatar-kachel:hover { transform: none; }
}
.stimmen-karte-namen { display: flex; flex-direction: column; gap: .1rem; min-width: 0; } .stimmen-karte-namen { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
.stimmen-karte-name { font-weight: 700; color: var(--text); font-size: .95rem; } .stimmen-karte-name { font-weight: 700; color: var(--text); font-size: .95rem; }
.stimmen-karte-tiktok { .stimmen-karte-tiktok {
+43
View File
@@ -0,0 +1,43 @@
/* =====================================================================
data-stimmen-avatare.js — Auswahlbilder für die "Stimmen" (21.08.2026,
Nutzer-Wunsch: "ich soll auch paar geile bilder zum aussuchen haben als
profilbild falls die leute kein bild mit schicken, das soll denen auch
als möglichkeit gegeben sein").
EINE gemeinsame Liste für beide Seiten: stimmen.html (dort wählt die
einreichende Person selbst) und verwaltung.html (dort kann Dogi/VanVan
das Bild nachträglich ändern). Getrennte Listen wären auseinander-
gelaufen, sobald irgendwann ein Bild dazukommt oder wegfällt.
Bewusst nur Figuren, Maskottchen und das Logo — KEINE echten Personen
aus dem Team. Sonst könnte sich eine fremde Person mit dem Gesicht von
Dogi, VanVan oder einem Modi als Absender darstellen.
WICHTIG: Die "id"-Werte müssen mit der Liste
ERLAUBTE_AVATARE in server-internal/routes/testimonials.js
übereinstimmen — der Server nimmt nur bekannte Ids an, damit über das
öffentliche Formular kein beliebiger Pfad eingeschleust werden kann.
===================================================================== */
window.STIMMEN_AVATARE = [
{ id: "husky-hoodie", name: "DogFather-Husky", datei: "/assets/img/stimmen-avatare/stimme-husky-hoodie.png" },
{ id: "hase-hoodie", name: "HasiDog", datei: "/assets/img/stimmen-avatare/stimme-hase-hoodie.png" },
{ id: "husky-kopf", name: "Husky-Kopf", datei: "/assets/img/stimmen-avatare/stimme-husky-kopf.png" },
{ id: "husky-kosmos", name: "Kosmos-Husky", datei: "/assets/img/stimmen-avatare/stimme-husky-kosmos.png" },
{ id: "hase-kosmos", name: "Kosmos-Hase", datei: "/assets/img/stimmen-avatare/stimme-hase-kosmos.png" },
{ id: "casper", name: "Casper", datei: "/assets/img/stimmen-avatare/stimme-casper.png" },
{ id: "casper-blick", name: "Casper (Blick)", datei: "/assets/img/stimmen-avatare/stimme-casper-blick.png" },
{ id: "logo", name: "DogFather-Logo", datei: "/assets/img/stimmen-avatare/stimme-logo.png" },
];
/* Liefert die Bilddatei zu einer gespeicherten Auswahl.
- bekannte Id -> das zugehörige Auswahlbild
- "/uploads/…" -> ein von der Verwaltung hochgeladenes eigenes Foto
(kommt als vollständige Adresse vom Server, siehe testimonials.js)
- sonst -> null, dann zeigt die Karte wie bisher den Anfangs-
buchstaben des Namens. */
window.stimmeAvatarBild = function (wert) {
if (!wert) return null;
if (/^https?:\/\//i.test(wert)) return wert;
const treffer = (window.STIMMEN_AVATARE || []).find((a) => a.id === wert);
return treffer ? treffer.datei : null;
};
+11
View File
@@ -40,6 +40,17 @@ window.I18N_PAGE = {
de: "Deine Nachricht", "de-CH": "Dini Nachricht", de: "Deine Nachricht", "de-CH": "Dini Nachricht",
en: "Your Message", fr: "Ton message", pt: "Sua mensagem", en: "Your Message", fr: "Ton message", pt: "Sua mensagem",
}, },
st_form_avatar_label: {
de: "Dein Profilbild (optional)", "de-CH": "Dis Profilbild (optional)",
en: "Your profile picture (optional)", fr: "Ta photo de profil (facultatif)", pt: "Sua foto de perfil (opcional)",
},
st_form_avatar_hinweis: {
de: "Ohne Auswahl erscheint einfach der erste Buchstabe deines Namens.",
"de-CH": "Ohni Uuswahl erschiint eifach de erscht Buechstab vo dim Name.",
en: "If you don't pick one, the first letter of your name is shown instead.",
fr: "Sans choix, la première lettre de ton prénom s'affichera.",
pt: "Sem escolha, aparece apenas a primeira letra do seu nome.",
},
st_form_nachricht_placeholder: { st_form_nachricht_placeholder: {
de: "Erzähl uns, was dir Team Dogi bedeutet …", "de-CH": "Verzell eus, was dir Team Dogi bedütet …", de: "Erzähl uns, was dir Team Dogi bedeutet …", "de-CH": "Verzell eus, was dir Team Dogi bedütet …",
en: "Tell us what Team Dogi means to you …", fr: "Dis-nous ce que Team Dogi représente pour toi …", pt: "Conte-nos o que a Team Dogi significa para você …", en: "Tell us what Team Dogi means to you …", fr: "Dis-nous ce que Team Dogi représente pour toi …", pt: "Conte-nos o que a Team Dogi significa para você …",
+43 -2
View File
@@ -9,6 +9,38 @@
(function () { (function () {
const API_BASE_URL = "https://postfach.dogfather-universe.com"; 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>`;
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("");
const markiere = () => {
wahlEl.querySelectorAll(".stimmen-avatar-kachel").forEach((b) => {
const aktiv = b.dataset.avatar === 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;
gewaehlterAvatar = btn.dataset.avatar;
markiere();
});
markiere();
}
const formular = document.getElementById("stimmen-formular"); const formular = document.getElementById("stimmen-formular");
if (formular) { if (formular) {
const erfolgEl = document.getElementById("stimmen-erfolg"); const erfolgEl = document.getElementById("stimmen-erfolg");
@@ -28,7 +60,7 @@
const res = await fetch(`${API_BASE_URL}/testimonials/submit`, { const res = await fetch(`${API_BASE_URL}/testimonials/submit`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, tiktokUsername, nachricht }), body: JSON.stringify({ name, tiktokUsername, nachricht, avatar: gewaehlterAvatar || null }),
}); });
const data = await res.json(); const data = await res.json();
if (!data.ok) { if (!data.ok) {
@@ -74,7 +106,16 @@
(t) => `<div class="stimmen-karte"> (t) => `<div class="stimmen-karte">
<p class="stimmen-karte-text">${escapeHtml(t.nachricht)}</p> <p class="stimmen-karte-text">${escapeHtml(t.nachricht)}</p>
<div class="stimmen-karte-fuss"> <div class="stimmen-karte-fuss">
<span class="stimmen-karte-avatar" aria-hidden="true">${escapeHtml(erstesZeichen(t.name))}</span> ${
/* 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-namen">
<span class="stimmen-karte-name">${escapeHtml(t.name)}</span> <span class="stimmen-karte-name">${escapeHtml(t.name)}</span>
${t.tiktokUsername ? `<span class="stimmen-karte-tiktok">@${escapeHtml(t.tiktokUsername)}</span>` : ""} ${t.tiktokUsername ? `<span class="stimmen-karte-tiktok">@${escapeHtml(t.tiktokUsername)}</span>` : ""}
+30
View File
@@ -222,6 +222,25 @@
background: linear-gradient(120deg, #ff8fc6 0%, #c084fc 30%, #ffd76a 55%, #ff8fc6 80%, #c084fc 100%); background: linear-gradient(120deg, #ff8fc6 0%, #c084fc 30%, #ffd76a 55%, #ff8fc6 80%, #c084fc 100%);
background-size: 340% 340%; background-size: 340% 340%;
} }
/* Dienes Kachel (21.08.2026, Nutzer-Wunsch: "mach eine 3 Zugangskiste ...
ich will nämlich dass Diene schon jetzt auch die Seite sieht, damit wir
mit mehreren Augen die letzten kleinen Details verbessern"). Wieder eine
EIGENE Farbwelt (Smaragd/Mint/Gold) statt einer Umfärbung der beiden
anderen -- so ist auf einen Blick klar, welche Kachel wem gehört. */
.gate-card--diene {
background:
linear-gradient(180deg, rgba(13,24,20,.92), rgba(8,15,13,.96)) padding-box,
linear-gradient(110deg, #6ee7b7, #34d399, #d4af37, #6ee7b7) border-box;
box-shadow: 0 30px 70px -25px rgba(0,0,0,.75), 0 0 50px -20px rgba(52,211,153,.35);
}
.gate-card-icon--diene { background-image: url('assets/img/avatar-diene.jpg'); }
.gate-card--diene .gate-card-icon { border-color: rgba(110,231,183,.65); }
.gate-card--diene .gate-card-name { color: #c7f5e3; }
.gate-card--diene input:focus { border-color: rgba(52,211,153,.7); box-shadow: 0 0 0 4px rgba(52,211,153,.16); }
.gate-card-btn--diene {
background: linear-gradient(120deg, #6ee7b7 0%, #34d399 30%, #ffd76a 55%, #6ee7b7 80%, #34d399 100%);
background-size: 340% 340%;
}
.gate-card .gate-fehler { margin-top: .7rem; } .gate-card .gate-fehler { margin-top: .7rem; }
@media (max-width: 480px) { @media (max-width: 480px) {
.gate-cards { flex-direction: column; align-items: center; } .gate-cards { flex-direction: column; align-items: center; }
@@ -275,6 +294,16 @@
</button> </button>
<p class="gate-fehler" id="gate-fehler-vanvan"></p> <p class="gate-fehler" id="gate-fehler-vanvan"></p>
</form> </form>
<form id="gate-form-diene" class="gate-card gate-card--diene" novalidate>
<span class="gate-card-icon gate-card-icon--diene" aria-hidden="true"></span>
<h2 class="gate-card-name">Diene</h2>
<input type="text" id="gate-code-diene" placeholder="Zugangscode" autocomplete="off" autocapitalize="off" spellcheck="false" />
<button type="submit" class="btn-holo gate-card-btn gate-card-btn--diene">
<span class="btn-holo-text">💚 Öffnen</span>
</button>
<p class="gate-fehler" id="gate-fehler-diene"></p>
</form>
</div> </div>
</div> </div>
@@ -374,6 +403,7 @@
} }
bindeGateKachel("gate-form-dogi", "gate-code-dogi", "gate-fehler-dogi"); bindeGateKachel("gate-form-dogi", "gate-code-dogi", "gate-fehler-dogi");
bindeGateKachel("gate-form-vanvan", "gate-code-vanvan", "gate-fehler-vanvan"); bindeGateKachel("gate-form-vanvan", "gate-code-vanvan", "gate-fehler-vanvan");
bindeGateKachel("gate-form-diene", "gate-code-diene", "gate-fehler-diene");
/* Service-Worker-Registrierung — auf allen anderen Seiten macht das main.js, diese Seite /* Service-Worker-Registrierung — auf allen anderen Seiten macht das main.js, diese Seite
ist aber bewusst eigenstaendig und laedt main.js nicht. Ohne registrierten Service ist aber bewusst eigenstaendig und laedt main.js nicht. Ohne registrierten Service
+7 -1
View File
@@ -194,6 +194,12 @@ export async function gateAuthHandler(req, res) {
let role = null; let role = null;
if (process.env.SITE_ACCESS_CODE_DOGI && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_DOGI)) role = "dogi"; if (process.env.SITE_ACCESS_CODE_DOGI && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_DOGI)) role = "dogi";
else if (process.env.SITE_ACCESS_CODE_VANVAN && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_VANVAN)) role = "vanvan"; else if (process.env.SITE_ACCESS_CODE_VANVAN && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_VANVAN)) role = "vanvan";
// Dritter Zugang, 21.08.2026 (Nutzer-Wunsch: "ich will nämlich dass Diene
// schon jetzt auch die Seite sieht, damit wir mit mehreren Augen die
// letzten kleinen Details verbessern"). Eigener Code + eigene Rolle statt
// Weitergabe eines bestehenden Codes -- so lässt sich der Zugang später
// einzeln wieder entziehen, ohne Dogi oder VanVan auszusperren.
else if (process.env.SITE_ACCESS_CODE_DIENE && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_DIENE)) role = "diene";
if (!role) { if (!role) {
recordGateFailedAttempt(clientKey); recordGateFailedAttempt(clientKey);
@@ -211,7 +217,7 @@ export async function gateAuthHandler(req, res) {
export function gateMiddleware(req, res, next) { export function gateMiddleware(req, res, next) {
const pfad = req.path; const pfad = req.path;
const codesKonfiguriert = !!(process.env.SITE_ACCESS_SECRET && (process.env.SITE_ACCESS_CODE_DOGI || process.env.SITE_ACCESS_CODE_VANVAN)); const codesKonfiguriert = !!(process.env.SITE_ACCESS_SECRET && (process.env.SITE_ACCESS_CODE_DOGI || process.env.SITE_ACCESS_CODE_VANVAN || process.env.SITE_ACCESS_CODE_DIENE));
if (!codesKonfiguriert) return next(); if (!codesKonfiguriert) return next();
if (pfad === "/gate-auth" && req.method === "POST") { if (pfad === "/gate-auth" && req.method === "POST") {
+16
View File
@@ -67,6 +67,21 @@
<label for="st-nachricht" data-i18n="st_form_nachricht_label">Deine Nachricht</label> <label for="st-nachricht" data-i18n="st_form_nachricht_label">Deine Nachricht</label>
<textarea id="st-nachricht" maxlength="500" rows="4" required data-i18n-attr="placeholder:st_form_nachricht_placeholder" placeholder="Erzähl uns, was dir Team Dogi bedeutet …"></textarea> <textarea id="st-nachricht" maxlength="500" rows="4" required data-i18n-attr="placeholder:st_form_nachricht_placeholder" placeholder="Erzähl uns, was dir Team Dogi bedeutet …"></textarea>
</div> </div>
<!-- Profilbild-Auswahl (21.08.2026, Nutzer-Wunsch: "ich soll auch
paar geile bilder zum aussuchen haben als profilbild falls die
leute kein bild mit schicken, das soll denen auch als
möglichkeit gegeben sein"). Bewusst OHNE Datei-Upload: das
Formular ist öffentlich und ohne Anmeldung erreichbar, ein
offener Upload wäre eine Einladung für beliebiges Material.
Wer ein eigenes Foto möchte, schickt es Dogi direkt — er kann
es in der Verwaltung hinterlegen. Die Kacheln werden aus
data-stimmen-avatare.js aufgebaut, damit Seite und Verwaltung
garantiert dieselbe Auswahl zeigen. -->
<div class="field">
<span class="stimmen-avatar-titel" data-i18n="st_form_avatar_label">Dein Profilbild (optional)</span>
<p class="small muted stimmen-avatar-hinweis" data-i18n="st_form_avatar_hinweis">Ohne Auswahl erscheint einfach der erste Buchstabe deines Namens.</p>
<div class="stimmen-avatar-wahl" id="stimmen-avatar-wahl" role="radiogroup" aria-labelledby="stimmen-avatar-titel"></div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;" data-i18n="st_form_absenden">💙 Nachricht einreichen</button> <button type="submit" class="btn btn-primary" style="width:100%;" data-i18n="st_form_absenden">💙 Nachricht einreichen</button>
</form> </form>
<p class="stimmen-erfolg" id="stimmen-erfolg" data-i18n="st_form_erfolg">Danke dir! Deine Nachricht wurde eingereicht und wird von Dogi persönlich geprüft. 🩵</p> <p class="stimmen-erfolg" id="stimmen-erfolg" data-i18n="st_form_erfolg">Danke dir! Deine Nachricht wurde eingereicht und wird von Dogi persönlich geprüft. 🩵</p>
@@ -87,6 +102,7 @@
</main> </main>
<div id="site-footer"></div> <div id="site-footer"></div>
<script src="assets/js/data-stimmen-avatare.js?v=20260821p"></script>
<script src="assets/js/i18n-stimmen.js?v=20260821p"></script> <script src="assets/js/i18n-stimmen.js?v=20260821p"></script>
<script src="assets/js/main.js?v=20260821p"></script> <script src="assets/js/main.js?v=20260821p"></script>
<script src="assets/js/stimmen.js?v=20260821p"></script> <script src="assets/js/stimmen.js?v=20260821p"></script>
+119
View File
@@ -69,6 +69,15 @@
Sättigung und weniger Helligkeit → sticht jetzt genauso klar hervor Sättigung und weniger Helligkeit → sticht jetzt genauso klar hervor
wie die anderen drei Akzentfarben. wie die anderen drei Akzentfarben.
===================================================================== */ ===================================================================== */
/* Profilbild-Auswahl in "Stimmen verwalten" (21.08.2026). Die Kacheln
selbst kommen aus main.css (.stimmen-avatar-*) — hier nur die Einbettung
in die Verwaltungs-Karte, etwas kleiner als auf der oeffentlichen Seite,
damit eine Einreichung nicht unnoetig hoch wird. */
.vw-stimme-avatar-block { margin-top: 1rem; padding-top: .9rem; border-top: 1px solid var(--border); }
.vw-stimme-avatar-wahl { grid-template-columns: repeat(auto-fill, minmax(46px, 1fr)); max-width: 520px; }
.vw-stimme-upload { display: block; margin-top: .8rem; color: var(--text-muted); }
.vw-stimme-upload input { display: block; margin-top: .3rem; }
#supp-root, #vw-stats-root, #session-bar { --accent: #7dd3fc; --accent-2: #38bdf8; --accent-contrast: #1a1408; } #supp-root, #vw-stats-root, #session-bar { --accent: #7dd3fc; --accent-2: #38bdf8; --accent-contrast: #1a1408; }
body { position: relative; } body { position: relative; }
/* Nutzer-Wunsch 04.08.2026 (3. Runde): "alles bisschen größer, Schrift /* Nutzer-Wunsch 04.08.2026 (3. Runde): "alles bisschen größer, Schrift
@@ -1211,6 +1220,7 @@
<script src="assets/js/data-scouts.js?v=20260821p"></script> <script src="assets/js/data-scouts.js?v=20260821p"></script>
<script src="assets/js/data-creator.js?v=20260821p"></script> <script src="assets/js/data-creator.js?v=20260821p"></script>
<script src="assets/js/data-manager.js?v=20260821p"></script> <script src="assets/js/data-manager.js?v=20260821p"></script>
<script src="assets/js/data-stimmen-avatare.js?v=20260821p"></script>
<script src="assets/js/main.js?v=20260821p"></script> <script src="assets/js/main.js?v=20260821p"></script>
<script src="assets/js/admin-auth.js?v=20260821p"></script> <script src="assets/js/admin-auth.js?v=20260821p"></script>
<script> <script>
@@ -1689,6 +1699,43 @@
stehen nur die sinnvollen Aktionen zur Verfügung. */ stehen nur die sinnvollen Aktionen zur Verfügung. */
const TESTIMONIAL_STATUS_LABELS = { eingereicht: "🟡 Neu", freigegeben: "🟢 Freigegeben", abgelehnt: "⚪ Abgelehnt" }; const TESTIMONIAL_STATUS_LABELS = { eingereicht: "🟡 Neu", freigegeben: "🟢 Freigegeben", abgelehnt: "⚪ Abgelehnt" };
/* Profilbild-Auswahl je Einreichung (21.08.2026, Nutzer-Wunsch: "ich soll
auch paar geile bilder zum aussuchen haben als profilbild"). Nutzt
dieselbe Liste wie die öffentliche Seite (data-stimmen-avatare.js), damit
Dogi genau die Bilder sieht, die es auch wirklich gibt. Zusätzlich zu den
Auswahlbildern kann hier ein eigenes Foto hochgeladen werden — das darf
bewusst NUR die Verwaltung, nicht das öffentliche Formular. */
function avatarWahlHtml(t) {
const liste = window.STIMMEN_AVATARE || [];
const aktiv = (wert) => (t.avatar === wert ? " aktiv" : "");
const eigenes = t.avatar && /^https?:\/\//i.test(t.avatar);
return `
<div class="vw-stimme-avatar-block">
<span class="small" style="color:var(--text-muted);display:block;margin-bottom:.45rem;">Profilbild</span>
<div class="stimmen-avatar-wahl vw-stimme-avatar-wahl">
<button type="button" class="stimmen-avatar-kachel${aktiv(null) || (t.avatar ? "" : " aktiv")}" data-avatar="" title="Kein Bild (Anfangsbuchstabe)">
<span class="stimmen-avatar-keins" aria-hidden="true">✦</span>
</button>
${liste
.map(
(a) =>
`<button type="button" class="stimmen-avatar-kachel${aktiv(a.id)}" data-avatar="${a.id}" title="${escapeHtml(a.name)}"><img src="${a.datei}" alt="" loading="lazy" /></button>`
)
.join("")}
${
eigenes
? `<button type="button" class="stimmen-avatar-kachel aktiv" data-avatar="${escapeHtml(t.avatar)}" title="Eigenes Foto"><img src="${escapeHtml(t.avatar)}" alt="" loading="lazy" /></button>`
: ""
}
</div>
<label class="small vw-stimme-upload">
Eigenes Foto hochladen (max. 5 MB, JPG/PNG/WebP)
<input type="file" class="testimonial-foto-input" accept="image/jpeg,image/png,image/webp" />
</label>
<p class="small testimonial-avatar-status" style="margin:.35rem 0 0;"></p>
</div>`;
}
function testimonialKarteHtml(t) { function testimonialKarteHtml(t) {
return `<div class="card vw-card vw-shine-card" style="margin-bottom:1rem;padding:1.3rem 1.4rem;" data-testimonial-id="${t.id}"> return `<div class="card vw-card vw-shine-card" style="margin-bottom:1rem;padding:1.3rem 1.4rem;" data-testimonial-id="${t.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;">
@@ -1703,6 +1750,7 @@
</div> </div>
</div> </div>
<p style="margin:.8rem 0 0;">${escapeHtml(t.nachricht)}</p> <p style="margin:.8rem 0 0;">${escapeHtml(t.nachricht)}</p>
${avatarWahlHtml(t)}
</div>`; </div>`;
} }
@@ -1733,6 +1781,77 @@
await ladeUndRenderTestimonials(); await ladeUndRenderTestimonials();
}); });
}); });
/* Profilbild anklicken = sofort speichern. Bewusst OHNE die ganze Liste
neu zu laden: sonst springt die Ansicht bei jedem Klick nach oben und
man verliert die Stelle, an der man gerade arbeitet. Stattdessen wird
nur die Markierung innerhalb dieser einen Karte umgesetzt. */
listeEl.querySelectorAll(".vw-stimme-avatar-wahl").forEach((wahl) => {
wahl.addEventListener("click", async (e) => {
const kachel = e.target.closest(".stimmen-avatar-kachel");
if (!kachel) return;
const karte = kachel.closest("[data-testimonial-id]");
const statusEl = karte.querySelector(".testimonial-avatar-status");
const avatar = kachel.dataset.avatar || "";
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/avatar", {
id: karte.dataset.testimonialId,
avatar,
});
if (!res.ok) {
statusEl.textContent = res.error || "Speichern fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
wahl.querySelectorAll(".stimmen-avatar-kachel").forEach((b) => b.classList.remove("aktiv"));
kachel.classList.add("aktiv");
statusEl.textContent = "✅ Profilbild gespeichert.";
statusEl.style.color = "#baf7d6";
});
});
/* Eigenes Foto: wird sofort bei der Auswahl hochgeladen UND direkt als
Bild dieser Stimme gesetzt — ein zusätzlicher "Speichern"-Klick wäre
hier nur eine Stolperfalle (dieselbe Logik wie bei den Event-Bildern). */
listeEl.querySelectorAll(".testimonial-foto-input").forEach((input) => {
input.addEventListener("change", async () => {
const datei = input.files?.[0];
if (!datei) return;
const karte = input.closest("[data-testimonial-id]");
const statusEl = karte.querySelector(".testimonial-avatar-status");
statusEl.style.color = "";
statusEl.textContent = "Foto wird hochgeladen …";
const form = new FormData();
form.append("image", datei);
const session = window.AdminAuth.getSession();
try {
const res = await fetch(`${API_BASE_URL}/admin/testimonials/upload-avatar`, {
method: "POST",
headers: session?.token ? { Authorization: `Bearer ${session.token}` } : {},
body: form,
});
const data = await res.json();
if (!data.ok) {
statusEl.textContent = data.error || "Upload fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
const gesetzt = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/testimonials/avatar", {
id: karte.dataset.testimonialId,
avatar: data.url,
});
if (!gesetzt.ok) {
statusEl.textContent = gesetzt.error || "Foto konnte nicht gesetzt werden.";
statusEl.style.color = "#ff6b6b";
return;
}
await ladeUndRenderTestimonials();
} catch {
statusEl.textContent = "Upload fehlgeschlagen (keine Verbindung).";
statusEl.style.color = "#ff6b6b";
}
});
});
} }
/* ===== "Team verwalten" — Modis/Scouts/Creator/Manager (21.08.2026) ===== /* ===== "Team verwalten" — Modis/Scouts/Creator/Manager (21.08.2026) =====