ЛС: редизайн списка (фото-аватары, галочка/значок связи у имени, попап-цепочка→профиль) + demo-чат и lab-маршрут
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aea6bbcb0e
commit
6904ac8b7c
BIN
shine-UI/assets/demo-avatars/u1.jpg
Normal file
BIN
shine-UI/assets/demo-avatars/u1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
shine-UI/assets/demo-avatars/u2.jpg
Normal file
BIN
shine-UI/assets/demo-avatars/u2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
shine-UI/assets/demo-avatars/u3.jpg
Normal file
BIN
shine-UI/assets/demo-avatars/u3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
BIN
shine-UI/assets/demo-avatars/u4.jpg
Normal file
BIN
shine-UI/assets/demo-avatars/u4.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
BIN
shine-UI/assets/demo-avatars/u6.jpg
Normal file
BIN
shine-UI/assets/demo-avatars/u6.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
shine-UI/assets/demo-avatars/u7.jpg
Normal file
BIN
shine-UI/assets/demo-avatars/u7.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
74
shine-UI/docs/design/messages-list-v2.md
Normal file
74
shine-UI/docs/design/messages-list-v2.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# Личные сообщения (messages-list) — дизайн v2
|
||||||
|
|
||||||
|
Экран ЛС — **списочная форма экрана «Связи»**: тип отношения читается через цвет
|
||||||
|
обода/ауры аватара и один правый статус. Тёмный космический фон + золотой header.
|
||||||
|
|
||||||
|
> Reference source: owner-approved chat visual reference; image asset is not yet stored in repository.
|
||||||
|
|
||||||
|
## Источник данных
|
||||||
|
- **Demo/lab:** мок `js/mock-data.js` → `directMessages` (семантические поля, цвет не хранится).
|
||||||
|
- **Прод:** те же поля придут из реальных relations (`relationFlagsForTarget` / `shineConfirmed` / `shine`).
|
||||||
|
- **Маршруты:**
|
||||||
|
- `/messages-list` — защищённый (требует сессии).
|
||||||
|
- `/messages-list/lab` — гость-демо (мок, без сети/WS, пригоден для скриншотов).
|
||||||
|
|
||||||
|
## Семантика → визуал
|
||||||
|
Решает **только** `js/pages/messages/dm-visual-resolver.js`. В данных цвет НЕ хранится.
|
||||||
|
|
||||||
|
Поля сообщения: `relationType` (contact|friend|family), `relationRole`, `isShining`,
|
||||||
|
`isConfirmed`, `hasActiveLink`, `unreadCount`, `preview`. (`toneOverride` — только для теста.)
|
||||||
|
|
||||||
|
### Цвета (значение)
|
||||||
|
| Цвет | Токен | Значение |
|
||||||
|
|------|-------|----------|
|
||||||
|
| violet | `--rel-contact` `#8C63FF` | обычный контакт (дефолт) |
|
||||||
|
| gold | `--rel-family` `#F0B82E` | семья / близкий круг / важная связь |
|
||||||
|
| celestial | `--rel-shining` `#68D8FF` | сияющий |
|
||||||
|
| emerald | `--rel-link` `#19E58A` | ТОЛЬКО активный статус «Связь» |
|
||||||
|
|
||||||
|
Обод аватара: `isShining → celestial; иначе family → gold; иначе → violet`.
|
||||||
|
**«Подтверждён» НЕ красит обод золотым** (золото = семья; подтверждение — правый статус).
|
||||||
|
|
||||||
|
### Приоритет правого статуса
|
||||||
|
`hasActiveLink → «Связь» (emerald)` > `isConfirmed → «Подтверждён» (gold shield)` > ничего.
|
||||||
|
На карточке максимум ОДИН главный статус.
|
||||||
|
|
||||||
|
### Unread
|
||||||
|
Отдельная **violet/cool сфера** (НЕ изумруд). Только при `>0`; `1–99`, далее `99+`.
|
||||||
|
Идёт после статуса, перед chevron.
|
||||||
|
|
||||||
|
## Матрица состояний (demo-мок покрывает все)
|
||||||
|
| # | relationType | shining | confirmed | link | unread | Обод | Правый статус | Бейдж |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| M01 | contact | – | ✓ | – | 0 | violet | 🛡 Подтверждён | – |
|
||||||
|
| M02 | contact | – | – | ✓ | 2 | violet | 🔗 Связь | 2 |
|
||||||
|
| M03 | contact | ✓ | – | ✓ | 5 | celestial | 🔗 Связь | 5 |
|
||||||
|
| M04 | contact | – | – | – | 0 | violet | — | – |
|
||||||
|
| M05 | family | – | ✓ | – | 0 | gold | 🛡 Подтверждён | – |
|
||||||
|
| M06 | family | – | ✓ | ✓ | 1 | gold | 🔗 Связь (приоритет link>confirmed) | 1 |
|
||||||
|
|
||||||
|
## Размеры
|
||||||
|
- Карточка: `min-height 92px`, `radius 26px`.
|
||||||
|
- Зазор списка: `8px` (flex-column).
|
||||||
|
- Аватар-обод `.dm-av`: `56px` (фото/инициалы — 50px внутри).
|
||||||
|
- Капсула «Связь»: высота `32px`, radius `16px`, изумрудный бордер, почти прозрачный fill.
|
||||||
|
- Header: grid `1fr auto 1fr` (бренд слева / title строго по центру / «+» справа), title `18px`.
|
||||||
|
|
||||||
|
## Сияющая сфера — связь с «Связями» (обязательно)
|
||||||
|
DM-сияние НЕ изобретает свой эффект, а **повторяет язык сияющего узла графа**:
|
||||||
|
- те же общие keyframes из `styles/network-graph.css`: `fg-shine-glow` (пульс box-shadow)
|
||||||
|
+ `fg-shine-halo` (дыхание ореола: scale/opacity);
|
||||||
|
- та же небесная палитра и тот же rim `rgba(150,240,255,0.62)`;
|
||||||
|
- тот же радиальный ореол (те же стопы градиента), `inset: -12px` — как у узла графа
|
||||||
|
(узел 58px ↔ аватар 56px, scale ≈ 1, отдельный масштабный коэффициент не нужен);
|
||||||
|
- `filter: blur(3.4px)` ≡ `feGaussianBlur stdDeviation="3.4"` SVG-фильтра `#fg-shine-glow`
|
||||||
|
графа. CSS-blur используется потому, что SVG-фильтр объявлен только на странице «Связи»;
|
||||||
|
- `prefers-reduced-motion` → анимации выключаются.
|
||||||
|
|
||||||
|
Разрешён только controlled scale factor; никаких отдельных hardcoded-параметров,
|
||||||
|
если они уже существуют в визуальном языке «Связей».
|
||||||
|
|
||||||
|
## Фон
|
||||||
|
Фон `.dm-screen` (`#05070A` + орбы `dm-orbs-drift`) — утверждённая база, **НЕ меняется**.
|
||||||
|
Все эффекты редизайна ограничены `.dm-*` (карточка, обод аватара, статус, бейдж, header, «+»).
|
||||||
|
Критерий: если скрыть карточки/аватары/header/nav/статусы — фон остаётся прежним.
|
||||||
@ -63,6 +63,7 @@ import * as appLogView from './pages/app-log-view.js';
|
|||||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||||
import * as messagesList from './pages/messages-list.js';
|
import * as messagesList from './pages/messages-list.js';
|
||||||
|
import * as dmLabChat from './pages/messages/dm-lab-chat.js';
|
||||||
import * as contactSearchView from './pages/contact-search-view.js';
|
import * as contactSearchView from './pages/contact-search-view.js';
|
||||||
import * as chatView from './pages/chat-view.js';
|
import * as chatView from './pages/chat-view.js';
|
||||||
import * as userProfileView from './pages/user-profile-view.js';
|
import * as userProfileView from './pages/user-profile-view.js';
|
||||||
@ -105,6 +106,7 @@ const routes = {
|
|||||||
'pwa-diagnostics-view': pwaDiagnosticsView,
|
'pwa-diagnostics-view': pwaDiagnosticsView,
|
||||||
'solana-users-init-view': solanaUsersInitView,
|
'solana-users-init-view': solanaUsersInitView,
|
||||||
'messages-list': messagesList,
|
'messages-list': messagesList,
|
||||||
|
'dm-lab-chat': dmLabChat,
|
||||||
'contact-search-view': contactSearchView,
|
'contact-search-view': contactSearchView,
|
||||||
'chat-view': chatView,
|
'chat-view': chatView,
|
||||||
user: userProfileView,
|
user: userProfileView,
|
||||||
@ -151,6 +153,7 @@ const GUEST_ALLOWED_PAGES = new Set([
|
|||||||
'channel-thread-view',
|
'channel-thread-view',
|
||||||
'user',
|
'user',
|
||||||
'contact-search-view',
|
'contact-search-view',
|
||||||
|
'dm-lab-chat', // demo-чат лаборатории ЛС (мок, без сессии)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
setClientErrorTransport((payload) => authService.reportClientUiError(payload));
|
||||||
@ -687,7 +690,10 @@ function renderApp() {
|
|||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view');
|
||||||
|
|
||||||
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId)) {
|
// Гостю доступен ТОЛЬКО demo-маршрут ЛС (/messages-list/lab) — для оффлайн-проверки редизайна без сессии.
|
||||||
|
// Реальный /messages-list остаётся защищённым (mode пустой → редирект на start-view).
|
||||||
|
const isDmDemo = pageId === 'messages-list' && route.params?.mode === 'lab';
|
||||||
|
if (!state.session.isAuthorized && !PRE_AUTH_PAGES.includes(pageId) && !GUEST_ALLOWED_PAGES.has(pageId) && !isDmDemo) {
|
||||||
navigate('start-view');
|
navigate('start-view');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,10 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
|
||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import { state } from '../state.js';
|
||||||
getChatMessages,
|
|
||||||
isSessionInvalidError,
|
|
||||||
setContacts,
|
|
||||||
state,
|
|
||||||
terminateCurrentSession,
|
|
||||||
} from '../state.js';
|
|
||||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
|
||||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||||
|
import { resolveDmVisualState } from './messages/dm-visual-resolver.js';
|
||||||
|
import { getPreview, getUnread } from './messages/dm-lab-store.js';
|
||||||
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||||
const dmAvatarSnapshotCache = new Map();
|
const dmAvatarSnapshotCache = new Map();
|
||||||
@ -36,24 +31,36 @@ async function loadDmAvatarSnapshot(login) {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDmAvatar(login) {
|
// Аватар: реальное фото через renderUserAvatar (lazy), при отсутствии — аккуратные инициалы.
|
||||||
|
// Ошибка загрузки снапшота не ломает карточку (catch → инициалы остаются).
|
||||||
|
function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
const title = cleanLogin ? `Профиль ${cleanLogin}` : '';
|
||||||
const avatarEl = renderUserAvatar({
|
// Инициалы для fallback берём из имени диалога («Марина К.» → «МК»), а не из служебного id.
|
||||||
login: cleanLogin || 'unknown',
|
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
size: 'small',
|
const firstName = parts[0] || '';
|
||||||
title,
|
const lastName = parts[1] || '';
|
||||||
});
|
const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', firstName, lastName, size: 'small', title });
|
||||||
if (!cleanLogin) return avatarEl;
|
// Тестовое фото (demo, «как в Связях»): pravatar поверх инициалов через .has-image; офлайн/ошибка → инициалы.
|
||||||
|
const photoUrl = String(photo || '').trim();
|
||||||
|
if (photoUrl) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
// eager (не lazy): аватары у верха списка; lazy в некоторых движках/headless не догружает фото.
|
||||||
|
img.alt = ''; img.loading = 'eager'; img.decoding = 'async';
|
||||||
|
img.addEventListener('load', () => avatarEl.classList.add('has-image'));
|
||||||
|
img.addEventListener('error', () => { try { img.remove(); } catch (e) { /* остаются инициалы */ } avatarEl.classList.remove('has-image'); });
|
||||||
|
img.src = photoUrl;
|
||||||
|
avatarEl.append(img);
|
||||||
|
}
|
||||||
|
// upgrade=false (demo/lab) → остаёмся на тестовом фото/инициалах, без сетевого запроса профиля.
|
||||||
|
if (!cleanLogin || !upgrade) return avatarEl;
|
||||||
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => {
|
||||||
if (!avatarEl.isConnected) return;
|
if (!avatarEl.isConnected) return;
|
||||||
const upgraded = renderUserAvatar({
|
const upgraded = renderUserAvatar({
|
||||||
login: cleanLogin,
|
login: cleanLogin,
|
||||||
|
firstName, lastName,
|
||||||
avatar: snapshot?.avatar?.txId
|
avatar: snapshot?.avatar?.txId
|
||||||
? {
|
? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
|
||||||
ar: String(snapshot.avatar.txId || '').trim(),
|
|
||||||
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
size: 'small',
|
size: 'small',
|
||||||
title,
|
title,
|
||||||
@ -64,149 +71,162 @@ function createDmAvatar(login) {
|
|||||||
return avatarEl;
|
return avatarEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChatRowTime(ts) {
|
// Иконки: галочка-подтверждён (gold, БЕЗ текста), цепочка (значок «связь через кого»), шеврон.
|
||||||
const value = Number(ts || 0);
|
const SVG_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8.4 12.4l2.5 2.5 4.7-5.1"/></svg>';
|
||||||
if (!Number.isFinite(value) || value <= 0) return '-';
|
const SVG_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.5 13a4 4 0 0 0 5.66 0l1.84-1.84a4 4 0 1 0-5.66-5.66l-1 1"/><path d="M13.5 11a4 4 0 0 0-5.66 0L6 12.84a4 4 0 1 0 5.66 5.66l1-1"/></svg>';
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
const SVG_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>';
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate, route }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack dm-screen dm-list-screen';
|
screen.className = 'dm-screen dm-list-screen';
|
||||||
|
|
||||||
screen.append(
|
// demo/lab: гость без сессии (маршрут /messages-list/lab или ?demo=1). В demo НЕ ходим в сеть за фото
|
||||||
renderHeader({
|
// профиля — иначе висящие listUserParams не дают сети уйти в idle и ломают скриншоты (остаются initials).
|
||||||
title: 'Личные сообщения',
|
const isDemo = route?.params?.mode === 'lab'
|
||||||
leftLabel: String(state.session.login || '').trim(),
|
|| (typeof window !== 'undefined' && /[?&]demo=1(?:&|$)/.test(window.location.search || ''));
|
||||||
rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }],
|
|
||||||
}),
|
// Слева сверху — имя владельца аккаунта (на проде реальный логин; в demo — заглушка, НЕ «shine»,
|
||||||
);
|
// чтобы не дублировать центральный бренд «Shine»).
|
||||||
|
const login = String(state.session.login || '').trim() || 'Aidar007';
|
||||||
|
|
||||||
|
// DM-шапка: grid 1fr auto 1fr (бренд слева, title строго по центру, «+» справа).
|
||||||
|
const head = document.createElement('header');
|
||||||
|
head.className = 'dm-head';
|
||||||
|
head.innerHTML = `
|
||||||
|
<div class="dm-head-brand">
|
||||||
|
<div class="dm-head-hex">${(login[0] || 'A').toUpperCase()}</div>
|
||||||
|
<div class="dm-head-id">
|
||||||
|
<span class="dm-head-name">${login}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="dm-head-title dm-head-shine">Shine</h1>
|
||||||
|
<button type="button" class="dm-head-plus" aria-label="Новый диалог">+</button>
|
||||||
|
`;
|
||||||
|
head.querySelector('.dm-head-plus').addEventListener('click', () => navigate('contact-search-view'));
|
||||||
|
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'dm-divider';
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack dm-list';
|
list.className = 'dm-list';
|
||||||
|
|
||||||
function renderRow(item) {
|
function renderRow(item) {
|
||||||
|
// В demo превью/непрочитанные берём из dm-lab-store (обновляются после отправки/открытия чата).
|
||||||
|
const resolverItem = isDemo ? { ...item, unreadCount: getUnread(item.id) } : item;
|
||||||
|
const v = resolveDmVisualState(resolverItem); // { tone, shining, confirmed, via, unread }
|
||||||
|
const cardVariant = v.tone === 'family' ? ' dm-card--family' : (v.tone === 'shining' ? ' dm-card--shining' : '');
|
||||||
|
const name = item.name || item.id;
|
||||||
|
const preview = (isDemo ? getPreview(item.id, item.preview || item.lastMessage || '') : (item.preview || item.lastMessage || '')) || 'Диалог пока пуст.';
|
||||||
|
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'list-item dm-dialog-card';
|
row.className = `dm-dialog-card${cardVariant}`;
|
||||||
const avatarEl = createDmAvatar(item.id);
|
row.tabIndex = 0;
|
||||||
avatarEl.classList.add('avatar');
|
row.setAttribute('role', 'button');
|
||||||
|
|
||||||
|
// Галочка-подтверждён — у имени, БЕЗ слова «Подтверждён».
|
||||||
|
const checkHtml = v.confirmed ? `<span class="dm-name-check" title="Подтверждён" aria-label="Подтверждён">${SVG_CHECK}</span>` : '';
|
||||||
|
const unreadHtml = v.unread ? `<span class="dm-unread-badge">${v.unread.label}</span>` : '';
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="dm-row-main">
|
<div class="dm-row-main">
|
||||||
<div class="dm-row-title-wrap">
|
<div class="dm-row-titleline">
|
||||||
<strong class="dm-row-title">${item.name}</strong>
|
<strong class="dm-row-title">${name}</strong>
|
||||||
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
|
${checkHtml}
|
||||||
</div>
|
</div>
|
||||||
<p class="meta-muted dm-row-last-message">${item.lastMessage}</p>
|
<p class="dm-row-last-message">${preview}</p>
|
||||||
</div>
|
|
||||||
<div class="dm-row-meta-col">
|
|
||||||
${item.unread ? `<span class="unread">${item.unread}</span>` : '<span></span>'}
|
|
||||||
<span class="meta-muted dm-row-time">${item.time}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dm-row-meta">${unreadHtml}<span class="dm-chevron">${SVG_CHEVRON}</span></div>
|
||||||
`;
|
`;
|
||||||
row.prepend(avatarEl);
|
|
||||||
row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(item.id)}`));
|
// Значок «связь через кого» (вместо слова «Связь»): цепочка + мини-аватар первого посредника, сразу за галочкой.
|
||||||
|
// Тап (stopPropagation) → попап пути «Ты → … → он»; сама карточка по-прежнему открывает чат.
|
||||||
|
if (v.via && v.via.length) {
|
||||||
|
const titleline = row.querySelector('.dm-row-titleline');
|
||||||
|
const viaBtn = document.createElement('button');
|
||||||
|
viaBtn.type = 'button';
|
||||||
|
viaBtn.className = 'dm-via';
|
||||||
|
viaBtn.setAttribute('aria-label', `Связь через ${v.via.map((x) => x.name).join(', ')}`);
|
||||||
|
viaBtn.innerHTML = `<span class="dm-via-icon">${SVG_LINK}</span>`; // только иконка (без мини-аватара/«+N»)
|
||||||
|
titleline.appendChild(viaBtn);
|
||||||
|
|
||||||
|
// Попап пути: Ты → …посредники… → целевой. Каждый узел — фото-аватар + имя.
|
||||||
|
// Тап по человеку (кроме «Ты») → его профиль (makeProfileRoute), чтобы отследить цепочку.
|
||||||
|
const pop = document.createElement('div');
|
||||||
|
pop.className = 'dm-via-path';
|
||||||
|
const chain = [
|
||||||
|
{ name: 'Ты', me: true },
|
||||||
|
...v.via.map((x) => ({ name: x.name, login: x.login || '', photo: x.photo || '' })),
|
||||||
|
{ name, login: item.login || item.id, photo: item.photo || '' },
|
||||||
|
];
|
||||||
|
chain.forEach((node, i) => {
|
||||||
|
if (i) {
|
||||||
|
const arr = document.createElement('span');
|
||||||
|
arr.className = 'dm-via-arrow';
|
||||||
|
arr.textContent = '→';
|
||||||
|
pop.appendChild(arr);
|
||||||
|
}
|
||||||
|
const clickable = !node.me && Boolean(node.login);
|
||||||
|
const el = document.createElement(clickable ? 'button' : 'span');
|
||||||
|
el.className = 'dm-via-node';
|
||||||
|
const ava = document.createElement('span');
|
||||||
|
ava.className = 'dm-via-node-ava';
|
||||||
|
if (node.me) {
|
||||||
|
const me = document.createElement('span');
|
||||||
|
me.className = 'dm-via-me';
|
||||||
|
me.textContent = (login[0] || 'A').toUpperCase();
|
||||||
|
ava.appendChild(me);
|
||||||
|
} else {
|
||||||
|
ava.appendChild(createDmAvatar(node.login || node.name, { upgrade: false, name: node.name, photo: node.photo }));
|
||||||
|
}
|
||||||
|
const nm = document.createElement('span');
|
||||||
|
nm.className = 'dm-via-node-name';
|
||||||
|
nm.textContent = node.name;
|
||||||
|
el.append(ava, nm);
|
||||||
|
if (clickable) {
|
||||||
|
el.type = 'button';
|
||||||
|
el.addEventListener('click', (e) => { e.stopPropagation(); navigate(makeProfileRoute(node.login)); });
|
||||||
|
}
|
||||||
|
pop.appendChild(el);
|
||||||
|
});
|
||||||
|
row.appendChild(pop);
|
||||||
|
|
||||||
|
const toggle = (e) => { e.stopPropagation(); pop.classList.toggle('is-open'); };
|
||||||
|
viaBtn.addEventListener('click', toggle);
|
||||||
|
viaBtn.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аватар: фото/инициалы без кольца; у сияющего — свечение (класс is-shine).
|
||||||
|
const avWrap = document.createElement('div');
|
||||||
|
avWrap.className = `dm-av dm-av--${v.tone}${v.shining ? ' is-shine' : ''}`;
|
||||||
|
const avatarEl = createDmAvatar(item.id, { upgrade: !isDemo, name, photo: isDemo ? item.photo : '' });
|
||||||
|
avatarEl.classList.add('avatar');
|
||||||
|
avWrap.appendChild(avatarEl);
|
||||||
|
row.prepend(avWrap);
|
||||||
|
|
||||||
|
const go = () => navigate(isDemo
|
||||||
|
? `messages-list/lab/chat/${encodeURIComponent(item.id)}`
|
||||||
|
: `chat-view/${encodeURIComponent(item.id)}`);
|
||||||
|
row.addEventListener('click', go);
|
||||||
|
row.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
|
||||||
|
});
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadList() {
|
// Оффлайн-демо: список из мока directMessages (с семантическими полями).
|
||||||
try {
|
// На проде источник заменяется на реальные relations (relationFlagsForTarget/shineConfirmed/shine) —
|
||||||
const relations = await loadCurrentRelations();
|
// карточки и резолвер не меняются.
|
||||||
const contacts = relations.outContacts || [];
|
const items = Array.isArray(directMessages) ? directMessages : [];
|
||||||
setContacts(contacts);
|
if (!items.length) {
|
||||||
list.innerHTML = '';
|
|
||||||
|
|
||||||
const contactRows = contacts.map((login) => {
|
|
||||||
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
|
|
||||||
const chat = getChatMessages(login);
|
|
||||||
const lastChat = chat[chat.length - 1];
|
|
||||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
|
||||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
|
||||||
return {
|
|
||||||
id: login,
|
|
||||||
name: preview?.name || login,
|
|
||||||
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
|
||||||
time: formatChatRowTime(lastTimeMs),
|
|
||||||
unread,
|
|
||||||
notInContacts: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const allChatIds = Object.keys(state.chats || {})
|
|
||||||
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
|
|
||||||
.filter((id) => (getChatMessages(id) || []).length > 0);
|
|
||||||
|
|
||||||
const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase()));
|
|
||||||
const extraRows = allChatIds
|
|
||||||
.filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
|
|
||||||
.map((login) => {
|
|
||||||
const chat = getChatMessages(login);
|
|
||||||
const lastChat = chat[chat.length - 1];
|
|
||||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
|
||||||
const lastTimeMs = Number(lastChat?.createdAtMs || 0);
|
|
||||||
return {
|
|
||||||
id: login,
|
|
||||||
name: login,
|
|
||||||
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
|
||||||
time: formatChatRowTime(lastTimeMs),
|
|
||||||
unread,
|
|
||||||
notInContacts: true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = [...contactRows, ...extraRows];
|
|
||||||
if (!rows.length) {
|
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'card meta-muted';
|
empty.className = 'card meta-muted';
|
||||||
empty.textContent = 'Пока нет ни контактов, ни сообщений';
|
empty.textContent = 'Пока нет диалогов';
|
||||||
list.append(empty);
|
list.append(empty);
|
||||||
return;
|
} else {
|
||||||
|
items.forEach((item) => list.append(renderRow(item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.forEach((item) => list.append(renderRow(item)));
|
screen.append(head, divider, list);
|
||||||
} catch (error) {
|
|
||||||
if (isSessionInvalidError(error)) {
|
|
||||||
list.innerHTML = '';
|
|
||||||
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'card stack';
|
|
||||||
|
|
||||||
const title = document.createElement('strong');
|
|
||||||
title.textContent = 'Сессия устарела';
|
|
||||||
|
|
||||||
const details = document.createElement('p');
|
|
||||||
details.className = 'meta-muted';
|
|
||||||
details.textContent = 'Ваша сессия больше не действует. Авторизуйтесь заново.';
|
|
||||||
|
|
||||||
const okBtn = document.createElement('button');
|
|
||||||
okBtn.type = 'button';
|
|
||||||
okBtn.className = 'primary-btn';
|
|
||||||
okBtn.textContent = 'ОК';
|
|
||||||
okBtn.addEventListener('click', async () => {
|
|
||||||
await terminateCurrentSession({
|
|
||||||
infoMessage: 'Ваша сессия устарела. Выполните вход заново.',
|
|
||||||
});
|
|
||||||
navigate('start-view');
|
|
||||||
});
|
|
||||||
|
|
||||||
card.append(title, details, okBtn);
|
|
||||||
list.append(card);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = '';
|
|
||||||
const fail = document.createElement('div');
|
|
||||||
fail.className = 'card meta-muted';
|
|
||||||
fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`;
|
|
||||||
list.append(fail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
screen.append(list);
|
|
||||||
loadList();
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
70
shine-UI/js/pages/messages/dm-lab-chat.js
Normal file
70
shine-UI/js/pages/messages/dm-lab-chat.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Demo-чат для оффлайн-флоу /messages-list/lab/chat/:id (только demo).
|
||||||
|
// Реальный chat-view.js НЕ трогаем (он завязан на бэкенд/WS); здесь — изолированная мок-страница.
|
||||||
|
// Состояние сообщений берём из dm-lab-store (localStorage). Без сети, без авторизации.
|
||||||
|
import { directMessages } from '../../mock-data.js';
|
||||||
|
import { getThread, appendOut, markRead } from './dm-lab-store.js';
|
||||||
|
|
||||||
|
// showAppChrome:false — у чата свой низ (поле ввода), нижнее меню прячем.
|
||||||
|
export const pageMeta = { id: 'dm-lab-chat', title: 'Чат (demo)', showAppChrome: false };
|
||||||
|
|
||||||
|
function findDialog(id) {
|
||||||
|
return (Array.isArray(directMessages) ? directMessages : []).find((m) => m.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bubble(m) {
|
||||||
|
const b = document.createElement('div');
|
||||||
|
b.className = `bubble ${m && m.from === 'out' ? 'out' : 'in'}`;
|
||||||
|
b.textContent = m ? m.text : '';
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function render({ navigate, route }) {
|
||||||
|
const chatId = String(route?.params?.chatId || '').trim();
|
||||||
|
const dialog = findDialog(chatId);
|
||||||
|
const name = (dialog && dialog.name) || chatId || 'Диалог';
|
||||||
|
|
||||||
|
// Открытие диалога сбрасывает у него непрочитанные (demo).
|
||||||
|
markRead(chatId);
|
||||||
|
|
||||||
|
const screen = document.createElement('section');
|
||||||
|
screen.className = 'dm-screen dm-chat-screen';
|
||||||
|
|
||||||
|
// Шапка чата: назад + имя собеседника + demo-метка.
|
||||||
|
const head = document.createElement('header');
|
||||||
|
head.className = 'dm-chat-head';
|
||||||
|
head.innerHTML = `
|
||||||
|
<button type="button" class="dm-chat-back" aria-label="Назад">‹</button>
|
||||||
|
<span class="dm-chat-peer">${name}</span>
|
||||||
|
<span class="dm-chat-demo-tag">demo</span>
|
||||||
|
`;
|
||||||
|
head.querySelector('.dm-chat-back').addEventListener('click', () => navigate('messages-list/lab'));
|
||||||
|
|
||||||
|
const log = document.createElement('div');
|
||||||
|
log.className = 'dm-messages-log';
|
||||||
|
getThread(chatId).forEach((m) => log.append(bubble(m)));
|
||||||
|
|
||||||
|
const inputRow = document.createElement('form');
|
||||||
|
inputRow.className = 'dm-chat-input';
|
||||||
|
inputRow.innerHTML = `
|
||||||
|
<textarea class="dm-input" rows="1" placeholder="Сообщение…" aria-label="Текст сообщения"></textarea>
|
||||||
|
<button type="submit" class="dm-send-btn dm-send-icon-btn" aria-label="Отправить">➤</button>
|
||||||
|
`;
|
||||||
|
const field = inputRow.querySelector('.dm-input');
|
||||||
|
|
||||||
|
const scrollToEnd = () => requestAnimationFrame(() => { log.scrollTop = log.scrollHeight; });
|
||||||
|
const submit = () => {
|
||||||
|
const msg = appendOut(chatId, field.value);
|
||||||
|
if (!msg) return;
|
||||||
|
field.value = '';
|
||||||
|
log.append(bubble(msg));
|
||||||
|
scrollToEnd();
|
||||||
|
};
|
||||||
|
inputRow.addEventListener('submit', (e) => { e.preventDefault(); submit(); });
|
||||||
|
field.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.append(head, log, inputRow);
|
||||||
|
scrollToEnd();
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
89
shine-UI/js/pages/messages/dm-lab-store.js
Normal file
89
shine-UI/js/pages/messages/dm-lab-store.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// Demo-состояние ЛС для оффлайн-флоу /messages-list/lab (только demo, на проде НЕ используется).
|
||||||
|
// Хранится в localStorage, поэтому переживает навигацию список ↔ чат и перезагрузку.
|
||||||
|
// Источник стартовых тредов — мок directMessages: последнее сообщение = preview карточки.
|
||||||
|
import { directMessages } from '../../mock-data.js';
|
||||||
|
|
||||||
|
const KEY = 'dm-lab-demo-v1';
|
||||||
|
|
||||||
|
function nowLabel() {
|
||||||
|
try {
|
||||||
|
const d = new Date();
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стартовый набор тредов: пара входящих, последнее = preview карточки (чтобы список совпал с моком).
|
||||||
|
function seed() {
|
||||||
|
const store = {};
|
||||||
|
(Array.isArray(directMessages) ? directMessages : []).forEach((m) => {
|
||||||
|
const last = m.preview || m.lastMessage || 'Сообщение';
|
||||||
|
store[m.id] = {
|
||||||
|
unread: Math.max(0, Math.trunc(Number(m.unreadCount) || 0)),
|
||||||
|
messages: [
|
||||||
|
{ from: 'in', text: 'Привет! Это тестовый диалог demo-режима.', time: m.time || '' },
|
||||||
|
{ from: 'in', text: last, time: m.time || '' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAll() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object') return parsed;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const fresh = seed();
|
||||||
|
writeAll(fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAll(store) {
|
||||||
|
try { localStorage.setItem(KEY, JSON.stringify(store)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThread(id) {
|
||||||
|
const all = readAll();
|
||||||
|
const t = all[id];
|
||||||
|
return t && Array.isArray(t.messages) ? t.messages : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnread(id) {
|
||||||
|
const all = readAll();
|
||||||
|
return all[id] ? Math.max(0, Math.trunc(Number(all[id].unread) || 0)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Превью для списка = текст последнего сообщения треда (или fallback из мока).
|
||||||
|
export function getPreview(id, fallback = '') {
|
||||||
|
const msgs = getThread(id);
|
||||||
|
const last = msgs[msgs.length - 1];
|
||||||
|
return last && last.text ? last.text : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавить исходящее сообщение; вернуть его (или null, если текст пуст).
|
||||||
|
export function appendOut(id, text) {
|
||||||
|
const clean = String(text || '').trim();
|
||||||
|
if (!id || !clean) return null;
|
||||||
|
const all = readAll();
|
||||||
|
if (!all[id]) all[id] = { unread: 0, messages: [] };
|
||||||
|
const msg = { from: 'out', text: clean, time: nowLabel() };
|
||||||
|
all[id].messages.push(msg);
|
||||||
|
writeAll(all);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие диалога сбрасывает непрочитанные у него.
|
||||||
|
export function markRead(id) {
|
||||||
|
const all = readAll();
|
||||||
|
if (all[id]) { all[id].unread = 0; writeAll(all); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// На случай отладки: сбросить demo-состояние к стартовому.
|
||||||
|
export function resetDemo() {
|
||||||
|
writeAll(seed());
|
||||||
|
}
|
||||||
@ -150,6 +150,15 @@ export function getRoute() {
|
|||||||
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// messages-list/<mode>: ловим второй сегмент как mode (нужно для demo-маршрута /messages-list/lab).
|
||||||
|
if (pageId === 'messages-list') {
|
||||||
|
// demo-чат под лабораторным префиксом: /messages-list/lab/chat/:id → отдельная demo-страница.
|
||||||
|
if (segments[1] === 'lab' && segments[2] === 'chat' && segments[3]) {
|
||||||
|
return { pageId: 'dm-lab-chat', params: { chatId: decodePart(segments[3]) } };
|
||||||
|
}
|
||||||
|
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
||||||
|
}
|
||||||
|
|
||||||
return { pageId, params: {} };
|
return { pageId, params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3553,13 +3553,24 @@ textarea.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dm-dialog-card {
|
.dm-dialog-card {
|
||||||
background: rgba(20, 25, 35, 0.4);
|
position: relative;
|
||||||
backdrop-filter: blur(25px);
|
display: grid;
|
||||||
-webkit-backdrop-filter: blur(25px);
|
grid-template-columns: 60px minmax(0, 1fr) auto;
|
||||||
border: 1px solid rgba(212, 175, 55, 0.4);
|
gap: 12px;
|
||||||
border-radius: 20px;
|
align-items: center;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37);
|
min-height: 92px; /* карточки не ужимаем; тап-зона ≥44px */
|
||||||
|
padding: 14px 16px 14px 14px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: rgba(7, 10, 18, 0.88);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
border: 1px solid rgba(140, 99, 255, 0.32); /* оконтовка = цвет линии связи; default = violet (контакт) */
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.42);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.dm-dialog-card:focus-visible { outline: 2px solid var(--rel-link); outline-offset: 2px; }
|
||||||
|
.dm-card--family { border-color: rgba(240, 184, 46, 0.42); } /* линия связи: gold (семья) */
|
||||||
|
.dm-card--shining { border-color: rgba(104, 216, 255, 0.45); } /* линия связи: cyan (сияющий) */
|
||||||
|
|
||||||
.dm-screen .list-item .avatar {
|
.dm-screen .list-item .avatar {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
@ -3582,66 +3593,169 @@ textarea.input {
|
|||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-status-line {
|
/* ===== «Личные сообщения» v2 — списочная форма «Связей» ===== */
|
||||||
color: rgba(255, 255, 255, 0.5);
|
/* Токены-мост: НЕ придумываем цвета, наследуем канонический язык «Связей» (network-graph.css :root). */
|
||||||
|
.dm-screen {
|
||||||
|
--dm-tone-default: var(--rel-contact);
|
||||||
|
--dm-tone-family: var(--rel-family);
|
||||||
|
--dm-tone-shining: var(--rel-shining);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-screen .unread {
|
/* DM-шапка через grid 1fr auto 1fr: бренд слева, title строго по центру, «+» справа. */
|
||||||
min-width: 26px;
|
.dm-head {
|
||||||
height: 26px;
|
position: sticky; top: 0; z-index: 12;
|
||||||
padding: 0 8px;
|
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px;
|
||||||
border-radius: 999px;
|
padding: 14px 14px 0;
|
||||||
display: inline-flex;
|
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||||
align-items: center;
|
background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0));
|
||||||
justify-content: center;
|
}
|
||||||
border: 1px solid rgba(212, 175, 55, 0.5);
|
.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
background: rgba(212, 175, 55, 0.22);
|
.dm-head-hex {
|
||||||
color: rgba(255, 200, 50, 0.95);
|
width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32);
|
font-weight: 700; font-size: 15px; color: #1a1205;
|
||||||
|
background: linear-gradient(150deg, #F0B82E, #D49F22);
|
||||||
|
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||||
|
box-shadow: 0 0 14px rgba(240, 184, 46, 0.35);
|
||||||
|
}
|
||||||
|
.dm-head-id { min-width: 0; display: grid; }
|
||||||
|
.dm-head-name { font-size: 15px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dm-head-sub { font-size: 11px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dm-head-title { font-size: 18px; font-weight: 700; color: var(--text); white-space: nowrap; text-align: center; padding: 0 6px; }
|
||||||
|
/* Центр шапки — светящийся бренд «Shine» */
|
||||||
|
.dm-head-shine {
|
||||||
|
font-size: 21px; letter-spacing: 0.6px; color: #FCEAC0;
|
||||||
|
text-shadow: 0 0 6px rgba(240, 184, 46, 0.55), 0 0 16px rgba(240, 184, 46, 0.38), 0 0 30px rgba(240, 184, 46, 0.20);
|
||||||
|
animation: dm-shine-pulse 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes dm-shine-pulse {
|
||||||
|
0%, 100% { text-shadow: 0 0 5px rgba(240, 184, 46, 0.42), 0 0 12px rgba(240, 184, 46, 0.26), 0 0 22px rgba(240, 184, 46, 0.12); }
|
||||||
|
50% { text-shadow: 0 0 9px rgba(240, 184, 46, 0.68), 0 0 20px rgba(240, 184, 46, 0.46), 0 0 34px rgba(240, 184, 46, 0.26); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .dm-head-shine { animation: none; } }
|
||||||
|
.dm-head-plus {
|
||||||
|
justify-self: end; width: 48px; height: 48px; border-radius: 15px;
|
||||||
|
display: grid; place-items: center; font-size: 24px; line-height: 1; font-weight: 300;
|
||||||
|
color: #FFD98A; border: 1.5px solid rgba(240, 184, 46, 0.6);
|
||||||
|
background: rgba(12, 12, 16, 0.66);
|
||||||
|
box-shadow: 0 0 20px rgba(240, 184, 46, 0.32), 0 0 6px rgba(240, 184, 46, 0.28), inset 0 0 12px rgba(240, 184, 46, 0.12);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dm-divider { position: relative; height: 18px; margin: 6px 14px 8px; }
|
||||||
|
.dm-divider::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 1px; background: linear-gradient(90deg, transparent, rgba(240, 184, 46, 0.5), transparent); }
|
||||||
|
.dm-divider::after { content: ''; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; transform: translate(-50%, -50%) rotate(45deg); background: var(--rel-family); box-shadow: 0 0 8px var(--rel-family-glow); }
|
||||||
|
|
||||||
|
/* список: скролл внутри контента, карточки не ужимаем, отступ снизу под bottom nav (86px) + 16px */
|
||||||
|
.dm-list { display: flex; flex-direction: column; gap: 8px; padding: 0 14px; padding-bottom: calc(86px + 16px); }
|
||||||
|
|
||||||
|
/* текст карточки */
|
||||||
|
.dm-row-main { min-width: 0; }
|
||||||
|
.dm-row-titleline { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||||
|
.dm-dialog-card .dm-row-title { font-size: 16px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dm-dialog-card .dm-row-last-message { font-size: 14px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* галочка-подтверждён у имени (золотая, без слова «Подтверждён») */
|
||||||
|
.dm-name-check { display: inline-flex; flex: 0 0 auto; color: var(--rel-family); }
|
||||||
|
.dm-name-check svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
/* AvatarRing — обод/свечение по тону (адаптация орбов «Связей», без тяжёлой магии) */
|
||||||
|
.dm-av { width: 60px; height: 60px; border-radius: 50%; display: grid; place-items: center; position: relative; }
|
||||||
|
.dm-av .avatar { width: 56px; height: 56px; min-width: 56px; min-height: 56px; border: none; box-shadow: none; }
|
||||||
|
/* Цветные обводки вокруг аватаров (violet/gold) убраны по просьбе. Свечение оставляем только у сияющих (ниже). */
|
||||||
|
.dm-av--default { box-shadow: none; }
|
||||||
|
.dm-av--family { box-shadow: none; }
|
||||||
|
/* Сияющий аватар = АДАПТАЦИЯ сияющего узла экрана «Связи»: та же небесная палитра, тот же небесный rim,
|
||||||
|
тот же двойной «дышащий» пульс. Переиспользуем ОБЩИЕ keyframes графа (fg-shine-glow — пульс box-shadow,
|
||||||
|
fg-shine-halo — дыхание радиального ореола; объявлены в network-graph.css, грузится глобально), а не рисуем
|
||||||
|
второй похожий эффект. Радиальный ореол повторяет стопы узла графа; SVG-фильтр #fg-shine-glow есть только на
|
||||||
|
стр. «Связи», поэтому здесь мягкий CSS-blur. Мини-сфера компактная — не размывает текст/соседей. */
|
||||||
|
.dm-av--shining {
|
||||||
|
border: 1px solid rgba(150, 240, 255, 0.62);
|
||||||
|
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.dm-av--shining::before {
|
||||||
|
content: ''; position: absolute; inset: -12px; border-radius: 50%; z-index: -1; pointer-events: none;
|
||||||
|
background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%);
|
||||||
|
filter: blur(3.4px); /* = stdDeviation 3.4 SVG-фильтра #fg-shine-glow графа; геометрия inset −12px тоже как у узла (58px↔56px, scale≈1) */
|
||||||
|
animation: fg-shine-halo 3.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dm-av--shining { animation: none; }
|
||||||
|
.dm-av--shining::before { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dm-row-meta-col {
|
/* правая зона: один статус сверху, ниже [unread + chevron] */
|
||||||
display: grid;
|
/* правая зона: один горизонтальный ряд [статус][unread][chevron] на общей оси */
|
||||||
justify-items: end;
|
.dm-dialog-card .dm-row-meta { display: inline-flex; align-items: center; gap: 8px; min-width: 0; white-space: nowrap; }
|
||||||
align-content: end;
|
.dm-dialog-card .dm-row-meta .dm-chevron { margin-left: 4px; } /* отступ бейдж→chevron ≈ 12px */
|
||||||
gap: 6px;
|
/* непрочитанные — отдельная violet-сфера (НЕ изумруд) */
|
||||||
min-width: 64px;
|
.dm-unread-badge {
|
||||||
align-self: stretch;
|
min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; font-weight: 700; color: var(--text);
|
||||||
|
background: rgba(140, 99, 255, 0.16); border: 1px solid rgba(140, 99, 255, 0.55);
|
||||||
}
|
}
|
||||||
|
.dm-chevron { display: inline-flex; color: rgba(244, 246, 255, 0.32); }
|
||||||
|
.dm-chevron svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
.dm-row-main {
|
/* Значок «связь через кого» — ТОЛЬКО иконка (кликабельная); детали пути в попапе ниже */
|
||||||
min-width: 0;
|
.dm-via {
|
||||||
display: grid;
|
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
|
||||||
grid-template-rows: auto auto;
|
width: 24px; height: 24px; padding: 0; border-radius: 8px; cursor: pointer;
|
||||||
gap: 4px;
|
color: var(--rel-link); border: 1px solid rgba(25, 229, 138, 0.5); background: rgba(25, 229, 138, 0.08);
|
||||||
}
|
}
|
||||||
|
.dm-via-icon { display: inline-flex; }
|
||||||
|
.dm-via-icon svg { width: 14px; height: 14px; }
|
||||||
|
/* попап пути связи: Ты → …посредники… → он; узлы = аватар+имя, кликабельные → профиль */
|
||||||
|
.dm-via-path {
|
||||||
|
display: none; position: absolute; left: 14px; right: 14px; top: 46px; z-index: 6;
|
||||||
|
flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 11px; border-radius: 12px;
|
||||||
|
background: rgba(8, 12, 20, 0.97); border: 1px solid rgba(25, 229, 138, 0.35);
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.dm-via-path.is-open { display: flex; }
|
||||||
|
.dm-via-node {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px 3px 4px; border-radius: 11px;
|
||||||
|
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text); font-size: 12px; cursor: default;
|
||||||
|
}
|
||||||
|
button.dm-via-node { cursor: pointer; }
|
||||||
|
button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); }
|
||||||
|
.dm-via-node-ava { width: 20px; height: 20px; border-radius: 50%; overflow: hidden; flex: 0 0 auto; }
|
||||||
|
.dm-via-node-ava .avatar { width: 20px; height: 20px; min-width: 20px; min-height: 20px; border: none; box-shadow: none; }
|
||||||
|
.dm-via-node-ava .avatar-fallback { font-size: 9px; font-weight: 700; }
|
||||||
|
.dm-via-me { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(150deg, #F0B82E, #D49F22); color: #1a1205; font-size: 10px; font-weight: 700; }
|
||||||
|
.dm-via-node-name { white-space: nowrap; }
|
||||||
|
.dm-via-arrow { font-size: 12px; color: rgba(25, 229, 138, 0.8); }
|
||||||
|
|
||||||
.dm-row-title-wrap {
|
/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и
|
||||||
display: flex;
|
даёт лишний скролл. Фон НЕ меняем — клиппим overflow на уровне страницы (как просит ТЗ, п.4). */
|
||||||
align-items: center;
|
html, body { overflow-x: hidden; }
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-row-title {
|
/* ===== Demo-чат лаборатории ЛС (dm-lab-chat) — только demo, прод chat-view не затрагивает ===== */
|
||||||
overflow: hidden;
|
.dm-chat-screen { display: flex; flex-direction: column; min-height: 100%; }
|
||||||
text-overflow: ellipsis;
|
.dm-chat-head {
|
||||||
white-space: nowrap;
|
position: sticky; top: 0; z-index: 12; display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 14px; border-bottom: 1px solid rgba(240, 184, 46, 0.22);
|
||||||
|
background: linear-gradient(180deg, rgba(10, 12, 18, 0.92), rgba(10, 12, 18, 0.55));
|
||||||
|
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
|
.dm-chat-back {
|
||||||
.dm-row-last-message {
|
flex: 0 0 auto; width: 40px; height: 40px; border-radius: 12px;
|
||||||
margin-top: 0 !important;
|
display: grid; place-items: center; font-size: 26px; line-height: 1;
|
||||||
overflow: hidden;
|
color: var(--rel-family); border: 1px solid rgba(240, 184, 46, 0.4);
|
||||||
text-overflow: ellipsis;
|
background: rgba(10, 12, 18, 0.6); cursor: pointer;
|
||||||
white-space: nowrap;
|
|
||||||
padding-right: 6px;
|
|
||||||
}
|
}
|
||||||
|
.dm-chat-peer { flex: 1 1 auto; min-width: 0; font-size: 17px; font-weight: 700; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.dm-row-time {
|
.dm-chat-demo-tag {
|
||||||
font-size: 11px;
|
flex: 0 0 auto; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
line-height: 1.2;
|
color: rgba(244, 246, 255, 0.55); padding: 3px 8px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
.dm-chat-screen .dm-messages-log {
|
||||||
|
flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 10px;
|
||||||
|
padding: 14px; padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
.dm-chat-screen .bubble.in { align-self: flex-start; }
|
||||||
|
.dm-chat-screen .bubble.out { align-self: flex-end; }
|
||||||
|
.dm-chat-screen .dm-chat-input { display: grid; }
|
||||||
|
|
||||||
.dm-chat-wrap {
|
.dm-chat-wrap {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -3777,34 +3891,6 @@ textarea.input {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DM messages-list status + empty block as full glass buttons */
|
|
||||||
.dm-screen .dm-status-line {
|
|
||||||
display: block;
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
margin: 2px 20px 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(18, 24, 38, 0.42);
|
|
||||||
backdrop-filter: blur(25px);
|
|
||||||
-webkit-backdrop-filter: blur(25px);
|
|
||||||
border: 1px solid rgba(212, 175, 55, 0.32);
|
|
||||||
color: rgba(255, 227, 154, 0.92);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide "Нет диалогов." line on DM list per UI request */
|
|
||||||
.dm-screen .dm-status-line {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-screen .dm-status-line.is-available {
|
|
||||||
color: rgba(255, 227, 154, 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-screen .dm-status-line.is-unavailable {
|
|
||||||
color: rgba(255, 161, 176, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dm-screen .dm-list > .card.meta-muted {
|
.dm-screen .dm-list > .card.meta-muted {
|
||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user