egorin: демо-раздел «Карта-лаборатория (GL)» — MapLibre GL, donut-кластеризация, 3D-здания, CARTO (порт benzrf/orbit-gps-mobile); главная карта = Leaflet+2ГИС везде; docs
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Карта: GL-рендер по референсу benzrf.ru (кластеризация + donut + 3D)
|
||||
|
||||
Портирован рендер-подход benzrf.ru из прототипа `prototypes/orbit-gps-mobile.html` в React.
|
||||
|
||||
## Модули
|
||||
- **`src/modules/gps/MapClusterGL.tsx`** — карта MapLibre GL: GeoJSON-источник с
|
||||
`cluster:true` + `clusterProperties` (статусы), **donut-кластеры** (SVG), пины со
|
||||
статус-цветом, экспандинг кластера по клику, подложка **2ГИС** + OpenFreeMap для 3D,
|
||||
**3D-здания** (fill-extrusion).
|
||||
- **`src/modules/gps/MapLabModule.tsx`** — демо-раздел **«Карта-лаборатория (GL)»**
|
||||
(маршрут `/map-lab`): карта + тумблер 3D + легенда статусов, данные из `useGpsData`.
|
||||
|
||||
## Статусы → цвет
|
||||
moving `#3fd06b` · parked/норма `#f4b740` · offline `#7d94a3` · alarm (ТП) `#ef4444`.
|
||||
|
||||
## Смысл
|
||||
Основная карта = Leaflet (DOM-маркеры). GL-рендер (GPU + кластеризация) — плавно при
|
||||
сотнях объектов и длинных треках. Демо-раздел показывает подход, не трогая рабочий
|
||||
GPS-раздел. Дальнейшее: треки на line-слои с градиентом, полная миграция.
|
||||
|
||||
Прод: `https://egorin.r.servaki.online/map-lab`.
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { Zap, Cpu, Navigation, Network, ShieldCheck, Siren, Smartphone, FolderGit2, Gauge, Wallet, BellRing, MapPinned, History, MapPin, Building2, Archive } from 'lucide-react';
|
||||
import { Zap, Cpu, Navigation, Network, ShieldCheck, Siren, Smartphone, FolderGit2, Gauge, Wallet, BellRing, MapPinned, History, MapPin, Building2, Archive, Boxes } from 'lucide-react';
|
||||
import { EnergyModule } from '@/modules/energy/EnergyModule';
|
||||
import { SmartModule } from '@/modules/smart/SmartModule';
|
||||
import { LocationsModule } from '@/modules/smart/LocationsModule';
|
||||
@@ -7,6 +7,7 @@ import { SecurityModule } from '@/modules/security/SecurityModule';
|
||||
import { ObjectsModule } from '@/modules/security/ObjectsModule';
|
||||
import { ArchiveModule } from '@/modules/security/ArchiveModule';
|
||||
import { GpsModule } from '@/modules/gps/GpsModule';
|
||||
import { MapLabModule } from '@/modules/gps/MapLabModule';
|
||||
import { GeoModule } from '@/modules/geo/GeoModule';
|
||||
import { EventsModule } from '@/modules/gps/EventsModule';
|
||||
import { NetworkModule } from '@/modules/network/NetworkModule';
|
||||
@@ -38,6 +39,7 @@ export const MODULES: ModuleDef[] = [
|
||||
{ id: 'security-objects', path: '/security-objects', title: 'Охраняемые объекты', short: 'Объекты', icon: Building2, component: ObjectsModule, section: 'security', hidden: true },
|
||||
{ id: 'security-archive', path: '/security-archive', title: 'Архив сработок', short: 'Архив', icon: Archive, component: ArchiveModule, section: 'security', hidden: true },
|
||||
{ id: 'gps', path: '/gps', title: 'GPS-мониторинг', short: 'GPS', icon: Navigation, component: GpsModule, section: 'gps' },
|
||||
{ id: 'map-lab', path: '/map-lab', title: 'Карта-лаборатория (GL)', short: 'GL-карта', icon: Boxes, component: MapLabModule, section: 'gps' },
|
||||
{ id: 'geo', path: '/geofences', title: 'Геозоны', short: 'Геозоны', icon: MapPinned, component: GeoModule, section: 'gps', hidden: true },
|
||||
{ id: 'events', path: '/events', title: 'История событий', short: 'События', icon: History, component: EventsModule, section: 'gps', hidden: true },
|
||||
{ id: 'network', path: '/network', title: 'Сетевая инфраструктура', short: 'Сеть', icon: Network, component: NetworkModule, section: 'netinfra' },
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { MonitoredEntity } from '@/core/types';
|
||||
|
||||
// «Карта-лаборатория» — рендер по референсу benzrf.ru / прототипа orbit-gps-mobile:
|
||||
// MapLibre GL, GeoJSON-источник с нативной кластеризацией + donut-кластеры (SVG-сегменты
|
||||
// по статусам), 2ГИС-подложка, опц. 3D-здания (OpenFreeMap fill-extrusion).
|
||||
|
||||
type Status = 'moving' | 'parked' | 'offline' | 'alarm';
|
||||
const COLORS: Record<Status, string> = { moving: '#3fd06b', parked: '#f4b740', offline: '#7d94a3', alarm: '#ef4444' };
|
||||
|
||||
function statusOf(e: MonitoredEntity): Status {
|
||||
if (!e.online) return e.type === 'energy' ? 'alarm' : 'offline';
|
||||
if (e.type === 'transport') return (e.telemetry?.speed ?? 0) > 0 ? 'moving' : 'parked';
|
||||
return 'parked'; // стационарные онлайн (энерго/сеть)
|
||||
}
|
||||
function glyphOf(e: MonitoredEntity): string {
|
||||
if (e.type === 'energy') return '⚡';
|
||||
if (e.type === 'network') return '📶';
|
||||
return '▲';
|
||||
}
|
||||
|
||||
// Подложка: CARTO dark (растр, CORS-совместим с WebGL — 2ГИС-тайлы в GL не отдают CORS/204,
|
||||
// поэтому 2ГИС остаётся на Leaflet-разделах, а GL-демо — на CARTO, как в эталоне benzrf).
|
||||
// + OpenFreeMap-вектор для 3D-зданий.
|
||||
const STYLE: any = {
|
||||
version: 8,
|
||||
sources: {
|
||||
carto: { type: 'raster', tileSize: 256, tiles: ['a', 'b', 'c', 'd'].map((s) => `https://${s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png`), attribution: '© OpenStreetMap © CARTO', maxzoom: 19 },
|
||||
openfreemap: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' },
|
||||
},
|
||||
layers: [
|
||||
{ id: 'bg', type: 'background', paint: { 'background-color': '#0b0f14' } },
|
||||
{ id: 'base', type: 'raster', source: 'carto' },
|
||||
],
|
||||
};
|
||||
|
||||
function fc(entities: MonitoredEntity[]) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: entities
|
||||
.filter((e) => e.lat != null && e.lon != null)
|
||||
.map((e) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [e.lon!, e.lat!] }, properties: { id: e.id, status: statusOf(e) } })),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function donutSegment(start: number, end: number, r: number, r0: number, color: string): string {
|
||||
if (end - start === 1) end -= 0.00001;
|
||||
const a0 = 2 * Math.PI * (start - 0.25), a1 = 2 * Math.PI * (end - 0.25);
|
||||
const x0 = Math.cos(a0), y0 = Math.sin(a0), x1 = Math.cos(a1), y1 = Math.sin(a1);
|
||||
const large = end - start > 0.5 ? 1 : 0;
|
||||
return `<path d="M ${r + r0 * x0} ${r + r0 * y0} L ${r + r * x0} ${r + r * y0} A ${r} ${r} 0 ${large} 1 ${r + r * x1} ${r + r * y1} L ${r + r0 * x1} ${r + r0 * y1} A ${r0} ${r0} 0 ${large} 0 ${r + r0 * x0} ${r + r0 * y0}" fill="${color}"/>`;
|
||||
}
|
||||
function createDonut(props: any): HTMLElement {
|
||||
const keys: Status[] = ['moving', 'parked', 'offline', 'alarm'];
|
||||
const total = props.point_count as number;
|
||||
const r = total >= 15 ? 27 : total >= 8 ? 23 : 19;
|
||||
const r0 = Math.round(r * 0.62), w = r * 2;
|
||||
let svg = `<svg width="${w}" height="${w}" viewBox="0 0 ${w} ${w}">`;
|
||||
let offset = 0;
|
||||
for (const k of keys) {
|
||||
const n = Number(props[k] || 0);
|
||||
if (!n) continue;
|
||||
svg += donutSegment(offset / total, (offset + n) / total, r, r0, COLORS[k]);
|
||||
offset += n;
|
||||
}
|
||||
svg += `<circle cx="${r}" cy="${r}" r="${r0}" fill="#141b22"/><text x="${r}" y="${r}" dominant-baseline="central" text-anchor="middle" fill="#cfe3d6" font-size="${Math.round(r * 0.58)}" font-weight="700" font-family="Inter,system-ui">${total}</text></svg>`;
|
||||
const el = document.createElement('div');
|
||||
el.style.cursor = 'pointer';
|
||||
el.style.filter = 'drop-shadow(0 2px 6px rgba(0,0,0,.5))';
|
||||
el.innerHTML = svg;
|
||||
return el;
|
||||
}
|
||||
function createPin(e: MonitoredEntity, onClick: () => void): HTMLElement {
|
||||
const c = COLORS[statusOf(e)];
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer';
|
||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${c};border:2px solid #0F172A;display:grid;place-items:center;box-shadow:0 0 0 3px ${c}40;font-size:13px">${glyphOf(e)}</div><div style="margin-top:2px;font:600 10px Inter,system-ui;color:#cfe3d6;background:rgba(12,16,20,.7);padding:1px 5px;border-radius:6px;white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis">${e.name}</div>`;
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onClick(); });
|
||||
return el;
|
||||
}
|
||||
|
||||
export function MapClusterGL({ entities, threeD, onSelect, className }: { entities: MonitoredEntity[]; threeD?: boolean; onSelect?: (id: string) => void; className?: string }) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const markers = useRef<Record<string, maplibregl.Marker>>({});
|
||||
const onScreen = useRef<Record<string, maplibregl.Marker>>({});
|
||||
const entRef = useRef(entities);
|
||||
entRef.current = entities;
|
||||
|
||||
useEffect(() => {
|
||||
if (!box.current || mapRef.current) return;
|
||||
const map = new maplibregl.Map({ container: box.current, center: [43.0, 44.04], zoom: 10.4, style: STYLE, attributionControl: { compact: true } as any, preserveDrawingBuffer: true });
|
||||
mapRef.current = map;
|
||||
map.touchZoomRotate.enableRotation();
|
||||
// Fix: MapLibre рисует пустой холст, если контейнер доразметился после init.
|
||||
// ResizeObserver + отложенные resize (rAF/таймауты) форсируют перерисовку после раскладки.
|
||||
const ro = new ResizeObserver(() => map.resize());
|
||||
ro.observe(box.current);
|
||||
// map.resize() в одиночку иногда не форсирует перерисовку из-за раскладки контейнера;
|
||||
// синтетический window-resize надёжно триггерит внутренний обработчик MapLibre (проверено).
|
||||
const bump = () => { try { map.resize(); window.dispatchEvent(new Event('resize')); } catch { /* map removed */ } };
|
||||
requestAnimationFrame(bump);
|
||||
const t1 = setTimeout(bump, 250), t2 = setTimeout(bump, 700), t3 = setTimeout(bump, 1400);
|
||||
|
||||
const updateMarkers = () => {
|
||||
if (!map.getSource('objects') || !map.isSourceLoaded('objects')) return;
|
||||
const next: Record<string, maplibregl.Marker> = {};
|
||||
const feats = map.querySourceFeatures('objects');
|
||||
for (const f of feats) {
|
||||
const coords = (f.geometry as any).coordinates as [number, number];
|
||||
const p: any = f.properties;
|
||||
let id: string, m: maplibregl.Marker | undefined;
|
||||
if (p.cluster) {
|
||||
id = 'c' + p.cluster_id;
|
||||
m = markers.current[id];
|
||||
const sig = [p.point_count, p.moving, p.parked, p.offline, p.alarm].join('|');
|
||||
if (m && (m as any)._sig !== sig) { m.remove(); delete markers.current[id]; delete onScreen.current[id]; m = undefined; }
|
||||
if (!m) {
|
||||
const el = createDonut(p);
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
(map.getSource('objects') as any).getClusterExpansionZoom(p.cluster_id).then((z: number) => map.easeTo({ center: coords, zoom: z + 0.4, duration: 500 }));
|
||||
});
|
||||
m = markers.current[id] = new maplibregl.Marker({ element: el }).setLngLat(coords);
|
||||
(m as any)._sig = sig;
|
||||
}
|
||||
} else {
|
||||
id = 'v' + p.id;
|
||||
const e = entRef.current.find((x) => x.id === p.id);
|
||||
if (!e) continue;
|
||||
m = markers.current[id];
|
||||
if (!m) m = markers.current[id] = new maplibregl.Marker({ element: createPin(e, () => onSelect?.(e.id)) }).setLngLat(coords);
|
||||
else m.setLngLat(coords);
|
||||
}
|
||||
next[id] = m;
|
||||
if (!onScreen.current[id]) m.addTo(map);
|
||||
}
|
||||
for (const id in onScreen.current) if (!next[id]) onScreen.current[id].remove();
|
||||
onScreen.current = next;
|
||||
};
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('objects', {
|
||||
type: 'geojson', data: fc(entRef.current), cluster: true, clusterMaxZoom: 13, clusterRadius: 56,
|
||||
clusterProperties: {
|
||||
moving: ['+', ['case', ['==', ['get', 'status'], 'moving'], 1, 0]],
|
||||
parked: ['+', ['case', ['==', ['get', 'status'], 'parked'], 1, 0]],
|
||||
offline: ['+', ['case', ['==', ['get', 'status'], 'offline'], 1, 0]],
|
||||
alarm: ['+', ['case', ['==', ['get', 'status'], 'alarm'], 1, 0]],
|
||||
},
|
||||
} as any);
|
||||
map.addLayer({ id: 'stub', type: 'circle', source: 'objects', paint: { 'circle-radius': 0, 'circle-opacity': 0 } });
|
||||
map.on('render', updateMarkers);
|
||||
map.resize(); // на случай, если контейнер получил размер только сейчас
|
||||
});
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); ro.disconnect(); map.remove(); mapRef.current = null; markers.current = {}; onScreen.current = {}; };
|
||||
}, [onSelect]);
|
||||
|
||||
// Обновление данных источника
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
const src = map?.getSource('objects') as any;
|
||||
if (src) src.setData(fc(entities));
|
||||
}, [entities]);
|
||||
|
||||
// 3D-здания
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const apply = () => {
|
||||
if (threeD && !map.getLayer('b3d')) {
|
||||
map.addLayer({ id: 'b3d', type: 'fill-extrusion', source: 'openfreemap', 'source-layer': 'building', minzoom: 14, paint: { 'fill-extrusion-color': '#2c3a47', 'fill-extrusion-height': ['coalesce', ['get', 'render_height'], 12], 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], 'fill-extrusion-opacity': 0.78 } } as any, 'stub');
|
||||
map.easeTo({ pitch: 52, duration: 600 });
|
||||
} else if (!threeD && map.getLayer('b3d')) {
|
||||
map.removeLayer('b3d');
|
||||
map.easeTo({ pitch: 0, duration: 600 });
|
||||
}
|
||||
};
|
||||
if (map.isStyleLoaded()) apply(); else map.once('load', apply);
|
||||
}, [threeD]);
|
||||
|
||||
return <div ref={box} className={className} style={{ width: '100%', height: '100%', minHeight: 320 }} />;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react';
|
||||
import { Boxes, Layers3 } from 'lucide-react';
|
||||
import { useAuth } from '@/core/store';
|
||||
import { useGpsData } from './useGpsData';
|
||||
import { MapClusterGL } from './MapClusterGL';
|
||||
|
||||
const LEGEND: Array<{ k: string; c: string; label: string }> = [
|
||||
{ k: 'moving', c: '#3fd06b', label: 'В движении' },
|
||||
{ k: 'parked', c: '#f4b740', label: 'Стоянка / норма' },
|
||||
{ k: 'offline', c: '#7d94a3', label: 'Нет связи' },
|
||||
{ k: 'alarm', c: '#ef4444', label: 'Авария (ТП)' },
|
||||
];
|
||||
|
||||
// Демо-раздел «Карта-лаборатория»: рендер карты по референсу benzrf.ru —
|
||||
// MapLibre GL + нативная кластеризация (donut-кластеры) + 2ГИС + 3D-здания.
|
||||
export function MapLabModule() {
|
||||
const user = useAuth((s) => s.user);
|
||||
const { objs, live } = useGpsData(user);
|
||||
const [threeD, setThreeD] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent/15 text-accent"><Boxes className="h-5 w-5" /></span>
|
||||
<div>
|
||||
<div className="text-base font-semibold leading-tight">Карта-лаборатория (GL)</div>
|
||||
<div className="text-[11px] text-fg-muted">MapLibre GL · кластеризация donut · 2ГИС · референс benzrf.ru</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${live ? 'bg-ok/20 text-ok' : 'bg-muted text-fg-muted'}`}>{live ? 'live · шлюз' : 'демо'} · {objs.length} объектов</span>
|
||||
<button onClick={() => setThreeD((v) => !v)} className={`flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-sm font-semibold transition-colors ${threeD ? 'bg-accent text-[#06210f]' : 'border border-border text-fg hover:bg-muted'}`}>
|
||||
<Layers3 className="h-4 w-4" /> 3D-здания
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[420px] flex-1 overflow-hidden rounded-2xl border border-border">
|
||||
<MapClusterGL entities={objs} threeD={threeD} className="absolute inset-0" />
|
||||
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col gap-1 rounded-xl bg-surface/80 px-3 py-2 backdrop-blur">
|
||||
{LEGEND.map((l) => (
|
||||
<span key={l.k} className="flex items-center gap-2 text-[11px] text-fg-muted">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ background: l.c }} /> {l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
Скрипты отрисовки портированы из прототипа <span className="font-mono">prototypes/orbit-gps-mobile.html</span>:
|
||||
GeoJSON-источник с <span className="font-mono">cluster:true</span>, donut-кластеры (SVG-сегменты по статусам),
|
||||
подложка 2ГИС, 3D-здания OpenFreeMap (fill-extrusion). Кластер разворачивается по клику.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
import { useEffect, useState, type ComponentProps } from 'react';
|
||||
import { type ComponentProps } from 'react';
|
||||
import { MapView } from './MapView';
|
||||
import { MapViewGL } from './MapViewGL';
|
||||
|
||||
// «Вторая схема» карты: на мобильном (<1024px) — MapLibre GL (плавно, GPU),
|
||||
// на десктопе — привычный Leaflet. Пропсы идентичны, переключение по ширине окна.
|
||||
// Основная карта — Leaflet с подложкой 2ГИС на всех разделах и устройствах
|
||||
// (2ГИС-растр отдаётся как <img>, работает везде; в WebGL/MapLibre 2ГИС не годится —
|
||||
// нет CORS. GL-рендер с кластеризацией вынесен в демо-раздел «Карта-лаборатория» на CARTO).
|
||||
export function MapSwitch(props: ComponentProps<typeof MapView>) {
|
||||
const [gl, setGl] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1023px)');
|
||||
const on = () => setGl(mq.matches);
|
||||
on();
|
||||
mq.addEventListener('change', on);
|
||||
return () => mq.removeEventListener('change', on);
|
||||
}, []);
|
||||
return gl ? <MapViewGL {...(props as any)} /> : <MapView {...props} />;
|
||||
return <MapView {...props} />;
|
||||
}
|
||||
|
||||
@@ -27,29 +27,9 @@ interface Props {
|
||||
events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>;
|
||||
}
|
||||
|
||||
// По умолчанию — 2ГИС (растровый источник) на всех разделах. dark/light не влияет на тайлы 2ГИС.
|
||||
const STYLE = (_dark: boolean): any => ({
|
||||
version: 8,
|
||||
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||
sources: {
|
||||
'2gis': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://tile0.maps.2gis.com/tiles?x={x}&y={y}&z={z}',
|
||||
'https://tile1.maps.2gis.com/tiles?x={x}&y={y}&z={z}',
|
||||
'https://tile2.maps.2gis.com/tiles?x={x}&y={y}&z={z}',
|
||||
'https://tile3.maps.2gis.com/tiles?x={x}&y={y}&z={z}',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© 2ГИС',
|
||||
maxzoom: 18,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{ id: 'bg', type: 'background', paint: { 'background-color': '#0b0f14' } },
|
||||
{ id: '2gis', type: 'raster', source: '2gis' },
|
||||
],
|
||||
});
|
||||
// CARTO GL-стиль (2ГИС-растр в WebGL не работает — нет CORS; основная карта = Leaflet+2ГИС).
|
||||
const STYLE = (dark: boolean) =>
|
||||
`https://basemaps.cartocdn.com/gl/${dark ? 'dark-matter' : 'positron'}-gl-style/style.json`;
|
||||
|
||||
function objColor(e: MonitoredEntity, colors?: Record<string, string>): string {
|
||||
if (colors?.[e.id]) return colors[e.id];
|
||||
|
||||
Reference in New Issue
Block a user