Eigene Bilder importieren und auf Slides legen
Bilder aus dem Finder kommen jetzt ueber die Dateiauswahl oder per Ziehen direkt auf eine Slide herein. Beim Ablegen wird das Bild dort zum zweiten Bild und laesst sich im Bearbeiten-Modus frei schieben und in der Groesse aendern; ueber den Knopf importiert man nur und ordnet danach unter Inhalt zu. Das zweite Bild laesst sich dort auch wieder abwaehlen. Die Datei geht byteweise unveraendert an den neuen Endpunkt /upload und liegt danach unter assets/eigene/. Nichts wird neu kodiert, skaliert oder umbenannt -- nur Zeichen, die in einem Dateinamen nichts zu suchen haben, weichen einem Bindestrich. Der Umweg ueber den Server ist Absicht: ein Foto als Base64 im Projektstand waere groesser als der gesamte uebrige Stand und machte jeden Diff unlesbar. So steht dort nur der Pfad, und das Bild liegt mit im Repo wie jedes andere Asset auch. Die Auswahllisten holt der Generator beim Start ueber /bilder vom Server statt sie im Code zu pflegen. Importierte stehen deshalb sofort darin, mit einem Stern gekennzeichnet. Beim Zuweisen folgt das Rahmenformat dem Seitenverhaeltnis der Aufnahme. Geprueft ueber das DevTools-Protokoll: ablegen, Bytes gegen die Quelle vergleichen, Format, Verschieben im Bearbeiten-Modus, Export und Neustart. Die abgelegte Datei war byteweise identisch mit der Quelle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1cce5ec492
commit
8e1ac0d2a9
3 files changed
+281
-7
No files matched your search
@@ -45,6 +45,30 @@ Wechsel automatisch dem Seitenverhältnis der gewählten Aufnahme.
|
||||
> Der eingetippte Tag beschriftet deshalb vorerst nur den Slide-Knopf im
|
||||
> Bedienfeld.
|
||||
|
||||
### Eigene Bilder importieren
|
||||
|
||||
Unter **Eigene Bilder** wählst du Dateien aus dem Finder, oder du ziehst sie
|
||||
direkt auf eine Slide. Beim Ablegen wird das Bild dort zum **zweiten Bild** und
|
||||
lässt sich im Bearbeiten-Modus frei schieben und in der Größe ändern; über den
|
||||
Knopf importierst du nur und ordnest danach unter *Inhalt* zu.
|
||||
|
||||
Die Datei geht **byteweise unverändert** an den Server und liegt danach unter
|
||||
`assets/eigene/`. Nichts wird neu kodiert, skaliert oder umbenannt — nur
|
||||
Zeichen, die in einem Dateinamen nichts zu suchen haben, weichen einem
|
||||
Bindestrich.
|
||||
|
||||
```
|
||||
assets/eigene/ importierte Bilder, gehören mit in den Commit
|
||||
```
|
||||
|
||||
> Der Umweg über den Server ist Absicht. Ein Foto als Base64 im Projektstand
|
||||
> wäre größer als der gesamte übrige Stand und machte jeden Diff unlesbar. So
|
||||
> steht dort nur der Pfad.
|
||||
|
||||
Die Auswahllisten unter *Inhalt* holt der Generator beim Start vom Server
|
||||
(`/bilder`), importierte Bilder stehen deshalb sofort darin — mit `★`
|
||||
gekennzeichnet.
|
||||
|
||||
**Mitgelieferte Slides bleiben Vorlagen.** Der Stand speichert für sie nur
|
||||
einen Verweis und die Felder, die du überschrieben hast — eine Änderung an der
|
||||
Vorlage im Code kommt dadurch weiterhin bei allen an, die sie nicht angefasst
|
||||
@@ -107,10 +131,11 @@ assets/
|
||||
├── partner/ DATEV, KWP, Google Calendar, Google Drive, MS Outlook, MS Teams
|
||||
├── mockups/ Geräte-Mockups (Displays ausgeknockt, Screenshot liegt dahinter)
|
||||
├── screens/ Software-Screenshots
|
||||
├── eigene/ selbst importierte Bilder
|
||||
└── fonts/ Manrope 400–800, lokal
|
||||
vendor/ html-to-image, JSZip
|
||||
state/ Projektstände, gehören ins Repo
|
||||
serve.py Statischer Server + /save- und /state-Endpunkt
|
||||
serve.py Statischer Server + /save, /state, /upload, /bilder
|
||||
rules.md Gestaltungsregeln, Farbwerte, Export-Wissen
|
||||
```
|
||||
|
||||
|
||||
+184
-6
@@ -49,6 +49,13 @@
|
||||
}
|
||||
#slide-ops button { flex: none; padding: 8px 10px; }
|
||||
|
||||
/* Zielmarke beim Ablegen einer Datei. */
|
||||
.holder.ablegen::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 5; pointer-events: none;
|
||||
outline: 12px dashed var(--accent-yellow); outline-offset: -6px;
|
||||
background: rgba(241,183,41,.10);
|
||||
}
|
||||
|
||||
.saved {
|
||||
font-size: 11px; color: #6FCF97; opacity: 0; transition: opacity .2s;
|
||||
margin-left: 6px;
|
||||
@@ -803,6 +810,15 @@
|
||||
<div id="inhalt"></div>
|
||||
</details>
|
||||
|
||||
<details class="group" data-grp="eigene-bilder">
|
||||
<summary>Eigene Bilder</summary>
|
||||
<input type="file" id="bild-datei" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple hidden>
|
||||
<button class="btn secondary" id="bild-waehlen" style="width:100%">Bilder importieren …</button>
|
||||
<div class="hint" id="bild-hinweis">Oder eine Datei auf eine Slide ziehen — sie wird
|
||||
dort das zweite Bild und lässt sich im Bearbeiten-Modus frei schieben.
|
||||
Die Datei landet unverändert unter <b>assets/eigene/</b> und gehört mit in den Commit.</div>
|
||||
</details>
|
||||
|
||||
<details class="group" data-grp="variante">
|
||||
<summary>Variante</summary>
|
||||
<div class="opts" id="variants">
|
||||
@@ -1090,7 +1106,10 @@
|
||||
/* Alle Screens im Ordner. Eine selbst angelegte Slide kann jeden davon
|
||||
wählen, deshalb werden sie vollständig geladen und nicht nur die, die
|
||||
eine Vorlage zufällig gerade benutzt. */
|
||||
const SCREENS = [
|
||||
/* Ausgangsliste. Der Server liefert beim Start die tatsaechlich vorhandenen
|
||||
Dateien nach, damit importierte Bilder sofort auftauchen und die Liste
|
||||
nicht im Code gepflegt werden muss. */
|
||||
let BILDER = [
|
||||
{ pfad: "./assets/screens/pipelines.png", name: "Pipelines · Board" },
|
||||
{ pfad: "./assets/screens/pipelines-liste.png", name: "Pipelines · Liste" },
|
||||
{ pfad: "./assets/screens/pipelines-detail.png", name: "Pipelines · Detail" },
|
||||
@@ -1117,6 +1136,9 @@
|
||||
const basis = eintrag.ref ? VORLAGEN.find(v => v.key === eintrag.ref) : null;
|
||||
const s = kopie(basis || NEUE_SLIDE);
|
||||
Object.assign(s, eintrag.werte || {});
|
||||
// null heisst "weg", nicht "leeres Objekt": nur so laesst sich ein zweites
|
||||
// Bild, das die Vorlage mitbringt, wieder abwaehlen.
|
||||
if (s.media2 === null) delete s.media2;
|
||||
// Der Schlüssel landet im Dateinamen und muss eindeutig bleiben.
|
||||
s.key = (basis ? basis.key : (eintrag.key || "eigen")) + (eintrag.ref ? "" : "-" + (i + 1));
|
||||
return s;
|
||||
@@ -1893,7 +1915,7 @@
|
||||
/* Alle Screens, nicht nur die gerade benutzten: eine selbst angelegte
|
||||
Slide kann jeden davon waehlen, und ein Bild, das erst beim Auswaehlen
|
||||
geladen wuerde, fehlt im Export. */
|
||||
SCREENS.forEach(sc => paths.push(sc.pfad));
|
||||
BILDER.forEach(sc => paths.push(sc.pfad));
|
||||
// Optional: the layout falls back to a hint if the file is not there yet.
|
||||
const optional = new Set(["./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png"]);
|
||||
paths.push("./assets/mockups/laptop.png", "./assets/mockups/macbook-fels.png");
|
||||
@@ -2443,6 +2465,148 @@
|
||||
Geschrieben wird in slideListe[i].werte, nicht in SLIDES: SLIDES entsteht
|
||||
aus der Vorlage plus diesen Ueberschreibungen. Wer nichts anfasst, bekommt
|
||||
Aenderungen an der Vorlage im Code weiterhin mit. */
|
||||
/* ─── Eigene Bilder ───────────────────────────────────────────────────────
|
||||
Die Datei geht unveraendert an den Server und liegt danach unter
|
||||
assets/eigene/. Der Umweg ist Absicht: als Base64 im Projektstand waere
|
||||
ein einziges Foto groesser als der gesamte uebrige Stand und jeder Diff
|
||||
unbrauchbar. So steht dort nur der Pfad. */
|
||||
function bildOptionen() {
|
||||
return BILDER.map(b =>
|
||||
'<option value="' + b.pfad + '">' +
|
||||
(b.ordner === "eigene" ? "★ " : "") + b.name + '</option>').join("");
|
||||
}
|
||||
|
||||
async function bilderNachladen() {
|
||||
try {
|
||||
const res = await fetch("/bilder", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const liste = await res.json();
|
||||
if (Array.isArray(liste) && liste.length) BILDER = liste;
|
||||
} catch (_) { /* ohne Server bleibt die eingebaute Liste */ }
|
||||
}
|
||||
|
||||
// Eine Datei einlesen, damit sie sofort im Canvas und im Export vorliegt.
|
||||
async function bildBereitstellen(pfad) {
|
||||
if (assets[pfad]) return true;
|
||||
const res = await fetch(pfad, { cache: "no-store" }).catch(() => null);
|
||||
if (!res || !res.ok) return false;
|
||||
const blob = await res.blob();
|
||||
assets[pfad] = await new Promise((ok, fail) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => ok(fr.result);
|
||||
fr.onerror = fail;
|
||||
fr.readAsDataURL(blob);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bildImportieren(datei) {
|
||||
const res = await fetch("/upload", {
|
||||
method: "POST",
|
||||
// Kodiert, damit Umlaute und Leerzeichen den Header nicht sprengen.
|
||||
headers: { "X-Filename": encodeURIComponent(datei.name) },
|
||||
body: datei,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const w = await res.json().catch(() => ({}));
|
||||
throw new Error(w.error || ("HTTP " + res.status));
|
||||
}
|
||||
const { pfad } = await res.json();
|
||||
await bilderNachladen();
|
||||
await bildBereitstellen(pfad);
|
||||
return pfad;
|
||||
}
|
||||
|
||||
// Masse aus dem Bild selbst: ein Hochformat soll nicht in einem
|
||||
// Querformat-Rahmen stehen.
|
||||
function seitenverhaeltnis(pfad) {
|
||||
return new Promise(ok => {
|
||||
const im = new Image();
|
||||
im.onload = () => ok(im.naturalWidth / im.naturalHeight);
|
||||
im.onerror = () => ok(1.6);
|
||||
im.src = assets[pfad] || pfad;
|
||||
});
|
||||
}
|
||||
|
||||
async function alsZweitesBild(i, pfad) {
|
||||
const ar = await seitenverhaeltnis(pfad);
|
||||
const w = werteVon(i);
|
||||
const alt = w.media2 || SLIDES[i].media2 || {};
|
||||
w.media2 = {
|
||||
img: pfad, w: alt.w || 400, ar: +ar.toFixed(4),
|
||||
left: alt.left != null ? alt.left : 500,
|
||||
bottom: alt.bottom != null ? alt.bottom : 60,
|
||||
z: alt.z || 2, rot: alt.rot || 0,
|
||||
};
|
||||
slidesNeuAufbauen();
|
||||
deckNeuBauen();
|
||||
show(i);
|
||||
saveState();
|
||||
}
|
||||
|
||||
async function dateienAufnehmen(dateien, zielSlide) {
|
||||
const bilder = [...dateien].filter(f => /^image\//.test(f.type));
|
||||
if (!bilder.length) { setStatus("Keine Bilddatei dabei", "err"); return; }
|
||||
setStatus("Importiere " + bilder.length + " Bild(er) …");
|
||||
const pfade = [];
|
||||
try {
|
||||
for (const f of bilder) pfade.push(await bildImportieren(f));
|
||||
} catch (err) {
|
||||
setStatus("Import fehlgeschlagen: " + err.message, "err");
|
||||
return;
|
||||
}
|
||||
// Beim Ablegen auf einer Slide wandert das erste Bild gleich dorthin. Ueber
|
||||
// den Knopf importiert man nur; zugeordnet wird dann in den Feldern.
|
||||
if (typeof zielSlide === "number") {
|
||||
await alsZweitesBild(zielSlide, pfade[0]);
|
||||
setStatus(pfade.length + " Bild(er) importiert, erstes auf Slide " +
|
||||
(zielSlide + 1) + " gelegt", "ok");
|
||||
} else {
|
||||
syncInhalt();
|
||||
setStatus(pfade.length + " Bild(er) importiert · unter Inhalt zuweisen", "ok");
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("bild-waehlen").addEventListener("click",
|
||||
() => document.getElementById("bild-datei").click());
|
||||
document.getElementById("bild-datei").addEventListener("change", ev => {
|
||||
const f = ev.target.files;
|
||||
if (f && f.length) dateienAufnehmen(f);
|
||||
ev.target.value = ""; // dieselbe Datei soll erneut waehlbar bleiben
|
||||
});
|
||||
|
||||
/* Ablegen auf einer Slide. Der Browser oeffnet eine fallengelassene Datei
|
||||
sonst einfach im Tab und der Projektstand waere verloren. */
|
||||
(function ablegen() {
|
||||
const sc = document.getElementById("scroller");
|
||||
const zielAus = e => {
|
||||
const h = e.target.closest && e.target.closest(".holder");
|
||||
return h ? Number(h.dataset.n) : null;
|
||||
};
|
||||
["dragenter", "dragover"].forEach(art => sc.addEventListener(art, e => {
|
||||
if (!e.dataTransfer || [...e.dataTransfer.types].indexOf("Files") < 0) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
const n = zielAus(e);
|
||||
deck.querySelectorAll(".holder").forEach((h, i) => h.classList.toggle("ablegen", i === n));
|
||||
}));
|
||||
sc.addEventListener("dragleave", e => {
|
||||
if (e.relatedTarget && sc.contains(e.relatedTarget)) return;
|
||||
deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
|
||||
});
|
||||
sc.addEventListener("drop", e => {
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
e.preventDefault();
|
||||
const n = zielAus(e);
|
||||
deck.querySelectorAll(".holder").forEach(h => h.classList.remove("ablegen"));
|
||||
dateienAufnehmen(e.dataTransfer.files, n == null ? undefined : n);
|
||||
});
|
||||
// Ausserhalb der Flaeche nichts oeffnen, sondern still ablehnen.
|
||||
["dragover", "drop"].forEach(art => window.addEventListener(art, e => {
|
||||
if (!sc.contains(e.target)) e.preventDefault();
|
||||
}));
|
||||
})();
|
||||
|
||||
function werteVon(i) {
|
||||
const e = slideListe[i];
|
||||
return (e.werte = e.werte || {});
|
||||
@@ -2466,9 +2630,10 @@
|
||||
).join("") +
|
||||
(s.type === "feature"
|
||||
? '<label class="inh-label" for="in-img">Screenshot</label>' +
|
||||
'<select id="in-img" data-feld="img">' +
|
||||
SCREENS.map(sc => '<option value="' + sc.pfad + '">' + sc.name + '</option>').join("") +
|
||||
'</select>'
|
||||
'<select id="in-img" data-feld="img">' + bildOptionen() + '</select>' +
|
||||
'<label class="inh-label" for="in-media2">Zweites Bild</label>' +
|
||||
'<select id="in-media2" data-feld="media2">' +
|
||||
'<option value="">— keins —</option>' + bildOptionen() + '</select>'
|
||||
: "") +
|
||||
'<div class="hint">*Wort* setzt den Akzent, ein Zeilenumbruch bricht die Zeile um.' +
|
||||
(s.type === "title" ? ' Beim Titel wirkt das nur im Layout „Klassisch“.' : '') + '</div>';
|
||||
@@ -2476,7 +2641,9 @@
|
||||
box.querySelectorAll("[data-feld]").forEach(el => {
|
||||
if (document.activeElement === el) return;
|
||||
const k = el.dataset.feld;
|
||||
el.value = k === "img" ? (s.img || "") : htmlZuText(s[k]);
|
||||
if (k === "img") el.value = s.img || "";
|
||||
else if (k === "media2") el.value = (s.media2 && s.media2.img) || "";
|
||||
else el.value = htmlZuText(s[k]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2485,6 +2652,16 @@
|
||||
if (!el) return;
|
||||
const k = el.dataset.feld;
|
||||
const w = werteVon(current);
|
||||
if (k === "media2") {
|
||||
if (!el.value) {
|
||||
// null statt weglassen: sonst kaeme das Bild der Vorlage zurueck.
|
||||
w.media2 = null;
|
||||
slidesNeuAufbauen(); deckNeuBauen(); show(current); saveState();
|
||||
} else {
|
||||
alsZweitesBild(current, el.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (k === "img") {
|
||||
w.img = el.value;
|
||||
// Das Rahmenformat folgt dem Bild, sonst steht ein Hochformat in einem
|
||||
@@ -3157,6 +3334,7 @@
|
||||
(async function boot() {
|
||||
try {
|
||||
setStatus("Lade Screens ...");
|
||||
await bilderNachladen();
|
||||
await preloadAssets();
|
||||
const restored = await loadState();
|
||||
buildDeck();
|
||||
|
||||
@@ -11,6 +11,12 @@ Der Projektstand liegt nicht mehr nur im localStorage des Browsers, sondern
|
||||
unter state/<projekt>.json im Repo. Nur so ueberlebt er einen Rechnerwechsel:
|
||||
gepusht und gezogen hat die andere Maschine denselben Stand. Der localStorage
|
||||
bleibt als Zwischenspeicher, die Datei ist die Wahrheit.
|
||||
|
||||
Eigene Bilder kommen ueber /upload herein und landen unverfaelscht als Datei
|
||||
unter assets/eigene/. Der Weg ueber den Projektstand waere der falsche: ein
|
||||
2-MB-PNG als Base64 blaeht state/post1.json auf und macht jeden Diff
|
||||
unlesbar. So steht im Stand nur der Pfad, und das Bild liegt mit im Repo --
|
||||
dasselbe Prinzip wie bei allen anderen Assets.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
@@ -25,9 +31,15 @@ PORT = 4321
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
TARGET = Path.home() / "Downloads"
|
||||
STATE_DIR = ROOT / "state"
|
||||
EIGENE_DIR = ROOT / "assets" / "eigene"
|
||||
SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,120}$")
|
||||
SAFE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
||||
MAX_STATE = 4 * 1024 * 1024
|
||||
MAX_BILD = 40 * 1024 * 1024
|
||||
# Nur verlustfreie oder unveraenderte Rasterbilder. Die Bytes werden nicht
|
||||
# angefasst, deshalb bleibt ein PNG genau das PNG, das der Finder geliefert hat.
|
||||
BILD_ENDUNGEN = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"}
|
||||
BILD_ORDNER = ("assets/screens", "assets/eigene")
|
||||
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
@@ -90,11 +102,70 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
tmp.replace(p)
|
||||
self._json(200, '{"ok":true}')
|
||||
|
||||
def do_GET(self):
|
||||
"""Vor der Dateiauslieferung nur die Bilderliste abfangen."""
|
||||
if urllib.parse.urlparse(self.path).path == "/bilder":
|
||||
self._liste_bilder()
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def _liste_bilder(self):
|
||||
"""Alle verwendbaren Bilder, damit die Auswahl im Generator nicht im
|
||||
Code gepflegt werden muss und importierte sofort auftauchen."""
|
||||
raus = []
|
||||
for ordner in BILD_ORDNER:
|
||||
verz = ROOT / ordner
|
||||
if not verz.is_dir():
|
||||
continue
|
||||
for f in sorted(verz.iterdir()):
|
||||
if f.is_file() and f.suffix.lower() in BILD_ENDUNGEN:
|
||||
raus.append({"pfad": "./" + ordner + "/" + f.name,
|
||||
"name": f.stem, "ordner": ordner.split("/")[-1]})
|
||||
self._json(200, json.dumps(raus, ensure_ascii=False))
|
||||
|
||||
def _upload(self):
|
||||
"""Ein eigenes Bild aufnehmen. Die Bytes gehen unveraendert auf die
|
||||
Platte: nicht neu kodiert, nicht skaliert, nicht umbenannt ausser um
|
||||
gefaehrliche Zeichen. Verlustfrei heisst hier woertlich unangetastet."""
|
||||
name = os.path.basename(urllib.parse.unquote(self.headers.get("X-Filename", "")))
|
||||
stamm, endung = os.path.splitext(name)
|
||||
endung = endung.lower()
|
||||
if endung not in BILD_ENDUNGEN:
|
||||
self._json(400, '{"error":"kein Bildformat"}')
|
||||
return
|
||||
# Aus dem Stamm wird ein unverfaenglicher Dateiname, der Rest faellt weg.
|
||||
stamm = re.sub(r"[^A-Za-z0-9._-]+", "-", stamm).strip("-.") or "bild"
|
||||
stamm = stamm[:80]
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length <= 0 or length > MAX_BILD:
|
||||
self._json(400, '{"error":"ungueltige Groesse"}')
|
||||
return
|
||||
daten = self.rfile.read(length)
|
||||
|
||||
EIGENE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ziel = (EIGENE_DIR / (stamm + endung)).resolve()
|
||||
if ziel.parent != EIGENE_DIR.resolve():
|
||||
self._json(400, '{"error":"ungueltiger Pfad"}')
|
||||
return
|
||||
# Gleicher Inhalt, gleicher Name: nicht doppelt ablegen.
|
||||
n = 1
|
||||
while ziel.exists() and ziel.read_bytes() != daten:
|
||||
ziel = EIGENE_DIR / (stamm + "-" + str(n) + endung)
|
||||
n += 1
|
||||
ziel.write_bytes(daten)
|
||||
pfad = "./assets/eigene/" + ziel.name
|
||||
print(f"importiert: {ziel.name} ({len(daten)/1024:.0f} KB)")
|
||||
self._json(200, json.dumps({"pfad": pfad, "name": ziel.stem}))
|
||||
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path == "/state":
|
||||
self._put_state()
|
||||
return
|
||||
if path == "/upload":
|
||||
self._upload()
|
||||
return
|
||||
if path != "/save":
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user