Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713ff12db2 | ||
|
|
2db7e03154 | ||
|
|
5dcb87d6ec |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.7.5"
|
version = "2.7.8"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.7.5"
|
version = "2.7.8"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -148,7 +148,11 @@ def create_base_app(
|
|||||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
#: Anwendungen.
|
#: Anwendungen.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.7.5",
|
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||||
|
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||||
|
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||||
|
#: falsche Version in die Fussnote schreiben.
|
||||||
|
app_version: str = "unbekannt",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
|
|||||||
@@ -1456,18 +1456,23 @@
|
|||||||
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||||
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||||
zieht statt die Seite zu scrollen. */
|
zieht statt die Seite zu scrollen. */
|
||||||
|
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
|
||||||
|
haengt darunter und darf die Hoehe nicht mitbestimmen. */
|
||||||
.viewer__unterschrift {
|
.viewer__unterschrift {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
container-type: inline-size;
|
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
|
||||||
border: 1px dashed transparent;
|
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
|
||||||
|
outline: 1px dashed transparent;
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer__unterschrift:hover,
|
.viewer__unterschrift:hover,
|
||||||
.viewer__unterschrift.is-fassend {
|
.viewer__unterschrift.is-fassend {
|
||||||
border-color: var(--accent);
|
outline-color: var(--accent);
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1483,11 +1488,23 @@
|
|||||||
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||||
anderes als die Datei. */
|
anderes als die Datei. */
|
||||||
.viewer__unterschrifttext {
|
.viewer__unterschrifttext {
|
||||||
font-size: 5.6cqw;
|
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
|
||||||
|
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
|
||||||
|
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||||
|
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||||
|
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
|
||||||
|
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
|
||||||
|
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
|
||||||
|
font-size: 8px;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: #111;
|
color: #111;
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||||
padding-top: 1px;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
|
|||||||
@@ -297,9 +297,9 @@ async function nachbearbeiten(
|
|||||||
const textbreite = Math.max(
|
const textbreite = Math.max(
|
||||||
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
||||||
);
|
);
|
||||||
let unten = eintrag.y - 2;
|
let unten = eintrag.y - UNTERSCHRIFT.textY;
|
||||||
for (const zeile of zeilen) {
|
for (const zeile of zeilen) {
|
||||||
unten -= groesse * 1.15;
|
unten -= groesse * UNTERSCHRIFT.zeile;
|
||||||
seite.drawText(zeile, {
|
seite.drawText(zeile, {
|
||||||
x: eintrag.x,
|
x: eintrag.x,
|
||||||
y: unten,
|
y: unten,
|
||||||
@@ -312,9 +312,9 @@ async function nachbearbeiten(
|
|||||||
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
|
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
|
||||||
// den Tabellenlinien schlicht unter.
|
// den Tabellenlinien schlicht unter.
|
||||||
seite.drawLine({
|
seite.drawLine({
|
||||||
start: { x: eintrag.x, y: eintrag.y - 1.5 },
|
start: { x: eintrag.x, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||||
end: { x: eintrag.x + textbreite, y: eintrag.y - 1.5 },
|
end: { x: eintrag.x + textbreite, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||||
thickness: 0.9,
|
thickness: UNTERSCHRIFT.strich,
|
||||||
color: PDFLib.rgb(0.15, 0.15, 0.15),
|
color: PDFLib.rgb(0.15, 0.15, 0.15),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -342,12 +342,77 @@ async function nachbearbeiten(
|
|||||||
return dokument.save();
|
return dokument.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Masse des Textblocks unter einer Unterschrift -- in PDF-Punkten.
|
||||||
|
*
|
||||||
|
* Die Vorschau rechnet dieselben Zahlen in Pixel um. Standen sie zweimal im
|
||||||
|
* Code, liefen sie auseinander: die Marke haengt mit ihrer **Unterkante** am
|
||||||
|
* Ankerpunkt, das PDF setzt dort die **Unterkante des Bildes** und schreibt
|
||||||
|
* den Text darunter. Beim Setzen sass der Block deshalb um die Texthoehe
|
||||||
|
* hoeher als in der gespeicherten Datei.
|
||||||
|
*/
|
||||||
|
const UNTERSCHRIFT = {
|
||||||
|
//: Mitte des Strichs unter der Bildkante.
|
||||||
|
strichY: 1.5,
|
||||||
|
//: Staerke des Strichs.
|
||||||
|
strich: 0.9,
|
||||||
|
//: Bildkante bis zur ersten Grundlinie, vor dem Zeilenabstand.
|
||||||
|
textY: 2,
|
||||||
|
//: Zeilenabstand als Vielfaches der Schriftgroesse.
|
||||||
|
zeile: 1.15,
|
||||||
|
//: Oberkante der Zeilenbox bis zur Grundlinie, in "em" -- nur ein
|
||||||
|
//: Rueckfall: gewoehnlich misst "grundlinie()" das am echten Text nach.
|
||||||
|
//: (Helvetica/Arial: Ascender 0,905 plus halber Durchschuss.)
|
||||||
|
grundlinie: 0.9215,
|
||||||
|
};
|
||||||
|
|
||||||
|
//: Gemessene Grundlinien, je dargestellter Schriftgroesse in Pixeln.
|
||||||
|
const grundlinien = new Map();
|
||||||
|
|
||||||
|
/** Abstand von der Oberkante der Zeilenbox zur Grundlinie, in "em".
|
||||||
|
*
|
||||||
|
* Gemessen statt geraten, und bei genau der Pixelgroesse, die auch auf dem
|
||||||
|
* Schirm steht. Zwei Gruende: welche Datei hinter "Helvetica" steckt,
|
||||||
|
* entscheidet das Geraet -- auf einem iPad eine andere als auf einem
|
||||||
|
* Windows-Rechner, mit anderen Oberlaengen. Und der Browser rundet die
|
||||||
|
* Schriftmasse auf ganze Pixel: bei acht Punkt Schrift verschiebt das die
|
||||||
|
* Zeile um einen halben Pixel gegenueber der Rechnung. pdf-lib rundet nicht.
|
||||||
|
*
|
||||||
|
* Eine leere Inline-Box sitzt auf der Grundlinie -- ihre Lage verraet also,
|
||||||
|
* wo der Text steht.
|
||||||
|
*/
|
||||||
|
function grundlinie(px) {
|
||||||
|
const schluessel = px.toFixed(1);
|
||||||
|
const gemerkt = grundlinien.get(schluessel);
|
||||||
|
if (gemerkt !== undefined) return gemerkt;
|
||||||
|
let anteil = UNTERSCHRIFT.grundlinie;
|
||||||
|
try {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.className = "viewer__unterschrifttext";
|
||||||
|
probe.style.cssText =
|
||||||
|
"position:absolute;left:-9999px;top:0;visibility:hidden;border:0;padding:0;";
|
||||||
|
probe.style.fontSize = `${px}px`;
|
||||||
|
probe.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||||
|
const lot = document.createElement("span");
|
||||||
|
lot.style.cssText = "display:inline-block;width:0;height:0;";
|
||||||
|
probe.append(document.createTextNode("Hg"), lot);
|
||||||
|
document.body.append(probe);
|
||||||
|
const oben = probe.getBoundingClientRect().top;
|
||||||
|
const auf = lot.getBoundingClientRect().top;
|
||||||
|
probe.remove();
|
||||||
|
if (auf > oben) anteil = (auf - oben) / px;
|
||||||
|
} catch (fehler) {
|
||||||
|
console.warn("Grundlinie nicht messbar -- Rueckfall auf Helvetica", fehler);
|
||||||
|
}
|
||||||
|
grundlinien.set(schluessel, anteil);
|
||||||
|
return anteil;
|
||||||
|
}
|
||||||
|
|
||||||
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
|
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
|
||||||
*
|
*
|
||||||
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
||||||
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
||||||
* Rechnung benutzt die Vorschau (als ``cqw``), damit auf dem Schirm steht,
|
* Rechnung benutzt die Vorschau -- in Pixeln umgerechnet --, damit auf dem
|
||||||
* was spaeter im PDF steht.
|
* Schirm steht, was spaeter im PDF steht.
|
||||||
*/
|
*/
|
||||||
function schriftgroesse(breite) {
|
function schriftgroesse(breite) {
|
||||||
return Math.max(5, Math.min(10, breite / 18));
|
return Math.max(5, Math.min(10, breite / 18));
|
||||||
@@ -637,6 +702,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
const MAX_BREITE = 150;
|
const MAX_BREITE = 150;
|
||||||
const MAX_HOEHE = 50;
|
const MAX_HOEHE = 50;
|
||||||
const unterschriften = [];
|
const unterschriften = [];
|
||||||
|
//: Je gesetzte Unterschrift eine Funktion, die ihre Marke neu vermisst.
|
||||||
|
const neuzeichnen = new Set();
|
||||||
let wartendeUnterschrift = null;
|
let wartendeUnterschrift = null;
|
||||||
let zeugenzeile = () => "";
|
let zeugenzeile = () => "";
|
||||||
|
|
||||||
@@ -684,11 +751,20 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
const bild = document.createElement("img");
|
const bild = document.createElement("img");
|
||||||
bild.src = eintrag.vorschau;
|
bild.src = eintrag.vorschau;
|
||||||
bild.alt = "";
|
bild.alt = "";
|
||||||
|
// Genau der Aufbau der spaeteren Datei: eine Zeile mit Namen und
|
||||||
|
// Zeitpunkt, darunter -- als eigene Zeile -- der Zeugenvermerk. In der
|
||||||
|
// Vorschau standen beide hintereinander, getrennt durch einen
|
||||||
|
// Schraegstrich; beim Positionieren sah der Block dadurch breiter und
|
||||||
|
// flacher aus als das Ergebnis.
|
||||||
const text = document.createElement("div");
|
const text = document.createElement("div");
|
||||||
text.className = "viewer__unterschrifttext";
|
text.className = "viewer__unterschrifttext";
|
||||||
text.textContent = `${eintrag.name} | ${eintrag.zeit}`;
|
const namenszeile = document.createElement("div");
|
||||||
|
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
|
||||||
|
text.append(namenszeile);
|
||||||
if (zeuge && eintrag === unterschriften[0]) {
|
if (zeuge && eintrag === unterschriften[0]) {
|
||||||
text.textContent += ` / In TESM abgelegt durch ${zeuge}`;
|
const zeugenzeile = document.createElement("div");
|
||||||
|
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
|
||||||
|
text.append(zeugenzeile);
|
||||||
}
|
}
|
||||||
const griff = document.createElement("button");
|
const griff = document.createElement("button");
|
||||||
griff.type = "button";
|
griff.type = "button";
|
||||||
@@ -705,16 +781,52 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
seitenEl.append(marke);
|
seitenEl.append(marke);
|
||||||
|
|
||||||
const zeichnen = () => {
|
const zeichnen = () => {
|
||||||
|
// Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
// Ankerpunkt, so wie im PDF. Der Text haengt darunter (siehe
|
||||||
|
// Gestaltung: absolut, "top: 100%") und zaehlt nicht zur Hoehe --
|
||||||
|
// sonst saesse der Block in der Vorschau um die Texthoehe zu hoch.
|
||||||
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
||||||
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
||||||
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
||||||
|
// Alles Weitere wird gerechnet, nicht vom Stylesheet abgeleitet:
|
||||||
|
// Punkte mal Pixel-je-Punkt der gerade dargestellten Seite. Ueber
|
||||||
|
// Container-Einheiten ("cqw") ging das auch -- aber nur dort, wo der
|
||||||
|
// Browser sie kennt; wo nicht, erbte der Block die Schriftgroesse der
|
||||||
|
// Seite, und der Text war um die Haelfte zu breit.
|
||||||
|
const proPunkt = seitenEl.getBoundingClientRect().width / blick.width;
|
||||||
|
const groesse = schriftgroesse(eintrag.breite);
|
||||||
|
const inPixeln = groesse * proPunkt;
|
||||||
|
const oben = UNTERSCHRIFT.strichY - UNTERSCHRIFT.strich / 2;
|
||||||
|
text.style.fontSize = `${inPixeln}px`;
|
||||||
|
text.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||||
|
text.style.marginTop = `${oben * proPunkt}px`;
|
||||||
|
text.style.borderTopWidth = `${UNTERSCHRIFT.strich * proPunkt}px`;
|
||||||
|
// Rahmenbreiten rundet der Browser auf ganze Geraetepixel: aus 1,21
|
||||||
|
// wird 1. Mit dem gerechneten Wert sass die erste Zeile deshalb einen
|
||||||
|
// Fuenftel Punkt zu hoch -- also mit dem tatsaechlich benutzten rechnen.
|
||||||
|
const strich =
|
||||||
|
(parseFloat(getComputedStyle(text).borderTopWidth) ||
|
||||||
|
UNTERSCHRIFT.strich * proPunkt) / proPunkt;
|
||||||
|
// Oberkante des Strichs, die Strichstaerke, dann so viel Luft, dass die
|
||||||
|
// erste Grundlinie dort sitzt, wo pdf-lib sie setzt: textY plus einen
|
||||||
|
// Zeilenabstand unter der Bildkante.
|
||||||
|
const innen =
|
||||||
|
UNTERSCHRIFT.textY +
|
||||||
|
UNTERSCHRIFT.zeile * groesse -
|
||||||
|
oben -
|
||||||
|
strich -
|
||||||
|
grundlinie(inPixeln) * groesse;
|
||||||
|
text.style.paddingTop = `${Math.max(0, innen) * proPunkt}px`;
|
||||||
};
|
};
|
||||||
|
// Beim Zoomen aendert sich Pixel-je-Punkt -- dann neu rechnen.
|
||||||
|
neuzeichnen.add(zeichnen);
|
||||||
zeichnen();
|
zeichnen();
|
||||||
|
|
||||||
weg.addEventListener("click", (event) => {
|
weg.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const stelle = unterschriften.indexOf(eintrag);
|
const stelle = unterschriften.indexOf(eintrag);
|
||||||
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
||||||
|
neuzeichnen.delete(zeichnen);
|
||||||
marke.remove();
|
marke.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -742,8 +854,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
} else {
|
} else {
|
||||||
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
||||||
eintrag.y = Math.max(
|
eintrag.y = Math.max(
|
||||||
// Platz fuer die Textzeilen unter der Zeichnung.
|
// Platz fuer die beiden Textzeilen unter der Zeichnung.
|
||||||
schriftgroesse(eintrag.breite) * 2.5,
|
UNTERSCHRIFT.textY + 2 * UNTERSCHRIFT.zeile * schriftgroesse(eintrag.breite),
|
||||||
start.y - (zug.clientY - startY) / proPunkt
|
start.y - (zug.clientY - startY) / proPunkt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -766,6 +878,18 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach dem Zoomen stehen die Marken anteilig richtig, aber die Schrift
|
||||||
|
// haengt an der Pixelgroesse der Seite -- die ist jetzt eine andere.
|
||||||
|
eventBus.on("scalechanging", () => {
|
||||||
|
for (const zeichnen of neuzeichnen) {
|
||||||
|
try {
|
||||||
|
zeichnen();
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error("Unterschrift neu vermessen", fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hinein: () => {
|
hinein: () => {
|
||||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.7.5"
|
version = "2.7.7"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
Reference in new issue
Block a user