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:
+181
-49
@@ -5,12 +5,20 @@
|
||||
automatisch in der Website hinzugefügt werden ... auch mit Fotos und
|
||||
TikTok Link").
|
||||
|
||||
Ergänzt (überschreibt NICHT) die von Hand gepflegten Einträge in
|
||||
assets/js/data-modis.js/data-scouts.js/data-creator.js/data-manager.js.
|
||||
Die Website mischt beide Quellen zu einer gemeinsamen Liste (siehe
|
||||
window.dogiTeamLaden() in main.js) -- ein Fehler hier kann die
|
||||
bestehenden, fest einprogrammierten Profile (VanVan, Patrick, ...) nie
|
||||
verschwinden lassen.
|
||||
Update 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"):
|
||||
volle Feld-Parität mit den von Hand gepflegten Profilen (intro/bioHtml/
|
||||
extraCta) PLUS ein Import-Weg für bestehende Profile (importLegacyMember)
|
||||
-- danach lässt sich JEDES Mitglied (alt oder neu) hier bearbeiten,
|
||||
und die Website zeigt (siehe window.dogiTeamLaden() in main.js) für
|
||||
importierte slugs die DB-Version statt der statischen an.
|
||||
|
||||
WICHTIG: importLegacyMember übersetzt NICHT automatisch -- die
|
||||
bestehenden Profile haben von Hand geschriebene, geprüfte Übersetzungen
|
||||
in allen 5 Sprachen (siehe data-modis.js etc.), die dürfen beim Import
|
||||
nicht durch Maschinenübersetzung ersetzt werden. saveTeamMember (Neu
|
||||
anlegen/Bearbeiten in der Verwaltung) übersetzt weiterhin automatisch,
|
||||
weil dort nur Deutsch eingetippt wird.
|
||||
|
||||
Öffentlicher Lesezugriff (getTeamPublic) ohne Session, nur AKTIVE
|
||||
Mitglieder -- Schreibzugriff nur mit TEAM_MANAGE-Berechtigung.
|
||||
@@ -23,6 +31,7 @@ import { logAction } from "../lib/audit.js";
|
||||
import { uebersetzeAlleSprachen } from "../lib/translate.js";
|
||||
|
||||
const KATEGORIEN = ["modi", "scout", "creator", "manager"];
|
||||
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
|
||||
|
||||
function can(session, key) {
|
||||
if (!session) return false;
|
||||
@@ -43,6 +52,25 @@ function parseJsonFeld(text) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Prüft, ob ein vom Client geschicktes mehrsprachiges Feld wirklich ein
|
||||
Objekt mit (mindestens) einem "de"-Text ist -- genutzt vom Import, der
|
||||
KEINE eigene Übersetzung anstößt und deshalb ein vollständiges Objekt
|
||||
erwartet, kein einzelnes Deutsch-Textfeld. */
|
||||
function istGueltigesSprachFeld(x) {
|
||||
return x && typeof x === "object" && typeof x.de === "string" && x.de.trim().length > 0;
|
||||
}
|
||||
|
||||
/** Baut aus einem client-gesendeten mehrsprachigen Feld ein sauberes,
|
||||
auf die bekannten 5 Sprachen begrenztes Objekt (verhindert, dass
|
||||
beliebige zusätzliche Keys in die Datenbank gelangen). */
|
||||
function saubereSprachen(x, maxLen) {
|
||||
const out = {};
|
||||
for (const sprache of SPRACHEN) {
|
||||
out[sprache] = String(x?.[sprache] ?? x?.de ?? "").slice(0, maxLen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Slug aus dem Namen ableiten (Kleinbuchstaben, Umlaute vereinfacht,
|
||||
alles außer a-z0-9 wird zu "-"), falls die Verwaltung keinen eigenen
|
||||
Slug mitschickt -- selbes Muster wie an anderen Stellen der Seite. */
|
||||
@@ -55,7 +83,24 @@ function slugAusName(name) {
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
function slugFrei(slug, ausgenommeneId) {
|
||||
const row = db.prepare(`SELECT id FROM team_members WHERE slug = ?`).get(slug);
|
||||
return !row || row.id === ausgenommeneId;
|
||||
}
|
||||
|
||||
/** Macht einen Slug bei Kollision automatisch eindeutig ("-2", "-3", ...)
|
||||
statt hart abzulehnen -- Dogi/VanVan sollen nicht wegen eines
|
||||
Namensdopplers (z.B. zwei Personen mit Spitzname "Foxy") hängen
|
||||
bleiben. */
|
||||
function eindeutigerSlug(wunschSlug, ausgenommeneId) {
|
||||
if (slugFrei(wunschSlug, ausgenommeneId)) return wunschSlug;
|
||||
let zaehler = 2;
|
||||
while (!slugFrei(`${wunschSlug}-${zaehler}`, ausgenommeneId)) zaehler++;
|
||||
return `${wunschSlug}-${zaehler}`;
|
||||
}
|
||||
|
||||
function oeffentlichesFeld(row) {
|
||||
const extraCtaLabel = parseJsonFeld(row.extra_cta_label_json);
|
||||
return {
|
||||
id: row.id,
|
||||
kategorie: row.kategorie,
|
||||
@@ -64,10 +109,13 @@ function oeffentlichesFeld(row) {
|
||||
rolle: parseJsonFeld(row.rolle_json),
|
||||
foto: row.foto_url || "",
|
||||
tiktok: row.tiktok_url || "",
|
||||
intro: parseJsonFeld(row.intro_json),
|
||||
bio: parseJsonFeld(row.bio_json),
|
||||
bioHtml: parseJsonFeld(row.bio_html_json),
|
||||
zitat: parseJsonFeld(row.zitat_json),
|
||||
rang: row.rang,
|
||||
rangLabel: parseJsonFeld(row.rang_label_json),
|
||||
extraCta: row.extra_cta_href && extraCtaLabel ? { href: row.extra_cta_href, label: extraCtaLabel } : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,23 +178,9 @@ export async function saveTeamMember(req, res, session) {
|
||||
const rolleDe = String(body.rolle || "").trim().slice(0, 120);
|
||||
if (!rolleDe) return json(res, { ok: false, error: "Rolle ist Pflicht." }, 400);
|
||||
|
||||
let slug = String(body.slug || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 60) || slugAusName(name);
|
||||
if (!slug) return json(res, { ok: false, error: "Konnte keinen gültigen Slug ableiten -- bitte Namen prüfen." }, 400);
|
||||
|
||||
// Slug muss GLOBAL eindeutig sein (profil.html sucht kategorieübergreifend).
|
||||
// Kollidiert er mit einem ANDEREN Eintrag (nicht dem gerade bearbeiteten),
|
||||
// wird er automatisch mit "-2", "-3", ... eindeutig gemacht statt hart
|
||||
// abzulehnen -- Dogi/VanVan sollen nicht wegen eines Namensdopplers
|
||||
// (z.B. zwei Personen mit Spitzname "Foxy") hängen bleiben.
|
||||
const slugBelegt = (kandidat) => {
|
||||
const row = db.prepare(`SELECT id FROM team_members WHERE slug = ?`).get(kandidat);
|
||||
return row && row.id !== id;
|
||||
};
|
||||
if (slugBelegt(slug)) {
|
||||
let zaehler = 2;
|
||||
while (slugBelegt(`${slug}-${zaehler}`)) zaehler++;
|
||||
slug = `${slug}-${zaehler}`;
|
||||
}
|
||||
const wunschSlug = String(body.slug || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 60) || slugAusName(name);
|
||||
if (!wunschSlug) return json(res, { ok: false, error: "Konnte keinen gültigen Slug ableiten -- bitte Namen prüfen." }, 400);
|
||||
const slug = eindeutigerSlug(wunschSlug, id);
|
||||
|
||||
const tiktokRoh = String(body.tiktok || "").trim().slice(0, 200);
|
||||
let tiktokUrl = null;
|
||||
@@ -160,7 +194,9 @@ export async function saveTeamMember(req, res, session) {
|
||||
}
|
||||
}
|
||||
|
||||
const introDe = String(body.intro || "").trim().slice(0, 400);
|
||||
const bioDe = String(body.bio || "").trim().slice(0, 800);
|
||||
const bioHtmlDe = String(body.bioHtml || "").trim().slice(0, 6000);
|
||||
const zitatDe = String(body.zitat || "").trim().slice(0, 200);
|
||||
const fotoUrl = String(body.foto || "").trim().slice(0, 300) || null;
|
||||
|
||||
@@ -168,54 +204,150 @@ export async function saveTeamMember(req, res, session) {
|
||||
? Math.max(1, Math.min(99, Number(body.rang) || 0)) : null;
|
||||
const rangLabelDe = String(body.rangLabel || "").trim().slice(0, 40);
|
||||
|
||||
const extraCtaHref = String(body.extraCtaHref || "").trim().slice(0, 300) || null;
|
||||
const extraCtaLabelDe = String(body.extraCtaLabel || "").trim().slice(0, 60);
|
||||
if (extraCtaHref && !/^https?:\/\//i.test(extraCtaHref) && !extraCtaHref.startsWith("/") && !extraCtaHref.endsWith(".html")) {
|
||||
return json(res, { ok: false, error: "Zweiter Button: Link muss eine Seite (…html) oder http(s)-Adresse sein." }, 400);
|
||||
}
|
||||
|
||||
const reihenfolge = Number.isFinite(Number(body.reihenfolge)) ? Number(body.reihenfolge) : 0;
|
||||
const status = body.status === "pausiert" ? "pausiert" : "aktiv";
|
||||
|
||||
const [rolle, bio, zitat, rangLabel] = await Promise.all([
|
||||
const [rolle, intro, bio, bioHtml, zitat, rangLabel, extraCtaLabel] = await Promise.all([
|
||||
uebersetzeAlleSprachen(rolleDe),
|
||||
introDe ? uebersetzeAlleSprachen(introDe) : Promise.resolve(null),
|
||||
bioDe ? uebersetzeAlleSprachen(bioDe) : Promise.resolve(null),
|
||||
bioHtmlDe ? uebersetzeAlleSprachen(bioHtmlDe) : Promise.resolve(null),
|
||||
zitatDe ? uebersetzeAlleSprachen(zitatDe) : Promise.resolve(null),
|
||||
rangLabelDe ? uebersetzeAlleSprachen(rangLabelDe) : Promise.resolve(null),
|
||||
extraCtaLabelDe ? uebersetzeAlleSprachen(extraCtaLabelDe) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const jetzt = nowIso();
|
||||
const werte = {
|
||||
id: id || generateId(),
|
||||
kategorie, name, slug,
|
||||
rolle_json: JSON.stringify(rolle),
|
||||
foto_url: fotoUrl,
|
||||
tiktok_url: tiktokUrl,
|
||||
intro_json: intro ? JSON.stringify(intro) : null,
|
||||
bio_json: bio ? JSON.stringify(bio) : null,
|
||||
bio_html_json: bioHtml ? JSON.stringify(bioHtml) : null,
|
||||
zitat_json: zitat ? JSON.stringify(zitat) : null,
|
||||
rang, rang_label_json: rangLabel ? JSON.stringify(rangLabel) : null,
|
||||
extra_cta_href: extraCtaHref && extraCtaLabel ? extraCtaHref : null,
|
||||
extra_cta_label_json: extraCtaHref && extraCtaLabel ? JSON.stringify(extraCtaLabel) : null,
|
||||
reihenfolge, status,
|
||||
geaendert_am: jetzt, geaendert_von: session.actor,
|
||||
};
|
||||
|
||||
if (id) {
|
||||
const info = db
|
||||
.prepare(
|
||||
`UPDATE team_members SET
|
||||
kategorie = ?, name = ?, slug = ?, rolle_json = ?, foto_url = ?, tiktok_url = ?,
|
||||
bio_json = ?, zitat_json = ?, rang = ?, rang_label_json = ?, reihenfolge = ?,
|
||||
status = ?, geaendert_am = ?, geaendert_von = ?
|
||||
WHERE id = ?`
|
||||
kategorie=@kategorie, name=@name, slug=@slug, rolle_json=@rolle_json,
|
||||
foto_url=@foto_url, tiktok_url=@tiktok_url, intro_json=@intro_json,
|
||||
bio_json=@bio_json, bio_html_json=@bio_html_json, zitat_json=@zitat_json,
|
||||
rang=@rang, rang_label_json=@rang_label_json,
|
||||
extra_cta_href=@extra_cta_href, extra_cta_label_json=@extra_cta_label_json,
|
||||
reihenfolge=@reihenfolge, status=@status,
|
||||
geaendert_am=@geaendert_am, geaendert_von=@geaendert_von
|
||||
WHERE id=@id`
|
||||
)
|
||||
.run(
|
||||
kategorie, name, slug, JSON.stringify(rolle), fotoUrl, tiktokUrl,
|
||||
bio ? JSON.stringify(bio) : null, zitat ? JSON.stringify(zitat) : null,
|
||||
rang, rangLabel ? JSON.stringify(rangLabel) : null, reihenfolge,
|
||||
status, jetzt, session.actor, id
|
||||
);
|
||||
.run(werte);
|
||||
if (info.changes === 0) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
||||
logAction(session.actor, "team.updated", id, { name, kategorie });
|
||||
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(id);
|
||||
return json(res, { ok: true, mitglied: vollesFeld(row) });
|
||||
} else {
|
||||
db.prepare(
|
||||
`INSERT INTO team_members
|
||||
(id, kategorie, name, slug, rolle_json, foto_url, tiktok_url, intro_json,
|
||||
bio_json, bio_html_json, zitat_json, rang, rang_label_json,
|
||||
extra_cta_href, extra_cta_label_json, reihenfolge, status,
|
||||
erstellt_am, geaendert_am, erstellt_von, geaendert_von)
|
||||
VALUES
|
||||
(@id, @kategorie, @name, @slug, @rolle_json, @foto_url, @tiktok_url, @intro_json,
|
||||
@bio_json, @bio_html_json, @zitat_json, @rang, @rang_label_json,
|
||||
@extra_cta_href, @extra_cta_label_json, @reihenfolge, @status,
|
||||
@geaendert_am, @geaendert_am, @geaendert_von, @geaendert_von)`
|
||||
).run(werte);
|
||||
logAction(session.actor, "team.created", werte.id, { name, kategorie });
|
||||
}
|
||||
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(werte.id);
|
||||
return json(res, { ok: true, mitglied: vollesFeld(row) });
|
||||
}
|
||||
|
||||
/** Importiert ein BESTEHENDES, von Hand gepflegtes Profil (aus
|
||||
data-modis.js/data-scouts.js/data-creator.js/data-manager.js) 1:1 in
|
||||
die Datenbank -- danach ist es über die Verwaltung wie jedes andere
|
||||
Mitglied bearbeitbar, und Änderungen gelten für ALLE (siehe
|
||||
window.dogiTeamLaden()-Override-Logik in main.js).
|
||||
|
||||
WICHTIG: übersetzt NICHTS automatisch (anders als saveTeamMember) --
|
||||
erwartet für jedes Textfeld ein bereits vollständiges {de,"de-CH",en,
|
||||
fr,pt}-Objekt, wie es in den data-*.js-Dateien von Hand gepflegt
|
||||
wird. Ist ein Feld kein gültiges Sprachobjekt, wird es einfach
|
||||
weggelassen statt einen Fehler zu werfen -- ein unvollständiges
|
||||
Altprofil soll den Import nicht komplett blockieren.
|
||||
|
||||
Idempotent: ist der slug schon vorhanden, passiert nichts (kein
|
||||
Duplikat, kein Fehler) -- der Button in der Verwaltung kann beliebig
|
||||
oft geklickt werden, ohne Doppel-Einträge zu erzeugen. */
|
||||
export async function importLegacyMember(req, res, session) {
|
||||
if (!can(session, "TEAM_MANAGE")) return forbidden(res);
|
||||
const body = req.body || {};
|
||||
const kategorie = String(body.kategorie || "").trim();
|
||||
if (!KATEGORIEN.includes(kategorie)) return json(res, { ok: false, error: "Ungültige Kategorie." }, 400);
|
||||
const name = String(body.name || "").trim().slice(0, 60);
|
||||
const slug = String(body.slug || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 60);
|
||||
if (!name || !slug) return json(res, { ok: false, error: "Name und Slug sind Pflicht." }, 400);
|
||||
if (!istGueltigesSprachFeld(body.rolle)) return json(res, { ok: false, error: "Rolle fehlt oder unvollständig." }, 400);
|
||||
|
||||
if (!slugFrei(slug, null)) {
|
||||
return json(res, { ok: true, uebersprungen: true, grund: "Bereits importiert." });
|
||||
}
|
||||
|
||||
const neueId = generateId();
|
||||
const rolle = saubereSprachen(body.rolle, 120);
|
||||
const intro = istGueltigesSprachFeld(body.intro) ? saubereSprachen(body.intro, 2000) : null;
|
||||
const bio = istGueltigesSprachFeld(body.bio) ? saubereSprachen(body.bio, 800) : null;
|
||||
const bioHtml = istGueltigesSprachFeld(body.bioHtml) ? saubereSprachen(body.bioHtml, 8000) : null;
|
||||
const zitat = istGueltigesSprachFeld(body.zitat) ? saubereSprachen(body.zitat, 200) : null;
|
||||
const rangLabel = istGueltigesSprachFeld(body.rangLabel) ? saubereSprachen(body.rangLabel, 40) : null;
|
||||
const extraCtaLabel = body.extraCta?.href && istGueltigesSprachFeld(body.extraCta?.label) ? saubereSprachen(body.extraCta.label, 60) : null;
|
||||
|
||||
const jetzt = nowIso();
|
||||
const werte = {
|
||||
id: generateId(),
|
||||
kategorie, name, slug,
|
||||
rolle_json: JSON.stringify(rolle),
|
||||
foto_url: String(body.foto || "").trim().slice(0, 300) || null,
|
||||
tiktok_url: body.tiktok?.href || (typeof body.tiktok === "string" ? body.tiktok : null),
|
||||
intro_json: intro ? JSON.stringify(intro) : null,
|
||||
bio_json: bio ? JSON.stringify(bio) : null,
|
||||
bio_html_json: bioHtml ? JSON.stringify(bioHtml) : null,
|
||||
zitat_json: zitat ? JSON.stringify(zitat) : null,
|
||||
rang: Number.isFinite(Number(body.rang)) ? Number(body.rang) : null,
|
||||
rang_label_json: rangLabel ? JSON.stringify(rangLabel) : null,
|
||||
extra_cta_href: extraCtaLabel ? body.extraCta.href : null,
|
||||
extra_cta_label_json: extraCtaLabel ? JSON.stringify(extraCtaLabel) : null,
|
||||
reihenfolge: 0,
|
||||
status: "aktiv",
|
||||
geaendert_am: jetzt, geaendert_von: session.actor,
|
||||
};
|
||||
db.prepare(
|
||||
`INSERT INTO team_members
|
||||
(id, kategorie, name, slug, rolle_json, foto_url, tiktok_url, bio_json, zitat_json,
|
||||
rang, rang_label_json, reihenfolge, status, erstellt_am, geaendert_am, erstellt_von, geaendert_von)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
neueId, kategorie, name, slug, JSON.stringify(rolle), fotoUrl, tiktokUrl,
|
||||
bio ? JSON.stringify(bio) : null, zitat ? JSON.stringify(zitat) : null,
|
||||
rang, rangLabel ? JSON.stringify(rangLabel) : null, reihenfolge,
|
||||
status, jetzt, jetzt, session.actor, session.actor
|
||||
);
|
||||
logAction(session.actor, "team.created", neueId, { name, kategorie });
|
||||
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(neueId);
|
||||
return json(res, { ok: true, mitglied: vollesFeld(row) });
|
||||
(id, kategorie, name, slug, rolle_json, foto_url, tiktok_url, intro_json,
|
||||
bio_json, bio_html_json, zitat_json, rang, rang_label_json,
|
||||
extra_cta_href, extra_cta_label_json, reihenfolge, status,
|
||||
erstellt_am, geaendert_am, erstellt_von, geaendert_von)
|
||||
VALUES
|
||||
(@id, @kategorie, @name, @slug, @rolle_json, @foto_url, @tiktok_url, @intro_json,
|
||||
@bio_json, @bio_html_json, @zitat_json, @rang, @rang_label_json,
|
||||
@extra_cta_href, @extra_cta_label_json, @reihenfolge, @status,
|
||||
@geaendert_am, @geaendert_am, @geaendert_von, @geaendert_von)`
|
||||
).run(werte);
|
||||
logAction(session.actor, "team.imported", werte.id, { name, kategorie, slug });
|
||||
const row = db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(werte.id);
|
||||
return json(res, { ok: true, uebersprungen: false, mitglied: vollesFeld(row) });
|
||||
}
|
||||
|
||||
export async function deleteTeamMember(req, res, session) {
|
||||
|
||||
Reference in New Issue
Block a user