Compare commits
2 Commits
5e4ef2a327
...
f7f1a238e5
| Author | SHA1 | Date | |
|---|---|---|---|
| f7f1a238e5 | |||
| 4395ff6ac6 |
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md — Project state, review findings, open items, and working rules
|
||||
|
||||
**Last updated: 2026-08-11** (after building the app — phases 1, 2, 4–10 of `UMSETZUNGSPLAN.md`). This file is the entry point for every new agent
|
||||
**Last updated: 2026-08-11** (after building the app and the native iOS hull — phases 1, 2, 4–11 of `UMSETZUNGSPLAN.md`). This file is the entry point for every new agent
|
||||
session: what this repo is, what is finished, what is missing, and how to work here. Detail lives in
|
||||
the linked documents — this file points, it does not duplicate.
|
||||
|
||||
@@ -80,7 +80,7 @@ assets. Never mix these the other way around.
|
||||
| `homeassistant/` | HA panel (`panel_custom`): pyscript backend + vanilla-JS frontend | ✅ **finished, in use** — to be replaced by the app |
|
||||
| `testumgebung/` | Script that rebuilds a throwaway Home Assistant with the real backend | ✅ new, reproducible |
|
||||
| `design-system/` | React component library `@audi-dash/ui`, brand-free, feeds Claude Design | ✅ done as a kit (20 components, 1,690 lines) |
|
||||
| `companion-app/` | **DataMetric360** — successor app (PWA today, Capacitor iOS/Android later, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; PWA works; Capacitor hull pending |
|
||||
| `companion-app/` | **DataMetric360** — successor app (web, PWA, native iOS/Android via Capacitor, HA iframe); will **replace** the panel | ✅ **all 21 screens built and tested**; runs natively on iOS with real data |
|
||||
| `design/` | Export of the Claude Design draft for DataMetric360 | 🚧 interim and now behind the code — screens were derived from the old panel instead (owner's decision) |
|
||||
|
||||
Root files: `dashboard-muster*.html` = original static prototype (superseded, reference only),
|
||||
@@ -193,6 +193,25 @@ Three defects that only surfaced by running against a real Home Assistant, not b
|
||||
Also: two TypeScript parameter properties in the data layer broke Node's strip-only mode, which is
|
||||
what the smoke scripts run on — rewritten as plain fields.
|
||||
|
||||
Three more that only screenshots revealed — nothing failed, the pixels were simply wrong:
|
||||
|
||||
4. **The vehicle block on the home screen was invisible.** `overflow: hidden` sets a flex item's
|
||||
automatic minimum size to 0, so once the page was taller than the screen, flex-shrink squashed
|
||||
the block to zero height and took model name, badge and plate with it. Fixed with
|
||||
`.dm-inhalt > * { flex: none }`.
|
||||
5. **The model name appeared twice** — once as the badge image, once as text beside it.
|
||||
6. **Filenames overflowed the gallery thumbnails.**
|
||||
|
||||
And one in the backend, found by watching the log against a current Home Assistant: the odometer
|
||||
screening called `urlopen` directly, which HA aborts as a blocking call since 2026.8. Every trip
|
||||
stayed without a distance, visible only as a warning. Now runs through `task.executor`.
|
||||
|
||||
**CORS is a real constraint for this app.** `cors_allowed_origins` did not take effect on HA
|
||||
2026.8 (preflight 403 even same-origin). Two consequences, both handled: the web build is served
|
||||
from Home Assistant itself (`/local/dm360/`, same origin — the planned deployment anyway), and the
|
||||
native hull enables `CapacitorHttp`, which routes fetch through native HTTP where CORS does not
|
||||
apply. Verify this again at commissioning if the app ever moves to a separate hostname.
|
||||
|
||||
**A self-written QR encoder was discarded.** It disagreed with a reference implementation on 1239
|
||||
of 3249 modules — the code would have been unreadable. `homeassistant/www/dm360-qr.html` now uses a
|
||||
vendored MIT library served from Home Assistant itself, which satisfies the actual requirement
|
||||
|
||||
+7
-4
@@ -52,7 +52,7 @@ KI-Modell oder in einer frischen Session ohne Vorwissen abgearbeitet werden kann
|
||||
| 7 | Alle Screens umsetzen | 3, 6 | nein |
|
||||
| 8 | Audi-Assets einbauen | 7 | nein |
|
||||
| 9 | Tests (authentifiziert + Komponenten) | 6 | nein |
|
||||
| 10 | PWA + Capacitor/iOS-Sideload | 7 | Mac mit Xcode, iPhone |
|
||||
| 10 | PWA + Capacitor/iOS-Sideload | 7 | erledigt bis auf das Signieren aufs Geraet |
|
||||
| 11 | HA-Einbettung, Ablösung + Archivierung des Panels | 7–10 | HA-Produktivinstanz |
|
||||
| 12 | Externer Zugriff: Cloudflare Tunnel + Reverse Proxy | teils unabhängig | Domain/DNS, Besitzer |
|
||||
| 13 | FMM003: MQTT, Zertifikate, Mapping, Fahrterkennung | Hardware | **ja: FMM003 verbaut** |
|
||||
@@ -416,10 +416,13 @@ nicht ein Riesencommit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — PWA und Capacitor 🟡 STUFE 1 ERLEDIGT (2026-08-11)
|
||||
## Phase 10 — PWA und Capacitor ✅ ERLEDIGT (2026-08-11)
|
||||
|
||||
> PWA fertig. Capacitor-Konfiguration, Secure-Storage-Adapter und die QR-Seite
|
||||
> liegen bereit; die Hülle selbst braucht einen Mac mit Xcode und ein Gerät.
|
||||
> PWA fertig. Native Hülle gebaut und in Betrieb: Capacitor 8 für iOS und
|
||||
> Android, Build im Simulator (iPhone 17 Pro, iOS 26.4) erfolgreich, App zeigt
|
||||
> echte Daten. `CapacitorHttp` umgeht die CORS-Beschränkung der WebView.
|
||||
> Offen bleibt allein das Signieren aufs eigene Gerät — das braucht das
|
||||
> angeschlossene iPhone und die Apple-ID des Besitzers.
|
||||
|
||||
**Ziel:** Die App läuft auf dem iPhone. Zwei Stufen — erst PWA (sofort nutzbar), dann Capacitor
|
||||
(Keychain + QR-Scan).
|
||||
|
||||
@@ -2,3 +2,9 @@ node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.DS_Store
|
||||
|
||||
# Native Huellen: von `npx cap add` erzeugt, aus dem Webbuendel jederzeit
|
||||
# wiederherstellbar. Die Projektdateien selbst (capacitor.config.ts) sind
|
||||
# getrackt.
|
||||
ios/
|
||||
android/
|
||||
|
||||
@@ -1,33 +1,15 @@
|
||||
import type { CapacitorConfig } from "@capacitor/cli"
|
||||
|
||||
/**
|
||||
* Capacitor-Konfiguration für die native Hülle.
|
||||
*
|
||||
* **Noch nicht in Betrieb.** Die Pakete sind bewusst nicht installiert; diese
|
||||
* Datei liegt fertig da, damit die Inbetriebnahme nur noch aus den Befehlen in
|
||||
* `../UMSETZUNGSPLAN.md` Phase 10 Stufe 2 besteht:
|
||||
*
|
||||
* npm i -D @capacitor/cli
|
||||
* npm i @capacitor/core @capacitor/ios @capacitor/android
|
||||
* npx cap add ios && npx cap add android
|
||||
* npm run build && npx cap sync
|
||||
* npx cap open ios # in Xcode signieren und aufs Gerät bauen
|
||||
*
|
||||
* Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
|
||||
* ausschließlich für diese private Installation freigegeben.
|
||||
* ausschließlich für diese eine private Installation freigegeben.
|
||||
*
|
||||
* Der Typ ist absichtlich nicht aus @capacitor/cli importiert — das Paket
|
||||
* fehlt ja noch, und ein toter Import würde die Typprüfung brechen.
|
||||
* Ablauf: `npm run build && npx cap sync`, dann `npx cap open ios` und in
|
||||
* Xcode signieren. Siehe `../UMSETZUNGSPLAN.md` Phase 10.
|
||||
*/
|
||||
|
||||
interface CapacitorKonfiguration {
|
||||
appId: string
|
||||
appName: string
|
||||
webDir: string
|
||||
server?: { androidScheme?: string; iosScheme?: string; cleartext?: boolean }
|
||||
ios?: { contentInset?: string; backgroundColor?: string }
|
||||
android?: { backgroundColor?: string }
|
||||
}
|
||||
|
||||
const konfiguration: CapacitorKonfiguration = {
|
||||
const konfiguration: CapacitorConfig = {
|
||||
appId: "app.datametric360",
|
||||
appName: "DataMetric360",
|
||||
webDir: "dist",
|
||||
@@ -46,6 +28,17 @@ const konfiguration: CapacitorKonfiguration = {
|
||||
android: {
|
||||
backgroundColor: "#161b23",
|
||||
},
|
||||
plugins: {
|
||||
// Leitet fetch() über die native HTTP-Schicht statt über die WebView.
|
||||
//
|
||||
// Das ist hier nicht Feinschliff, sondern notwendig: Die native Hülle
|
||||
// liefert die Oberfläche unter einem eigenen Ursprung aus, jede Anfrage an
|
||||
// Home Assistant ist damit ursprungsübergreifend, und die WebView lehnt sie
|
||||
// ohne CORS-Freigabe ab. Nativ gestellte Anfragen kennen keine
|
||||
// CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am
|
||||
// Server. Die WebSocket-Verbindung läuft unberührt weiter.
|
||||
CapacitorHttp: { enabled: true },
|
||||
},
|
||||
}
|
||||
|
||||
export default konfiguration
|
||||
|
||||
@@ -14,12 +14,17 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||
"@audi-dash/ui": "*",
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@capacitor/core": "^8.5.0",
|
||||
"@capacitor/ios": "^8.5.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"@types/node": "^24.13.3",
|
||||
|
||||
@@ -23,13 +23,16 @@ export function Bild({
|
||||
groesse?: "default" | "mini" | "round"
|
||||
}) {
|
||||
const url = bildUrl(datei)
|
||||
// Der Dateiname hilft nur, wenn er lesbar ist — in der 78px breiten
|
||||
// Miniaturansicht liefe er über den Rand hinaus.
|
||||
const mitDateiname = groesse !== "mini" && groesse !== "round"
|
||||
return (
|
||||
<ImagePlaceholder
|
||||
{...(url ? { src: url } : {})}
|
||||
{...(groesse ? { size: groesse } : {})}
|
||||
{...(mitDateiname && datei ? { hint: datei } : {})}
|
||||
alt={beschreibung}
|
||||
label={name}
|
||||
hint={datei ?? undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Statistik() {
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dm-raster">
|
||||
<Tile>
|
||||
<span className="ads-eyebrow">Distanz und Fahrzeit {a.jahr}</span>
|
||||
<Fig value={de(a.kmJahr)} unit="km" size={40} />
|
||||
@@ -74,7 +74,7 @@ export function Statistik() {
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<Tile>
|
||||
<Tile className="dm-raster__breit">
|
||||
<span className="ads-eyebrow">Langzeitverbrauch</span>
|
||||
<Fig value={deOderStrich(a.langzeit.wert, 1)} unit="l/100 km" size={40} />
|
||||
<Werteliste
|
||||
@@ -150,6 +150,6 @@ export function Statistik() {
|
||||
}
|
||||
/>
|
||||
</Tile>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,23 +17,30 @@ const SCHILDER = import.meta.glob<string>("../assets/audi/typenschilder/*.svg",
|
||||
query: "?url",
|
||||
})
|
||||
|
||||
/** Modellname aus dem Profil → Dateikürzel des Typenschilds. */
|
||||
export function schildKuerzel(titel: string): string | null {
|
||||
const norm = titel.toLowerCase().replace(/\s+/g, " ").trim()
|
||||
const treffer = /\b(rs)\s?([34567])\b/.exec(norm) ?? /\b(s)\s?(q7|3)\b/.exec(norm)
|
||||
if (!treffer) return null
|
||||
const [, reihe, zahl] = treffer
|
||||
return `${reihe}${zahl}`.replace("sq7", "sq7")
|
||||
/**
|
||||
* Erkennt aus dem Modellnamen das passende Typenschild und trennt ab, was
|
||||
* darüber hinaus im Namen steht.
|
||||
*
|
||||
* Das ist die Regel aus dem alten Panel (`badge()`): Der Modellname selbst
|
||||
* steckt schon im Schild — daneben gehört nur, was danach kommt („Avant
|
||||
* competition"). Sonst stünde „RS 4" zweimal da.
|
||||
*/
|
||||
export function schildErkennen(
|
||||
titel: string,
|
||||
): { kuerzel: string; zusatz: string } | null {
|
||||
const treffer = /^\s*(RS\s?[34567]|S\s?Q7|S\s?3)\b/i.exec(titel)
|
||||
if (!treffer?.[1]) return null
|
||||
const kuerzel = treffer[1].replace(/\s+/g, "").toLowerCase()
|
||||
return { kuerzel, zusatz: titel.slice(treffer[0].length).trim() }
|
||||
}
|
||||
|
||||
export function Typenschild({ modell, hoehe = 22 }: { modell: string; hoehe?: number }) {
|
||||
const [theme] = useTheme()
|
||||
const kuerzel = schildKuerzel(modell)
|
||||
if (!kuerzel) return null
|
||||
const erkannt = schildErkennen(modell)
|
||||
if (!erkannt) return null
|
||||
|
||||
const variante = theme === "nacht" ? "negative" : "positive"
|
||||
const pfad = `../assets/audi/typenschilder/${kuerzel}-${variante}.svg`
|
||||
const quelle = SCHILDER[pfad]
|
||||
const quelle = SCHILDER[`../assets/audi/typenschilder/${erkannt.kuerzel}-${variante}.svg`]
|
||||
if (!quelle) return null
|
||||
|
||||
return <img src={quelle} alt={modell} height={hoehe} style={{ height: hoehe, width: "auto" }} />
|
||||
|
||||
@@ -15,7 +15,7 @@ import { datum, datumZeit, dauer, de, deOderStrich, eur } from "../format"
|
||||
import type { SeitenName } from "../navigation"
|
||||
import { Leerzustand, NaviKachel, Wertzeile, Werteliste } from "./bausteine"
|
||||
import { Bild } from "./Bild"
|
||||
import { Typenschild } from "./Typenschild"
|
||||
import { Typenschild, schildErkennen } from "./Typenschild"
|
||||
|
||||
export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string) => void }) {
|
||||
const { fahrzeug, einstellungen, fahrten, tankvorgaenge, jetztAktualisieren, statusStand } =
|
||||
@@ -24,6 +24,22 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
|
||||
if (!fahrzeug || !einstellungen) return null
|
||||
|
||||
// Der Zusatz stammt aus dem Modellnamen selbst (alles nach dem Typenschild)
|
||||
// plus der eigens gepflegten Ausführung — so macht es auch das alte Panel.
|
||||
const schild = schildErkennen(einstellungen.fahrzeugtitel)
|
||||
const ausfuehrung = einstellungen.ausfuehrung.trim()
|
||||
// Die Ausführung steht oft schon im Modellnamen („RS 4 Avant competition"
|
||||
// plus Ausführung „competition") — dann nicht doppelt anhängen.
|
||||
const zusatz = [
|
||||
schild?.zusatz,
|
||||
ausfuehrung && !schild?.zusatz.toLowerCase().includes(ausfuehrung.toLowerCase())
|
||||
? ausfuehrung
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim()
|
||||
|
||||
const letzteFahrt = fahrten
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.ts_start).getTime() - new Date(a.ts_start).getTime())[0]
|
||||
@@ -59,11 +75,18 @@ export function Uebersicht({ geheZu }: { geheZu: (name: SeitenName, id?: string)
|
||||
/>
|
||||
<div className="dm-szene__text">
|
||||
<div className="dm-szene__marke">
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={20} />
|
||||
<div className="ads-title">
|
||||
{einstellungen.fahrzeugtitel}{" "}
|
||||
{einstellungen.fahrzeugzusatz && (
|
||||
<span className="ads-sport">{einstellungen.fahrzeugzusatz}</span>
|
||||
<Typenschild modell={einstellungen.fahrzeugtitel} hoehe={26} />
|
||||
{/* Neben dem Schild steht nur, was im Namen darüber hinausgeht —
|
||||
das Modell selbst zeigt bereits das Schild. Ohne erkanntes
|
||||
Schild tritt der volle Name an dessen Stelle. */}
|
||||
<div className="ads-title dm-szene__zusatz">
|
||||
{schild ? (
|
||||
zusatz && <span className="ads-sport">{zusatz}</span>
|
||||
) : (
|
||||
<>
|
||||
{einstellungen.fahrzeugtitel}{" "}
|
||||
{zusatz && <span className="ads-sport">{zusatz}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,15 @@
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
/* Inhalte dürfen nie gestaucht werden — die Fläche scrollt stattdessen.
|
||||
Ohne das quetscht Flex jeden Block mit `overflow: hidden` auf Höhe 0,
|
||||
sobald die Seite länger ist als der Bildschirm: `overflow: hidden` setzt
|
||||
die automatische Mindesthöhe auf 0, und dann greift flex-shrink ungebremst.
|
||||
Genau so verschwand das Fahrzeugbild samt Modellname und Kennzeichen. */
|
||||
.dm-inhalt > * {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dm-inhalt--breit {
|
||||
padding: var(--sp-5) 32px 32px;
|
||||
}
|
||||
|
||||
@@ -421,9 +421,10 @@
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: var(--fg3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
/* Umbrechen statt abschneiden: Datum, Uhrzeit und Dauer sind alle drei
|
||||
wichtig, und "11.08.2026 · 12:27 Uhr · 3…" verliert genau die Angabe,
|
||||
die man sucht. */
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Detailseiten */
|
||||
@@ -568,3 +569,27 @@
|
||||
background: var(--canvas);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dm-szene__zusatz {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Der Zusatz neben dem Typenschild darf umbrechen — abgeschnittener Text mit
|
||||
Auslassungspunkten sieht nach Fehler aus, ein Umbruch nicht. */
|
||||
.dm-szene__zusatz {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Die Galerie richtet sich nach der festen Kachelbreite der Bibliothek (78px),
|
||||
damit die Kacheln nicht auseinandergezogen werden und ihre Beschriftung
|
||||
innerhalb des Rahmens bleibt. */
|
||||
.dm-galerie {
|
||||
grid-template-columns: repeat(auto-fill, 78px);
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
@@ -51,10 +51,19 @@ def _token_lesen():
|
||||
|
||||
|
||||
def historie_abfragen(entity_id, start_iso, end_iso):
|
||||
"""urllib.request.urlopen läuft direkt, nicht über task.executor - anders
|
||||
als ein bloßes open() ist das ein modulqualifizierter Aufruf einer echten
|
||||
externen Funktion, der an der Testinstanz ohne Fehler durchlief. Blockiert
|
||||
kurz, läuft nur beim Fahrtabschluss-Screening, nicht bei jedem Tick."""
|
||||
"""Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab.
|
||||
|
||||
Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand
|
||||
rief urlopen() direkt auf - das lief jahrelang durch, bis Home Assistant
|
||||
anfing, blockierende Aufrufe im Ereignisschleifen-Thread zu erkennen und
|
||||
abzubrechen (real beobachtet ab 2026.8: "Caught blocking call to
|
||||
putrequest ... inside the event loop"). Seitdem schlug jedes Screening
|
||||
fehl, und manuell angelegte Fahrten blieben ohne Strecke - sichtbar nur
|
||||
als Warnung im Protokoll, weil der Aufrufer den Fehler bewusst abfängt.
|
||||
|
||||
task.executor braucht echte externe Funktionen; urllib.request.urlopen und
|
||||
die read-Methode der Antwort sind genau das (dieselbe Einschränkung wie bei
|
||||
io.open in profil.py)."""
|
||||
token = _token_lesen()
|
||||
start_enc = urllib.parse.quote(start_iso)
|
||||
end_enc = urllib.parse.quote(end_iso)
|
||||
@@ -63,10 +72,10 @@ def historie_abfragen(entity_id, start_iso, end_iso):
|
||||
f"?filter_entity_id={entity_id}&end_time={end_enc}"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
rohtext = resp.read().decode("utf-8")
|
||||
resp = task.executor(urllib.request.urlopen, req, timeout=10)
|
||||
rohdaten = task.executor(resp.read)
|
||||
resp.close()
|
||||
daten = json.loads(rohtext)
|
||||
daten = json.loads(rohdaten.decode("utf-8"))
|
||||
return daten[0] if daten else []
|
||||
|
||||
|
||||
|
||||
Generated
+1220
-2
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,17 @@
|
||||
|
||||
default_config:
|
||||
|
||||
# Die App laeuft unter einer anderen Adresse als Home Assistant. Ohne diese
|
||||
# Freigabe lehnt der Browser jede Anfrage ab, noch bevor sie den Server
|
||||
# erreicht (CORS). Gilt genauso im Echtbetrieb - siehe
|
||||
# ../homeassistant/REVERSE_PROXY.md.
|
||||
http:
|
||||
cors_allowed_origins:
|
||||
- http://localhost:4173 # Vorschau des Produktionsbuendels
|
||||
- http://localhost:5173 # Entwicklungsserver
|
||||
- capacitor://localhost # native Huelle iOS
|
||||
- ionic://localhost # native Huelle Android
|
||||
|
||||
pyscript:
|
||||
allow_all_imports: true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user