diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js
index b831b71..8b840bc 100644
--- a/shine-UI/js/pages/network-view.js
+++ b/shine-UI/js/pages/network-view.js
@@ -366,7 +366,7 @@ export function render({ navigate }) {
const legend = document.createElement('div');
legend.className = 'network-legend';
legend.innerHTML = `
- Друзья
+ Близкие друзья
Родственники
Односторонняя связь
`;
@@ -386,7 +386,12 @@ export function render({ navigate }) {
closeNodeMenu();
const menu = document.createElement('div');
menu.className = 'node-menu card';
- menu.innerHTML = '';
+ menu.innerHTML = `
+
+ `;
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'}`;
diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js
index 0bfa2f1..fd1a8a2 100644
--- a/shine-UI/js/pages/profile-view.js
+++ b/shine-UI/js/pages/profile-view.js
@@ -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 = `
@@ -324,11 +323,11 @@ export function render({ navigate }) {
type="text"
maxlength="30"
placeholder="Введите логин пользователя"
- list="${datalistId}"
/>
-
+
+ Начните вводить логин. Поиск запустится через 2 секунды паузы или по Enter.
+
+
@@ -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) => (
+ ``
+ )).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);
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index e97c4a6..5b8923e 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -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 },
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index aa7f7a4..a6f4254 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -271,6 +271,41 @@
box-shadow: 0 0 0 3px rgba(65, 174, 255, 0.2);
}
+.profile-relative-search-meta {
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.profile-relative-search-suggest {
+ max-height: 180px;
+ overflow-y: auto;
+ border: 1px solid rgba(150, 174, 224, 0.3);
+ border-radius: 12px;
+ background: rgba(9, 18, 35, 0.94);
+ padding: 6px;
+}
+
+.profile-relative-suggest-item {
+ width: 100%;
+ text-align: left;
+ border: 1px solid rgba(132, 162, 224, 0.28);
+ border-radius: 9px;
+ background: rgba(16, 30, 56, 0.78);
+ color: #e5eeff;
+ padding: 8px 10px;
+ cursor: pointer;
+ margin-bottom: 6px;
+}
+
+.profile-relative-suggest-item:last-child {
+ margin-bottom: 0;
+}
+
+.profile-relative-suggest-item:hover {
+ border-color: rgba(216, 178, 95, 0.52);
+ color: #f3dca8;
+}
+
.profile-param-time {
font-size: 12px;
}
@@ -966,6 +1001,11 @@ textarea.input {
padding: 8px;
}
+.node-menu-actions {
+ display: grid;
+ gap: 8px;
+}
+
.user-relations-list {
gap: 6px;
}
diff --git a/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java
index 4b983eb..cad0147 100644
--- a/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java
+++ b/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java
@@ -73,11 +73,16 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
- /** Добавить в друзья. */
+ /** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10;
- /** Удалить из друзей. */
+ /** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11;
+ /** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
+ public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
+ /** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
+ public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
+
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
/** Удалить из контактов. */
diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
index b89ed1a..37c0f68 100644
--- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
+++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
@@ -39,8 +39,11 @@ public final class DatabaseInitializer {
public static final short REACTION_UNLIKE = 2;
/* ===================== CONNECTION (msg_type=3) ===================== */
+ // Близкий друг (close friend). Исторически в коде использовалось имя FRIEND.
public static final short CONNECTION_FRIEND = 10;
public static final short CONNECTION_UNFRIEND = 11;
+ public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
+ public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
public static final short CONNECTION_CONTACT = 20;
public static final short CONNECTION_UNCONTACT = 21;
diff --git a/shine-server-db/src/main/java/shine/db/MsgSubType.java b/shine-server-db/src/main/java/shine/db/MsgSubType.java
index 5f63269..5c92f91 100644
--- a/shine-server-db/src/main/java/shine/db/MsgSubType.java
+++ b/shine-server-db/src/main/java/shine/db/MsgSubType.java
@@ -40,16 +40,22 @@ public final class MsgSubType {
/* ===================== CONNECTION (msg_type=3) ===================== */
/**
* Совпадает с ConnectionBody:
- * SET: FRIEND=10, CONTACT=20, FOLLOW=30, PARENT=50, CHILD=52, SIBLING=54
- * UNSET: UNFRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNPARENT=51, UNCHILD=53, UNSIBLING=55
+ * SET: CLOSE_FRIEND(=FRIEND)=10, CONTACT=20, FOLLOW=30, PARENT=50, CHILD=52, SIBLING=54
+ * UNSET: UNCLOSE_FRIEND(=UNFRIEND)=11, UNCONTACT=21, UNFOLLOW=31, UNPARENT=51, UNCHILD=53, UNSIBLING=55
*/
- /** Добавить в друзья. */
+ /** Добавить в близкие друзья (close friend). */
public static final short CONNECTION_FRIEND = 10;
- /** Удалить из друзей. */
+ /** Удалить из близких друзей (close friend). */
public static final short CONNECTION_UNFRIEND = 11;
+ /** Alias: добавить в close friend (то же значение, что CONNECTION_FRIEND). */
+ public static final short CONNECTION_CLOSE_FRIEND = CONNECTION_FRIEND;
+
+ /** Alias: удалить из close friend (то же значение, что CONNECTION_UNFRIEND). */
+ public static final short CONNECTION_UNCLOSE_FRIEND = CONNECTION_UNFRIEND;
+
/** Добавить в контакты. */
public static final short CONNECTION_CONTACT = 20;
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java
index d15bcbf..9a7350a 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_AddCloseFriend_Handler.java
@@ -90,7 +90,7 @@ public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
- ps.setInt(2, MsgSubType.CONNECTION_FRIEND);
+ ps.setInt(2, MsgSubType.CONNECTION_CLOSE_FRIEND);
ps.setString(3, toLogin);
ps.setString(4, toBchName);
ps.setInt(5, 0);
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java
index c105b9e..1c6a476 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetFriendsLists_Handler.java
@@ -67,7 +67,7 @@ public class Net_GetFriendsLists_Handler implements JsonMessageHandler {
);
}
- int relType = (int) MsgSubType.CONNECTION_FRIEND;
+ int relType = (int) MsgSubType.CONNECTION_CLOSE_FRIEND;
// 2) Два списка (логины канонические)
List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType);
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
index b8f6272..8a9df4f 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
@@ -37,8 +37,8 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
- List outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
- List inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
+ List outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
+ List inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CLOSE_FRIEND);
List outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
List inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
List outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);