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);
|
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 {
|
||||||
|
|||||||
@@ -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",
|
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
@@ -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>` : ""}
|
||||||
|
|||||||
@@ -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
@@ -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") {
|
||||||
|
|||||||
@@ -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
@@ -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) =====
|
||||||
|
|||||||
Reference in New Issue
Block a user