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:
@@ -4107,6 +4107,61 @@ body.start-seite .music-toggle:focus-visible {
|
||||
background: linear-gradient(135deg, #ffd0e8, #a8e4f2);
|
||||
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-name { font-weight: 700; color: var(--text); font-size: .95rem; }
|
||||
.stimmen-karte-tiktok {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -40,6 +40,17 @@ window.I18N_PAGE = {
|
||||
de: "Deine Nachricht", "de-CH": "Dini Nachricht",
|
||||
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: {
|
||||
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ê …",
|
||||
|
||||
+43
-2
@@ -9,6 +9,38 @@
|
||||
(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>`;
|
||||
|
||||
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");
|
||||
if (formular) {
|
||||
const erfolgEl = document.getElementById("stimmen-erfolg");
|
||||
@@ -28,7 +60,7 @@
|
||||
const res = await fetch(`${API_BASE_URL}/testimonials/submit`, {
|
||||
method: "POST",
|
||||
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();
|
||||
if (!data.ok) {
|
||||
@@ -74,7 +106,16 @@
|
||||
(t) => `<div class="stimmen-karte">
|
||||
<p class="stimmen-karte-text">${escapeHtml(t.nachricht)}</p>
|
||||
<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-name">${escapeHtml(t.name)}</span>
|
||||
${t.tiktokUsername ? `<span class="stimmen-karte-tiktok">@${escapeHtml(t.tiktokUsername)}</span>` : ""}
|
||||
|
||||
@@ -222,6 +222,25 @@
|
||||
background: linear-gradient(120deg, #ff8fc6 0%, #c084fc 30%, #ffd76a 55%, #ff8fc6 80%, #c084fc 100%);
|
||||
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; }
|
||||
@media (max-width: 480px) {
|
||||
.gate-cards { flex-direction: column; align-items: center; }
|
||||
@@ -275,6 +294,16 @@
|
||||
</button>
|
||||
<p class="gate-fehler" id="gate-fehler-vanvan"></p>
|
||||
</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>
|
||||
|
||||
@@ -374,6 +403,7 @@
|
||||
}
|
||||
bindeGateKachel("gate-form-dogi", "gate-code-dogi", "gate-fehler-dogi");
|
||||
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
|
||||
ist aber bewusst eigenstaendig und laedt main.js nicht. Ohne registrierten Service
|
||||
|
||||
+7
-1
@@ -194,6 +194,12 @@ export async function gateAuthHandler(req, res) {
|
||||
let role = null;
|
||||
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";
|
||||
// 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) {
|
||||
recordGateFailedAttempt(clientKey);
|
||||
@@ -211,7 +217,7 @@ export async function gateAuthHandler(req, res) {
|
||||
export function gateMiddleware(req, res, next) {
|
||||
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 (pfad === "/gate-auth" && req.method === "POST") {
|
||||
|
||||
@@ -67,6 +67,21 @@
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
@@ -87,6 +102,7 @@
|
||||
</main>
|
||||
|
||||
<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/main.js?v=20260821p"></script>
|
||||
<script src="assets/js/stimmen.js?v=20260821p"></script>
|
||||
|
||||
+119
@@ -69,6 +69,15 @@
|
||||
Sättigung und weniger Helligkeit → sticht jetzt genauso klar hervor
|
||||
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; }
|
||||
body { position: relative; }
|
||||
/* 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-creator.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/admin-auth.js?v=20260821p"></script>
|
||||
<script>
|
||||
@@ -1689,6 +1699,43 @@
|
||||
stehen nur die sinnvollen Aktionen zur Verfügung. */
|
||||
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) {
|
||||
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;">
|
||||
@@ -1703,6 +1750,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin:.8rem 0 0;">${escapeHtml(t.nachricht)}</p>
|
||||
${avatarWahlHtml(t)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1733,6 +1781,77 @@
|
||||
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) =====
|
||||
|
||||
Reference in New Issue
Block a user