Compare commits
17 Commits
578b526f96
...
01d9553db4
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
01d9553db4 | ||
| 2559f1e66b | |||
| 519bce6b78 | |||
| 557ea96be0 | |||
| 9a49cc67f0 | |||
| 3012f0799b | |||
| 7a8852f64b | |||
| f92e6c3cf1 | |||
| 72dc83daff | |||
| 04d9d588e8 | |||
| 345a21a211 | |||
| 3de992d251 | |||
| 369ef61cab | |||
| e6e96c4b0d | |||
| dc96033cb1 | |||
| 9ee6bf4380 | |||
| e0f0726e68 |
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.155
|
client.version=1.2.159
|
||||||
server.version=1.2.147
|
server.version=1.2.148
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
|
// центрирование и навигацию между пользователями. Используется связанный мульти-граф
|
||||||
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
|
// networkGraphUsers: у каждого пользователя свой набор связей, тап по узлу переключает
|
||||||
// карту на сеть этого человека (как реальный путь, но локально).
|
// карту на сеть этого человека (как реальный путь, но локально).
|
||||||
|
//
|
||||||
|
// + Прототип «Мегамасштаб»: переключатель «Вселенная» процедурно достраивает связи
|
||||||
|
// 2-го и 3-го уровней НА ФРОНТЕНДЕ (API отдаёт только прямые связи — его не трогаем).
|
||||||
|
// Это чисто визуальный лабораторный эксперимент на мок-данных.
|
||||||
|
|
||||||
import { renderHeader } from '../../components/header.js';
|
import { renderHeader } from '../../components/header.js';
|
||||||
import { networkGraphUsers } from '../../mock-data.js';
|
import { networkGraphUsers } from '../../mock-data.js';
|
||||||
@ -22,6 +26,18 @@ const FILTERS = {
|
|||||||
};
|
};
|
||||||
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
|
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
|
||||||
|
|
||||||
|
// Пул имён для процедурных «дальних» узлов 2-го уровня (3-й уровень — без подписей, точки).
|
||||||
|
const DEEP_NAMES = ['Лео', 'Майя', 'Тео', 'Ева', 'Ник', 'Зоя', 'Ян', 'Ада', 'Рик', 'Уна',
|
||||||
|
'Гор', 'Лия', 'Сэм', 'Иня', 'Ким', 'Эль', 'Юта', 'Бьорн', 'Мила', 'Орт'];
|
||||||
|
|
||||||
|
// Детерминированный сид 0..1 по строке (без Math.random: одинаковый узел всегда одинаков).
|
||||||
|
function seed01(str) {
|
||||||
|
let h = 2166136261;
|
||||||
|
const s = String(str || '');
|
||||||
|
for (let i = 0; i < s.length; i += 1) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||||
|
return ((h >>> 0) % 100000) / 100000;
|
||||||
|
}
|
||||||
|
|
||||||
function helpText() {
|
function helpText() {
|
||||||
return [
|
return [
|
||||||
'Лаборатория карты связей (мок-данные, без сервера).',
|
'Лаборатория карты связей (мок-данные, без сервера).',
|
||||||
@ -30,6 +46,13 @@ function helpText() {
|
|||||||
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
'• Тап по центральному узлу — здесь открылся бы профиль.',
|
||||||
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
'• Долгое нажатие — контекстное меню (в реальном пути «Связи»).',
|
||||||
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
|
'• Чипы под шапкой — фильтры слоёв: Все / Семья / Друзья / Сияющие.',
|
||||||
|
'• Чип «Вселенная» — режим «Интерактивная паутина» (глубина 2-3 уровней, фейковые связи).',
|
||||||
|
' Наведи мышь/палец на узел — его связи временно выплывают (превью). КЛИК по узлу — «умный',
|
||||||
|
' наезд»: камера летит и центрирует его, он вырастает, друзья раскрываются крупно вокруг,',
|
||||||
|
' путь назад к Ивану горит нитью, остальное затемняется. Клик по нему ещё раз — всплыть.',
|
||||||
|
' Тап по центру (Ивану) — полный сброс (всё на 100%, камера отъезжает).',
|
||||||
|
' Колесо мыши / щипок двумя пальцами — зум; при сильном приближении точки 3-го уровня',
|
||||||
|
' превращаются в аватарки. Свайп — pan.',
|
||||||
'',
|
'',
|
||||||
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
'Доступные люди: Иван, Алиса, Павел, Елена, Дмитрий, Олег, Нина, Марина, Света, Кирилл.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@ -42,6 +65,104 @@ function graphForLogin(login) {
|
|||||||
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
|
return { focusUser: { id: key, login: key, name: key, avatar: null, status: '' }, connections: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если у фокуса нет прямых связей (например, тапнули по фейковому дальнему узлу) —
|
||||||
|
// синтезируем 4-6 связей 1-го уровня, чтобы «вселенная» вокруг него не была пустой.
|
||||||
|
function synthTier1(focusId) {
|
||||||
|
const k = 4 + Math.floor(seed01(focusId + ':n') * 3); // 4-6
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < k; i += 1) {
|
||||||
|
const id = `${focusId}__t1_${i}`;
|
||||||
|
const s = seed01(id);
|
||||||
|
out.push({
|
||||||
|
id, login: id, name: DEEP_NAMES[Math.floor(seed01(id + 'n') * DEEP_NAMES.length)],
|
||||||
|
avatar: null, photo: null,
|
||||||
|
relationType: ['family', 'friend', 'business', 'contact'][i % 4],
|
||||||
|
connectionStrength: 0.5 + s * 0.4,
|
||||||
|
status: s > 0.78 ? 'shining' : '',
|
||||||
|
hasOwnConnections: true, tier: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Процедурно достраиваем дальние орбиты: для каждого узла 1-го уровня — 3-5 узлов 2-го уровня,
|
||||||
|
// для каждого узла 2-го уровня — 3-5 «микрозвёзд» 3-го уровня. Всё детерминировано по id.
|
||||||
|
function addDeepLevels(model) {
|
||||||
|
const focusId = model.focusId;
|
||||||
|
const tier1 = model.nodes.filter((n) => String(n.id) !== String(focusId));
|
||||||
|
const extra = [];
|
||||||
|
tier1.forEach((p) => {
|
||||||
|
const k2 = 3 + Math.floor(seed01(p.id + ':2') * 3); // 3-5
|
||||||
|
// «общий друг»: детерминированно берём ДРУГОГО друга Ивана — он окажется и в сети p (общая связь).
|
||||||
|
const others = tier1.filter((o) => String(o.id) !== String(p.id));
|
||||||
|
const common = others.length ? others[Math.floor(seed01(p.id + ':common') * others.length)] : null;
|
||||||
|
for (let i = 0; i < k2; i += 1) {
|
||||||
|
const id2 = `${p.id}__d2_${i}`;
|
||||||
|
const ang2 = (i / k2) * Math.PI * 2 + seed01(p.id) * 0.6;
|
||||||
|
// i===0 + есть общий → подставляем узнаваемого друга Ивана как ОБЩУЮ связь (золотой ободок ★)
|
||||||
|
if (i === 0 && common) {
|
||||||
|
extra.push({
|
||||||
|
id: id2, login: id2, name: common.name || common.login,
|
||||||
|
avatar: null, photo: common.photo || null, relationType: common.relationType || 'friend',
|
||||||
|
strength: 0.5, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2, common: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// узлы 2-го уровня — полноценные (видимые) аватарки: детерминированное фото-лицо (pravatar) + имя
|
||||||
|
const face2 = `https://i.pravatar.cc/120?img=${1 + Math.floor(seed01(id2 + 'p') * 70)}`;
|
||||||
|
extra.push({
|
||||||
|
id: id2, login: id2, name: DEEP_NAMES[Math.floor(seed01(id2) * DEEP_NAMES.length)],
|
||||||
|
avatar: null, photo: face2, relationType: p.relationType || 'contact',
|
||||||
|
strength: 0.4, shining: false, tier: 2, parentId: String(p.id), deepAngle: ang2,
|
||||||
|
});
|
||||||
|
const k3 = 3 + Math.floor(seed01(id2 + ':3') * 3); // 3-5
|
||||||
|
for (let j = 0; j < k3; j += 1) {
|
||||||
|
const id3 = `${id2}_d3_${j}`;
|
||||||
|
const ang3 = (j / k3) * Math.PI * 2 + seed01(id2) * 0.9;
|
||||||
|
// фото-лицо + имя для LOD: точка-звезда дорисуется в читаемую аватарку при сильном зуме
|
||||||
|
const face3 = `https://i.pravatar.cc/100?img=${1 + Math.floor(seed01(id3 + 'p') * 70)}`;
|
||||||
|
extra.push({
|
||||||
|
id: id3, login: id3, name: DEEP_NAMES[Math.floor(seed01(id3 + 'n') * DEEP_NAMES.length)],
|
||||||
|
avatar: null, photo: face3, relationType: 'contact',
|
||||||
|
strength: 0.2, shining: seed01(id3) > 0.82, tier: 3, parentId: id2, deepAngle: ang3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { focusId, nodes: [...model.nodes, ...extra] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Полная модель лаборатории для логина: реальные мок-связи (или синтетика для фейковых
|
||||||
|
// логинов) + опционально дальние уровни, когда включён режим «Вселенная».
|
||||||
|
// fromLogin — предыдущий фокус: остаётся на орбите ярким «треком прохождения» (Иван → Олег → …).
|
||||||
|
function buildLabModel(login, deep, fromLogin) {
|
||||||
|
const tz = graphForLogin(login);
|
||||||
|
if (!Array.isArray(tz.connections) || tz.connections.length === 0) {
|
||||||
|
if (deep) tz.connections = synthTier1(String(tz.focusUser?.id || login));
|
||||||
|
else tz.connections = [];
|
||||||
|
}
|
||||||
|
const base = buildModelFromTz(tz);
|
||||||
|
|
||||||
|
// «Трек прохождения»: предыдущий фокус — на орбите, линия к нему горит ярко (не уходит в ghost).
|
||||||
|
if (fromLogin && String(fromLogin).toLowerCase() !== String(login).toLowerCase()) {
|
||||||
|
const fid = String(fromLogin);
|
||||||
|
const found = base.nodes.find((n) => String(n.id).toLowerCase() === fid.toLowerCase()
|
||||||
|
|| String(n.login || '').toLowerCase() === fid.toLowerCase());
|
||||||
|
if (found) {
|
||||||
|
found.track = true; // уже среди связей — просто подсветим трек
|
||||||
|
} else {
|
||||||
|
const f = graphForLogin(fromLogin).focusUser || {};
|
||||||
|
base.nodes.push({
|
||||||
|
id: fid, login: f.login || fromLogin, name: f.name || fromLogin,
|
||||||
|
avatar: f.avatar && f.avatar !== 'url_to_image' ? f.avatar : null,
|
||||||
|
photo: f.photo || null, relationType: 'friend', strength: 0.97,
|
||||||
|
shining: false, tier: 1, track: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deep ? addDeepLevels(base) : base;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderNetworkLab({ navigate }) {
|
export function renderNetworkLab({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'network-screen';
|
screen.className = 'network-screen';
|
||||||
@ -54,17 +175,13 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
|
|
||||||
const header = renderHeader({
|
const header = renderHeader({
|
||||||
title: 'Связи · лаборатория',
|
title: 'Связи · лаборатория',
|
||||||
leftAction: {
|
leftAction: { label: '←', onClick: () => navigate('network-view') },
|
||||||
label: '←',
|
rightActions: [{ label: '?', onClick: () => window.alert(helpText()) }],
|
||||||
onClick: () => navigate('network-view'),
|
|
||||||
},
|
|
||||||
rightActions: [
|
|
||||||
{ label: '?', onClick: () => window.alert(helpText()) },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
header.classList.add('network-header-overlay');
|
header.classList.add('network-header-overlay');
|
||||||
|
|
||||||
const model = buildModelFromTz(graphForLogin(START_LOGIN));
|
let centerLogin = START_LOGIN;
|
||||||
|
let deepMode = false;
|
||||||
|
|
||||||
// Состояние активного слоя (как в network-view): фокус всегда виден.
|
// Состояние активного слоя (как в network-view): фокус всегда виден.
|
||||||
let activeFilter = 'all';
|
let activeFilter = 'all';
|
||||||
@ -82,18 +199,41 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
stage.append(header);
|
stage.append(header);
|
||||||
screen.append(stage);
|
screen.append(stage);
|
||||||
|
|
||||||
|
const model = buildLabModel(centerLogin, deepMode);
|
||||||
|
|
||||||
|
// Объявлено заранее: onDiveChange (вызывается уже при монтировании) ссылается на эти элементы.
|
||||||
|
let breadcrumbEl = null; // панель хлебных крошек (создаётся ниже)
|
||||||
|
|
||||||
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
|
// Монтируем движок синхронно (не через rAF): размеры сцены движок берёт с запасом
|
||||||
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
|
// (window.innerWidth) и корректирует через ResizeObserver, когда сцена попадёт в DOM.
|
||||||
const graph = createForceGraph({
|
const graph = createForceGraph({
|
||||||
stage,
|
stage,
|
||||||
model,
|
model,
|
||||||
// тап по узлу — переключаем карту на сеть выбранного человека
|
// тап по узлу — переключаем карту на сеть выбранного человека (или фейкового дальнего узла);
|
||||||
|
// в режиме «Вселенная» вокруг нового центра процедурно генерится дальняя орбита.
|
||||||
onNodeTap: (node) => {
|
onNodeTap: (node) => {
|
||||||
graph.setModel(buildModelFromTz(graphForLogin(node.login)));
|
if (deepMode) {
|
||||||
// сохраняем выбранный слой при переходе на сеть другого человека (как в network-view)
|
// Умный фокус: клик по ЛЮБОМУ узлу (Нина/её друг) — камера наезжает и центрирует его, ветка
|
||||||
|
// раскрывается, путь назад к Ивану горит, остальное затемняется (0.25). Повтор по нему — всплыть.
|
||||||
|
graph.diveTo(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// обычный режим: перецентрирование на выбранного человека (+ трек прохождения)
|
||||||
|
const from = centerLogin;
|
||||||
|
centerLogin = node.login || node.id;
|
||||||
|
graph.setModel(buildLabModel(centerLogin, deepMode, from));
|
||||||
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
||||||
},
|
},
|
||||||
onCenterTap: (node) => window.alert(`Профиль: ${node.name || node.login || node.id}`),
|
// Наведение мышью / касание пальцем — ВРЕМЕННОЕ раскрытие ветки (превью): выплывает под курсором,
|
||||||
|
// втягивается при уходе (если узел не закреплён кликом). Только в режиме «Вселенная».
|
||||||
|
onNodeHover: (node, over) => { if (deepMode) graph.setHover(over ? node : null); },
|
||||||
|
// Изменение пути погружения → перерисовываем хлебные крошки (Иван › Нина › Ада).
|
||||||
|
onDiveChange: (path) => renderBreadcrumb(path),
|
||||||
|
onCenterTap: (node) => {
|
||||||
|
// в паутине тап по корню (Ивану) — глобальный сброс: свернуть все раскрытые ветки
|
||||||
|
if (deepMode) { graph.collapseAll(); return; }
|
||||||
|
window.alert(`Профиль: ${node.name || node.login || node.id}`);
|
||||||
|
},
|
||||||
onNodeLongPress: (node, point) => openNodeMenu({
|
onNodeLongPress: (node, point) => openNodeMenu({
|
||||||
login: node.name || node.login || node.id,
|
login: node.name || node.login || node.id,
|
||||||
relationType: node.relationType,
|
relationType: node.relationType,
|
||||||
@ -108,8 +248,7 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
|
// Панель фильтров слоёв — тот же оверлей-стиль (.fg-filter-bar), что и в network-view.
|
||||||
const filterBar = document.createElement('div');
|
const filterBar = document.createElement('div');
|
||||||
filterBar.className = 'fg-filter-bar';
|
filterBar.className = 'fg-filter-bar';
|
||||||
// Не даём нажатию на чип «провалиться» в сцену: иначе движок делает setPointerCapture на stage,
|
// Не даём нажатию на чип «провалиться» в сцену (иначе сцена перехватит указатель и «съест» click).
|
||||||
// а захват указателя перенаправляет нативный click со сцены — и кнопка фильтра не срабатывает.
|
|
||||||
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
filterBar.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
FILTER_ORDER.forEach((key) => {
|
FILTER_ORDER.forEach((key) => {
|
||||||
const chip = document.createElement('button');
|
const chip = document.createElement('button');
|
||||||
@ -120,8 +259,79 @@ export function renderNetworkLab({ navigate }) {
|
|||||||
filterChips[key] = chip;
|
filterChips[key] = chip;
|
||||||
filterBar.append(chip);
|
filterBar.append(chip);
|
||||||
});
|
});
|
||||||
|
// Переключатель прототипа «Вселенная» (глубина 2-3 уровней) — отдельный чип.
|
||||||
|
const deepChip = document.createElement('button');
|
||||||
|
deepChip.type = 'button';
|
||||||
|
deepChip.className = 'fg-filter-chip fg-deep-chip';
|
||||||
|
deepChip.textContent = '🌌 Вселенная';
|
||||||
|
deepChip.addEventListener('click', () => {
|
||||||
|
deepMode = !deepMode;
|
||||||
|
deepChip.classList.toggle('is-active', deepMode);
|
||||||
|
graph.setModel(buildLabModel(centerLogin, deepMode));
|
||||||
|
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
||||||
|
});
|
||||||
|
filterBar.append(deepChip);
|
||||||
stage.append(filterBar);
|
stage.append(filterBar);
|
||||||
|
|
||||||
|
// --- Поиск + телепорт камеры: ввёл имя → камера летит к узлу (dive в «Вселенной», иначе перецентр) ---
|
||||||
|
const searchWrap = document.createElement('div');
|
||||||
|
searchWrap.className = 'fg-search';
|
||||||
|
searchWrap.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
|
const searchIco = document.createElement('span');
|
||||||
|
searchIco.className = 'fg-search-ico';
|
||||||
|
searchIco.textContent = '🔍';
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.type = 'search';
|
||||||
|
searchInput.placeholder = 'Найти человека…';
|
||||||
|
searchInput.setAttribute('aria-label', 'Поиск по имени');
|
||||||
|
function doSearch() {
|
||||||
|
const hit = graph.findNode(searchInput.value);
|
||||||
|
if (!hit) return;
|
||||||
|
if (deepMode) {
|
||||||
|
graph.diveTo({ id: hit.id }); // камера летит и центрирует найденного
|
||||||
|
} else {
|
||||||
|
const from = centerLogin;
|
||||||
|
centerLogin = hit.id;
|
||||||
|
graph.setModel(buildLabModel(centerLogin, deepMode, from));
|
||||||
|
if (activeFilter !== 'all') graph.setFilter(FILTERS[activeFilter].pred);
|
||||||
|
}
|
||||||
|
searchInput.blur();
|
||||||
|
}
|
||||||
|
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
|
||||||
|
searchWrap.append(searchIco, searchInput);
|
||||||
|
stage.append(searchWrap);
|
||||||
|
|
||||||
|
// --- Хлебные крошки: стек погружений (Иван › Нина › Ада); клик по крошке — навигация назад ---
|
||||||
|
breadcrumbEl = document.createElement('div');
|
||||||
|
breadcrumbEl.className = 'fg-breadcrumb';
|
||||||
|
breadcrumbEl.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||||
|
stage.append(breadcrumbEl);
|
||||||
|
// hoisted-функция: на неё ссылается onDiveChange (вызывается уже после монтирования UI)
|
||||||
|
function renderBreadcrumb(path) {
|
||||||
|
if (!breadcrumbEl) return;
|
||||||
|
breadcrumbEl.innerHTML = '';
|
||||||
|
const open = Array.isArray(path) && path.length > 1; // показываем только при активном погружении
|
||||||
|
breadcrumbEl.classList.toggle('is-open', open);
|
||||||
|
if (!open) return;
|
||||||
|
path.forEach((p, i) => {
|
||||||
|
if (i) { const sep = document.createElement('span'); sep.className = 'fg-crumb-sep'; sep.textContent = '›'; breadcrumbEl.append(sep); }
|
||||||
|
const c = document.createElement('button');
|
||||||
|
c.type = 'button';
|
||||||
|
c.className = `fg-crumb${i === path.length - 1 ? ' is-last' : ''}`;
|
||||||
|
c.textContent = p.name;
|
||||||
|
if (i < path.length - 1) {
|
||||||
|
c.addEventListener('click', () => { if (i === 0 || p.isFocus) graph.collapseAll(); else graph.diveTo({ id: p.id }); });
|
||||||
|
}
|
||||||
|
breadcrumbEl.append(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автопроверки: ТОЛЬКО при ?fgtest — экспонируем граф и прогоняем self-test (результат в консоль).
|
||||||
|
if (typeof window !== 'undefined' && String(location.search).includes('fgtest')) {
|
||||||
|
window.__fg = graph;
|
||||||
|
import('./selftest.js').then((m) => m.runNetworkSelfTest(graph, deepChip)).catch((e) => console.error('[fg-selftest] не загрузился', e));
|
||||||
|
}
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
graph.destroy();
|
graph.destroy();
|
||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
appScreenEl?.classList.remove('network-scroll-lock');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user