Уточнён термин close friend и улучшен UX связей

Что сделано:\n- В UI возвращён термин «Близкий друг» без пометки про «друга».\n- На графе связей добавлено меню узла с двумя действиями: «Показать информацию» и «Показать связи» (перенос узла в центр).\n- В модалке добавления связи реализован автопоиск логинов: Enter или пауза 2 секунды, до 5 подсказок, выбор кликом.\n- Добавлены стили для меню узла и списка подсказок.\n- В коде добавлены явные пояснения и alias-константы close friend (без изменения кодов 10/11 и логики):\n  CONNECTION_CLOSE_FRIEND / CONNECTION_UNCLOSE_FRIEND.\n- Обработчики чтения/записи связей переключены на alias close friend для лучшей читаемости.
This commit is contained in:
AidarKC
2026-04-17 21:18:03 +03:00
parent 4a92a7fa22
commit 30fcde5744
10 changed files with 213 additions and 21 deletions
+15 -5
View File
@@ -366,7 +366,7 @@ export function render({ navigate }) {
const legend = document.createElement('div');
legend.className = 'network-legend';
legend.innerHTML = `
<span><i class="legend-line friend"></i> Друзья</span>
<span><i class="legend-line friend"></i> Близкие друзья</span>
<span><i class="legend-line relative"></i> Родственники</span>
<span><i class="legend-arrow"></i> Односторонняя связь</span>
`;
@@ -386,7 +386,12 @@ export function render({ navigate }) {
closeNodeMenu();
const menu = document.createElement('div');
menu.className = 'node-menu card';
menu.innerHTML = '<button class="ghost-btn" type="button">Показать информацию о пользователе</button>';
menu.innerHTML = `
<div class="node-menu-actions">
<button class="ghost-btn" type="button" data-menu-action="show-info">Показать информацию</button>
<button class="ghost-btn" type="button" data-menu-action="show-graph">Показать связи</button>
</div>
`;
const rect = node.getBoundingClientRect();
const boardRect = board.getBoundingClientRect();
@@ -396,11 +401,16 @@ export function render({ navigate }) {
menu.style.left = `${Math.max(8, Math.min(x - 120, boardRect.width - 248))}px`;
menu.style.top = `${Math.max(8, Math.min(y, boardRect.height - 58))}px`;
const btn = menu.querySelector('button');
btn?.addEventListener('click', () => {
const infoBtn = menu.querySelector('[data-menu-action="show-info"]');
const graphBtn = menu.querySelector('[data-menu-action="show-graph"]');
infoBtn?.addEventListener('click', () => {
navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`);
closeNodeMenu();
});
graphBtn?.addEventListener('click', async () => {
closeNodeMenu();
await load(login);
});
board.append(menu);
activeMenu = menu;
@@ -485,7 +495,7 @@ export function render({ navigate }) {
redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges);
requestAnimationFrame(() => redrawEdges());
note.textContent = 'Тап: информация о пользователе. Долгое нажатие: сделать узел центром. Линия = взаимно, стрелка = в одну сторону.';
note.textContent = 'Тап по узлу: меню «Показать информацию» или «Показать связи». Долгое нажатие: сделать узел центром.';
} catch (error) {
if (requestId !== loadSeq) return;
note.textContent = `Ошибка загрузки связей: ${error?.message || 'unknown'}`;
+132 -6
View File
@@ -305,7 +305,6 @@ export function render({ navigate }) {
const uniqueContacts = Array.from(new Set(preparedContacts.map((item) => item.toLowerCase())))
.map((key) => preparedContacts.find((item) => item.toLowerCase() === key))
.filter(Boolean);
const datalistId = `profile-relative-contacts-${Math.random().toString(16).slice(2, 9)}`;
root.innerHTML = `
<div class="modal" id="profile-add-relative-modal">
@@ -324,11 +323,11 @@ export function render({ navigate }) {
type="text"
maxlength="30"
placeholder="Введите логин пользователя"
list="${datalistId}"
/>
<datalist id="${datalistId}">
${uniqueContacts.map((contact) => `<option value="${escapeHtml(contact)}"></option>`).join('')}
</datalist>
<div class="meta-muted profile-relative-search-meta" id="profile-relative-search-meta">
Начните вводить логин. Поиск запустится через 2 секунды паузы или по Enter.
</div>
<div class="profile-relative-search-suggest" id="profile-relative-search-suggest" hidden></div>
<div class="meta-muted inline-error" id="profile-relative-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="profile-relative-cancel" type="button">Отмена</button>
@@ -345,6 +344,8 @@ export function render({ navigate }) {
const submitEl = root.querySelector('#profile-relative-submit');
const cancelEl = root.querySelector('#profile-relative-cancel');
const errorEl = root.querySelector('#profile-relative-error');
const searchMetaEl = root.querySelector('#profile-relative-search-meta');
const suggestEl = root.querySelector('#profile-relative-search-suggest');
if (!(modal instanceof HTMLElement) || !(kindEl instanceof HTMLSelectElement) || !(loginEl instanceof HTMLInputElement)) {
root.innerHTML = '';
@@ -352,7 +353,103 @@ export function render({ navigate }) {
return;
}
let closed = false;
let searchTimerId = 0;
let searchSeq = 0;
const clearSearchTimer = () => {
if (!searchTimerId) return;
window.clearTimeout(searchTimerId);
searchTimerId = 0;
};
const uniqueCaseInsensitive = (list) => {
const out = [];
const seen = new Set();
(Array.isArray(list) ? list : []).forEach((item) => {
const value = String(item || '').trim();
if (!value) return;
const key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(value);
});
return out;
};
const renderSuggestions = (list) => {
if (!(suggestEl instanceof HTMLElement)) return;
const values = uniqueCaseInsensitive(list).slice(0, 5);
if (!values.length) {
suggestEl.hidden = true;
suggestEl.innerHTML = '';
return;
}
suggestEl.hidden = false;
suggestEl.innerHTML = values.map((value) => (
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
)).join('');
};
const searchContactsFallback = (prefix) => {
const query = String(prefix || '').trim().toLowerCase();
if (!query) return [];
return uniqueContacts
.filter((entry) => entry.toLowerCase().startsWith(query))
.slice(0, 5);
};
const runSearch = async ({ withErrorMessage = false } = {}) => {
const prefix = String(loginEl.value || '').trim();
if (!prefix) {
renderSuggestions([]);
if (searchMetaEl) {
searchMetaEl.textContent = 'Начните вводить логин. Поиск запустится через 2 секунды паузы или по Enter.';
}
return;
}
if (searchMetaEl) searchMetaEl.textContent = `Поиск пользователей по префиксу «${prefix}»...`;
const reqId = ++searchSeq;
let found = [];
let searchFailed = false;
try {
const payload = await authService.searchUsers(prefix);
found = Array.isArray(payload) ? payload : [];
} catch {
searchFailed = true;
found = [];
}
if (closed || reqId !== searchSeq) return;
const query = prefix.toLowerCase();
const candidateList = uniqueCaseInsensitive([
...found,
...searchContactsFallback(prefix),
])
.filter((entry) => entry.toLowerCase().startsWith(query))
.slice(0, 5);
renderSuggestions(candidateList);
if (candidateList.length > 0) {
if (searchMetaEl) searchMetaEl.textContent = `Найдено ${candidateList.length} пользователей. Выберите пользователя из списка.`;
return;
}
if (searchFailed) {
if (searchMetaEl) searchMetaEl.textContent = 'Не удалось получить список пользователей. Уточните логин вручную.';
if (withErrorMessage && errorEl) errorEl.textContent = 'Ошибка поиска пользователей.';
return;
}
if (searchMetaEl) searchMetaEl.textContent = 'Пользователи с таким префиксом не найдены.';
};
const close = (payload = null) => {
closed = true;
clearSearchTimer();
root.innerHTML = '';
resolve(payload);
};
@@ -376,10 +473,39 @@ export function render({ navigate }) {
});
cancelEl?.addEventListener('click', () => close(null));
submitEl?.addEventListener('click', submit);
suggestEl?.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const button = target.closest('[data-login]');
if (!(button instanceof HTMLElement)) return;
const pickedLogin = String(button.dataset.login || '').trim();
if (!pickedLogin) return;
loginEl.value = pickedLogin;
if (errorEl) errorEl.textContent = '';
if (searchMetaEl) searchMetaEl.textContent = `Выбран пользователь «${pickedLogin}». Нажмите «Продолжить».`;
renderSuggestions([]);
});
loginEl.addEventListener('input', () => {
if (errorEl) errorEl.textContent = '';
clearSearchTimer();
const value = String(loginEl.value || '').trim();
if (!value) {
renderSuggestions([]);
if (searchMetaEl) {
searchMetaEl.textContent = 'Начните вводить логин. Поиск запустится через 2 секунды паузы или по Enter.';
}
return;
}
if (searchMetaEl) searchMetaEl.textContent = 'Ожидание паузы 2 секунды для поиска...';
searchTimerId = window.setTimeout(() => {
runSearch({ withErrorMessage: false });
}, 2000);
});
loginEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
clearSearchTimer();
runSearch({ withErrorMessage: true });
}
});
window.setTimeout(() => loginEl.focus(), 0);
+2
View File
@@ -45,6 +45,8 @@ const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
const CREATE_CHANNEL_BODY_VERSION = 2;
const CONNECTION_SUBTYPES = Object.freeze({
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
close_friend: { on: 10, off: 11 },
friend: { on: 10, off: 11 },
contact: { on: 20, off: 21 },
follow: { on: 30, off: 31 },