Team-Verwaltung Runde 2: bestehende Mitglieder importieren + Kategorien/Seitentitel editierbar

Nutzer-Wunsch 21.08.2026: "wenn ich eine änderung mache ist es auch für
jeden für die die schon da sind und die neuen" + "ich will ich titel
und namen von kategorien und seiten ändern können in der verwaltung".

Backend (braucht Filipes manuellen Deploy):
- Migration 0008: team_members bekommt intro/bioHtml/extraCta-Spalten
  -- volle Feld-Parität mit den von Hand gepflegten Profilen (Diene/
  Patrick/Bananenstift/Marina nutzen diese Felder).
- routes/team.js: neue importLegacyMember()-Funktion -- übernimmt ein
  bestehendes Profil 1:1 in die Datenbank, OHNE erneut zu übersetzen
  (die vorhandenen, von Hand geschriebenen Übersetzungen bleiben
  erhalten). Idempotent: mehrfacher Import erzeugt keine Duplikate.
- routes/site-texts.js (neu): admin-editierbare Kategorie-Namen und
  Seitentitel, generischer key->{de,...}-Override über app_settings
  (wie "Event des Jahres"), fester Schlüssel-Katalog aus
  Sicherheitsgründen. Leeres Feld setzt auf den Standardtext zurück.
- 27 Tests in isolierter Testumgebung geprüft (kein better-sqlite3
  lokal kompilierbar).

Frontend:
- window.dogiTeamZusammenfuehren() (main.js): admin-Mitglieder
  ÜBERSCHREIBEN jetzt gleichnamige statische Einträge (per slug) statt
  sie zu duplizieren -- eine Bearbeitung wirkt dadurch für alle.
- window.dogiSiteTexteLaden() + applyTranslations() erweitert um
  data-site-text-key -- Kategorie-Seitentitel (team-modis.html/
  team-scouts.html/creator.html/manager.html) sind jetzt live editierbar.
- Verwaltung: "📥 Bestehende Mitglieder importieren"-Knopf (holt VanVan/
  Diene/Funny/Miss/Marina/Ghost/Patrick/Bananenstift aus den data-*.js-
  Dateien, VanVan bewusst ausgenommen -- eigene Sonderkarte + Seite),
  erweitertes Formular (Intro/ausführliche Vorstellung/zweiter Button,
  eingeklappt unter "Erweitert"), neue Sektion "Kategorien &
  Seitentitel" (4 Karten, sofort wirksam auf Website UND Verwaltung).

Beim Testen einen echten UX-Bug gefunden und gefixt: die "Gespeichert"-
Meldung nach dem Speichern eines Mitglieds wurde von der direkt
anschließenden Formular-Zurücksetzung sofort wieder überschrieben und
war nie sichtbar.

Cache-Busting-Version auf 20260821k erhöht.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-21 03:47:51 +02:00
co-authored by Claude Sonnet 5
parent 7060a4a6ae
commit 81f55d67db
41 changed files with 894 additions and 306 deletions
+279 -8
View File
@@ -19,10 +19,10 @@
<link rel="apple-touch-icon" href="assets/img/apple-touch-icon-admin.png" />
<meta name="apple-mobile-web-app-title" content="DogiCrew-Verwaltung" />
<meta name="theme-color" content="#0e0a16" />
<link rel="stylesheet" href="assets/css/main.css?v=20260821j" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821j" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821j" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821j" />
<link rel="stylesheet" href="assets/css/main.css?v=20260821k" />
<link rel="stylesheet" href="assets/css/theme-dogfather.css?v=20260821k" />
<link rel="stylesheet" href="assets/css/theme-hasidog.css?v=20260821k" />
<link rel="stylesheet" href="assets/css/theme-spicymedia.css?v=20260821k" />
<style>
/* =====================================================================
verwaltung.html — Dogis privater Command-Center-Bereich.
@@ -880,6 +880,20 @@
Speichern automatisch übersetzt.
</p>
<!-- 21.08.2026 (Runde 2, Nutzer-Wunsch: "wenn ich eine änderung mache
ist es auch für jeden für die die schon da sind und die neuen"):
holt VanVan, Diene, Funny, Miss, Marina, Ghost, Patrick,
Bananenstift usw. einmalig aus den bestehenden data-*.js-Dateien
in die Datenbank -- danach sind sie hier genauso bearbeitbar wie
jedes neu angelegte Mitglied. Übernimmt die vorhandenen, von Hand
geschriebenen Übersetzungen 1:1 (übersetzt NICHT erneut
maschinell). Mehrfach klickbar, ohne Duplikate zu erzeugen --
schon importierte Personen werden dabei einfach übersprungen. -->
<div class="text-center" style="margin-bottom:1.6rem;">
<button type="button" class="btn btn-outline" id="team-import-btn">📥 Bestehende Mitglieder importieren</button>
<p class="small muted" id="team-import-status" style="margin-top:.5rem;"></p>
</div>
<div class="card vw-card vw-shine-card vw-noise" style="max-width:640px;margin:0 auto 2rem;padding:1.5rem;">
<h3 id="team-form-titel" style="margin-top:0;">Neues Mitglied</h3>
<input type="hidden" id="team-id" value="" />
@@ -919,6 +933,40 @@
<label for="team-zitat">Zitat/Motto (Deutsch, optional)</label>
<input type="text" id="team-zitat" maxlength="200" />
</div>
<!-- 21.08.2026 (Runde 2): dieselbe Feld-Tiefe wie die von Hand
gepflegten Profile (siehe data-modis.js: intro/bioHtml/
extraCta) -- eingeklappt, weil die meisten Mitglieder das
nicht brauchen (nur wenige bestehende Profile wie Diene/
Patrick/Bananenstift/Marina nutzen das überhaupt). -->
<details style="margin-bottom:1.2rem;">
<summary style="cursor:pointer;color:var(--accent);font-weight:600;">Erweitert (optional)</summary>
<div style="margin-top:1rem;">
<div class="field">
<label for="team-intro">Kurzer Intro-Text neben dem Foto (Deutsch, optional, HTML erlaubt)</label>
<textarea id="team-intro" maxlength="2000" rows="2" placeholder="Ersetzt/ergänzt die kurze Vorstellung oben direkt neben dem Foto."></textarea>
</div>
<div class="field">
<label for="team-bio-html">Ausführliche Vorstellung (Deutsch, optional, HTML erlaubt: &lt;p&gt;/&lt;h3&gt;/&lt;strong&gt;/&lt;ul&gt;&lt;li&gt;)</label>
<textarea id="team-bio-html" maxlength="6000" rows="6" placeholder="Erscheint als breiter Lesebereich unterhalb des Steckbriefs, wie bei Diene/Patrick/Bananenstift."></textarea>
</div>
<div class="grid grid-2" style="gap:.8rem;">
<div class="field">
<label for="team-extra-cta-href">Zweiter Button: Ziel (z. B. manager.html)</label>
<input type="text" id="team-extra-cta-href" maxlength="300" placeholder="z. B. manager.html" />
</div>
<div class="field">
<label for="team-extra-cta-label">Zweiter Button: Text (Deutsch)</label>
<input type="text" id="team-extra-cta-label" maxlength="60" placeholder="z. B. Zum Manager-Profil" />
</div>
</div>
<p class="small muted" style="margin:.3rem 0 0;">
Zweiter Button erscheint zusätzlich zu "Profil ansehen" auf der Karte (aktuell nur bei
Marina genutzt, Verlinkung zu manager.html).
</p>
</div>
</details>
<div id="team-rang-felder" style="display:none;">
<div class="grid grid-2" style="gap:.8rem;">
<div class="field">
@@ -961,6 +1009,27 @@
</div>
</section>
<!-- ===== "Kategorien & Seitentitel" (21.08.2026, Nutzer-Wunsch: "ich
will ich titel und namen von kategorien und seiten ändern können in
der verwaltung"). Ändert die Kategorie-Namen (z.B. "Modi") und die
Überschrift der jeweiligen öffentlichen Seite -- wirkt sofort
überall, wo diese Kategorie auftaucht (siehe data-site-text-key in
team-modis.html/team-scouts.html/creator.html/manager.html sowie
hier in der Team-Verwaltung selbst). Leeres Feld = zurück zum
eingebauten Standardtext. Gleiche TEAM_MANAGE-Berechtigung wie die
Team-Verwaltung, weil beides zusammengehört. ===== -->
<section class="section-tight" id="site-texts-root" style="display:none;padding-top:0;">
<div class="container">
<h2 class="text-center" style="margin-bottom:.3rem;">🏷️ Kategorien &amp; Seitentitel</h2>
<p class="small muted text-center" style="max-width:64ch;margin:0 auto 1.4rem;">
Ändert, wie die vier Team-Kategorien überall auf der Seite (und hier in der Verwaltung)
heißen. Deutsch eintippen, der Rest wird automatisch übersetzt. Feld leer lassen und
speichern setzt wieder auf den Standardtext zurück.
</p>
<div class="grid grid-2" id="site-texts-grid" style="gap:1.2rem;max-width:900px;margin:0 auto;"></div>
</div>
</section>
<!-- ===== Supporter-Liste ===== -->
<section class="section-tight" id="supp-root" style="display:none;padding-top:0;">
<div class="container" id="liste-ansicht">
@@ -1135,8 +1204,15 @@
</main>
<div id="site-footer"></div>
<script src="assets/js/main.js?v=20260821j"></script>
<script src="assets/js/admin-auth.js?v=20260821j"></script>
<!-- 21.08.2026: für den "Bestehende Mitglieder importieren"-Knopf in der
Team-Verwaltung -- liest die von Hand gepflegten Profile aus diesen
Dateien aus und bietet an, sie 1:1 in die Datenbank zu übernehmen. -->
<script src="assets/js/data-modis.js?v=20260821k"></script>
<script src="assets/js/data-scouts.js?v=20260821k"></script>
<script src="assets/js/data-creator.js?v=20260821k"></script>
<script src="assets/js/data-manager.js?v=20260821k"></script>
<script src="assets/js/main.js?v=20260821k"></script>
<script src="assets/js/admin-auth.js?v=20260821k"></script>
<script>
(function () {
const API_BASE_URL = "https://postfach.dogfather-universe.com";
@@ -1219,6 +1295,8 @@
if (window.AdminAuth.sessionHasPermission(session, "TEAM_MANAGE")) {
document.getElementById("team-root").style.display = "";
await ladeUndRenderTeam();
document.getElementById("site-texts-root").style.display = "";
await ladeUndRenderSiteTexte();
}
const pollsBox = document.getElementById("box-abstimmungen");
if (window.AdminAuth.sessionHasPermission(session, "POLLS_MANAGE")) {
@@ -1240,6 +1318,7 @@
document.getElementById("special-event-root").style.display = "none";
document.getElementById("testimonials-root").style.display = "none";
document.getElementById("team-root").style.display = "none";
document.getElementById("site-texts-root").style.display = "none";
document.getElementById("box-abstimmungen").style.display = "none";
}
@@ -1729,6 +1808,10 @@
document.getElementById("team-tiktok").value = "";
document.getElementById("team-bio").value = "";
document.getElementById("team-zitat").value = "";
document.getElementById("team-intro").value = "";
document.getElementById("team-bio-html").value = "";
document.getElementById("team-extra-cta-href").value = "";
document.getElementById("team-extra-cta-label").value = "";
document.getElementById("team-rang").value = "";
document.getElementById("team-rang-label").value = "";
document.getElementById("team-reihenfolge").value = "0";
@@ -1737,6 +1820,7 @@
document.getElementById("team-foto-input").value = "";
document.getElementById("team-abbrechen").style.display = "none";
document.getElementById("team-status").textContent = "";
document.getElementById("team-intro").closest("details").open = false;
teamHochgeladenesFoto = null;
teamRangFelderAnpassen();
}
@@ -1750,10 +1834,18 @@
document.getElementById("team-tiktok").value = m.tiktok || "";
document.getElementById("team-bio").value = m.bio?.de || "";
document.getElementById("team-zitat").value = m.zitat?.de || "";
document.getElementById("team-intro").value = m.intro?.de || "";
document.getElementById("team-bio-html").value = m.bioHtml?.de || "";
document.getElementById("team-extra-cta-href").value = m.extraCta?.href || "";
document.getElementById("team-extra-cta-label").value = m.extraCta?.label?.de || "";
document.getElementById("team-rang").value = m.rang || "";
document.getElementById("team-rang-label").value = m.rangLabel?.de || "";
document.getElementById("team-reihenfolge").value = m.reihenfolge ?? 0;
document.getElementById("team-aktiv").checked = m.status !== "pausiert";
// "Erweitert" automatisch aufklappen, wenn schon etwas drinsteht --
// sonst würde man leicht übersehen, dass z.B. Diene eine lange
// bioHtml-Vorstellung hat, weil sie eingeklappt verborgen bliebe.
document.getElementById("team-intro").closest("details").open = !!(m.intro?.de || m.bioHtml?.de || m.extraCta?.href);
teamHochgeladenesFoto = m.foto || null;
const vorschau = document.getElementById("team-foto-vorschau");
if (teamHochgeladenesFoto) {
@@ -1831,6 +1923,10 @@
tiktok: document.getElementById("team-tiktok").value.trim(),
bio: document.getElementById("team-bio").value.trim(),
zitat: document.getElementById("team-zitat").value.trim(),
intro: document.getElementById("team-intro").value.trim(),
bioHtml: document.getElementById("team-bio-html").value.trim(),
extraCtaHref: document.getElementById("team-extra-cta-href").value.trim(),
extraCtaLabel: document.getElementById("team-extra-cta-label").value.trim(),
rang: document.getElementById("team-rang").value,
rangLabel: document.getElementById("team-rang-label").value.trim(),
reihenfolge: document.getElementById("team-reihenfolge").value,
@@ -1842,10 +1938,15 @@
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "✅ Gespeichert.";
statusEl.style.color = "#baf7d6";
teamFormularZuruecksetzen();
await ladeUndRenderTeam();
// WICHTIG: erst NACH teamFormularZuruecksetzen() setzen -- die Funktion
// leert team-status als Teil des normalen Zurücksetzens, ein Erfolgs-
// text davor wäre also sofort wieder verschwunden, bevor er je zu sehen
// war (mit Playwright gefunden: der Status blieb nach dem Speichern
// immer leer).
document.getElementById("team-status").textContent = "✅ Gespeichert.";
document.getElementById("team-status").style.color = "#baf7d6";
});
document.getElementById("team-filter-bar").addEventListener("click", (e) => {
@@ -1859,6 +1960,176 @@
teamRangFelderAnpassen();
/* "Bestehende Mitglieder importieren" (21.08.2026, Runde 2, Nutzer-Wunsch:
"wenn ich eine änderung mache ist es auch für jeden für die die schon
da sind und die neuen"). Liest VanVan/Diene/Funny/Miss/Marina/Ghost
(window.MODIS, VanVan bewusst ausgenommen -- sie hat ihre eigene,
abweichend gebaute Hervorhebungs-Karte + eigene Seite vanvan.html,
passt nicht ins generische Schema), Patrick/Bananenstift
(window.SCOUTS) sowie window.CREATORS/MANAGERS (aktuell leer) aus und
schickt jede Person 1:1 an /admin/team/import. Übernimmt die
VORHANDENEN, von Hand geschriebenen Übersetzungen -- KEINE erneute
Maschinenübersetzung. Mehrfach klickbar (idempotent, siehe
routes/team.js): bereits importierte Personen werden serverseitig
einfach übersprungen, nie dupliziert. */
function zuSprachObjekt(feld) {
if (!feld) return null;
if (typeof feld === "string") return { de: feld, "de-CH": feld, en: feld, fr: feld, pt: feld };
if (typeof feld === "object" && feld.de) return feld;
return null;
}
function slugAusNameClient(name) {
return String(name || "")
.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
document.getElementById("team-import-btn").addEventListener("click", async () => {
const btn = document.getElementById("team-import-btn");
const statusEl = document.getElementById("team-import-status");
btn.disabled = true;
statusEl.textContent = "Importiere …";
const quellen = [
...(window.MODIS || []).filter((p) => p.name !== "VanVan").map((p) => ({ p, kategorie: "modi" })),
...(window.SCOUTS || []).map((p) => ({ p, kategorie: "scout" })),
...(window.CREATORS || []).map((p) => ({ p, kategorie: "creator" })),
...(window.MANAGERS || []).map((p) => ({ p, kategorie: "manager" })),
];
let importiert = 0, uebersprungen = 0, fehler = 0;
for (const { p, kategorie } of quellen) {
const rolle = zuSprachObjekt(p.role);
if (!rolle) { fehler++; continue; } // Pflichtfeld fehlt -- diese Person überspringen, nicht alle abbrechen
const payload = {
kategorie,
name: p.name,
slug: p.slug || slugAusNameClient(p.name),
rolle,
foto: p.photo || p.avatar || "",
tiktok: p.social?.tiktok || null,
bio: zuSprachObjekt(p.bio),
intro: zuSprachObjekt(p.intro),
bioHtml: zuSprachObjekt(p.bioHtml),
zitat: zuSprachObjekt(p.quote),
rang: p.rank || null,
rangLabel: zuSprachObjekt(p.rankLabel),
extraCta: p.extraCta?.href ? { href: p.extraCta.href, label: zuSprachObjekt(p.extraCta.label) } : null,
};
try {
const res = await window.AdminAuth.apiCall(API_BASE_URL, "/admin/team/import", payload);
if (res.ok && res.uebersprungen) uebersprungen++;
else if (res.ok) importiert++;
else fehler++;
} catch {
fehler++;
}
}
statusEl.textContent = `✅ ${importiert} importiert, ${uebersprungen} schon vorhanden` + (fehler ? `, ${fehler} übersprungen (fehlerhaft)` : "") + ".";
btn.disabled = false;
await ladeUndRenderTeam();
});
/* ===== "Kategorien & Seitentitel" (21.08.2026) =====
Ein Kärtchen pro Kategorie mit zwei Feldern (Kategorie-Name,
Seitentitel) -- speichert beide über denselben admin/site-texts/save-
Endpunkt wie zwei unabhängige Overrides. Aktualisiert nach dem
Speichern sofort die Kategorie-Badges in der Team-Liste, damit man den
Effekt nicht erst durch Neuladen sehen muss. */
const SITE_TEXT_KATEGORIEN = [
{ kat: "modi", label: "Modi", kategorieKey: "kategorie_modi", seitentitelKey: "seitentitel_modi", kategoriePlatzhalter: "Modi", seitentitelPlatzhalter: "Das Modi-Team" },
{ kat: "scout", label: "Scout", kategorieKey: "kategorie_scout", seitentitelKey: "seitentitel_scout", kategoriePlatzhalter: "Scouts", seitentitelPlatzhalter: "Mein Scout-Team" },
{ kat: "creator", label: "Creator", kategorieKey: "kategorie_creator", seitentitelKey: "seitentitel_creator", kategoriePlatzhalter: "Creator", seitentitelPlatzhalter: "Creator im Team" },
{ kat: "manager", label: "Manager", kategorieKey: "kategorie_manager", seitentitelKey: "seitentitel_manager", kategoriePlatzhalter: "Manager", seitentitelPlatzhalter: "Weitere Manager" },
];
function siteTextKarteHtml(eintrag) {
return `<div class="card vw-card vw-shine-card vw-noise" style="padding:1.3rem;" data-site-text-kat="${eintrag.kat}">
<h3 style="margin-top:0;">${eintrag.label}</h3>
<div class="field">
<label>Kategorie-Name</label>
<input type="text" class="site-text-kategorie-input" maxlength="40" placeholder="${eintrag.kategoriePlatzhalter}" />
</div>
<div class="field">
<label>Seitentitel</label>
<input type="text" class="site-text-seitentitel-input" maxlength="100" placeholder="${eintrag.seitentitelPlatzhalter}" />
</div>
<button type="button" class="btn btn-outline site-text-speichern-btn" style="width:100%;">Speichern</button>
<p class="small site-text-status" style="margin-top:.5rem;"></p>
</div>`;
}
async function teamKategorieLabelsAktualisieren(texteVorhanden) {
let texte = texteVorhanden;
if (!texte) {
try {
const res = await fetch(`${API_BASE_URL}/site-texts`, { cache: "no-store" });
const data = await res.json();
texte = data.ok ? data.texte || {} : {};
} catch {
texte = {};
}
}
Object.keys(TEAM_KATEGORIE_LABELS).forEach((kat) => {
const override = texte[`kategorie_${kat}`]?.de;
if (override) TEAM_KATEGORIE_LABELS[kat] = override;
});
document.querySelectorAll("#team-filter-bar .filter-btn[data-team-filter]").forEach((btn) => {
const kat = btn.dataset.teamFilter;
if (TEAM_KATEGORIE_LABELS[kat]) btn.textContent = TEAM_KATEGORIE_LABELS[kat];
});
}
async function ladeUndRenderSiteTexte() {
const grid = document.getElementById("site-texts-grid");
grid.innerHTML = SITE_TEXT_KATEGORIEN.map(siteTextKarteHtml).join("");
let aktuelleTexte = {};
try {
const res = await fetch(`${API_BASE_URL}/site-texts`, { cache: "no-store" });
const data = await res.json();
if (data.ok) aktuelleTexte = data.texte || {};
} catch {
/* Formular bleibt leer (= eingebauter Standardtext), Speichern funktioniert trotzdem. */
}
SITE_TEXT_KATEGORIEN.forEach((eintrag) => {
const karte = grid.querySelector(`[data-site-text-kat="${eintrag.kat}"]`);
karte.querySelector(".site-text-kategorie-input").value = aktuelleTexte[eintrag.kategorieKey]?.de || "";
karte.querySelector(".site-text-seitentitel-input").value = aktuelleTexte[eintrag.seitentitelKey]?.de || "";
karte.querySelector(".site-text-speichern-btn").addEventListener("click", async () => {
const statusEl = karte.querySelector(".site-text-status");
const btn = karte.querySelector(".site-text-speichern-btn");
btn.disabled = true;
statusEl.textContent = "Wird gespeichert …";
statusEl.style.color = "";
const kategorieText = karte.querySelector(".site-text-kategorie-input").value.trim();
const seitentitelText = karte.querySelector(".site-text-seitentitel-input").value.trim();
const [r1, r2] = await Promise.all([
window.AdminAuth.apiCall(API_BASE_URL, "/admin/site-texts/save", { key: eintrag.kategorieKey, text: kategorieText }),
window.AdminAuth.apiCall(API_BASE_URL, "/admin/site-texts/save", { key: eintrag.seitentitelKey, text: seitentitelText }),
]);
btn.disabled = false;
if (!r1.ok || !r2.ok) {
statusEl.textContent = r1.error || r2.error || "Speichern fehlgeschlagen.";
statusEl.style.color = "#ff6b6b";
return;
}
statusEl.textContent = "✅ Gespeichert — wirkt sofort auf der Website.";
statusEl.style.color = "#baf7d6";
await teamKategorieLabelsAktualisieren();
teamListeZeichnen();
});
});
await teamKategorieLabelsAktualisieren(aktuelleTexte);
teamListeZeichnen();
}
// Nutzer-Wunsch 04.08.2026 (6. Runde): zwei getrennte Kisten/Formulare
// (Dogi + VanVan) statt einem gemeinsamen Feld — beide rufen denselben
// Login-Endpunkt auf, die Fehlermeldung erscheint jeweils nur in der