diff --git a/shine-UI/assets/demo-avatars/u1.jpg b/shine-UI/assets/demo-avatars/u1.jpg new file mode 100644 index 0000000..f5dfb5a Binary files /dev/null and b/shine-UI/assets/demo-avatars/u1.jpg differ diff --git a/shine-UI/assets/demo-avatars/u2.jpg b/shine-UI/assets/demo-avatars/u2.jpg new file mode 100644 index 0000000..3f8d8f1 Binary files /dev/null and b/shine-UI/assets/demo-avatars/u2.jpg differ diff --git a/shine-UI/assets/demo-avatars/u3.jpg b/shine-UI/assets/demo-avatars/u3.jpg new file mode 100644 index 0000000..b706e9e Binary files /dev/null and b/shine-UI/assets/demo-avatars/u3.jpg differ diff --git a/shine-UI/assets/demo-avatars/u4.jpg b/shine-UI/assets/demo-avatars/u4.jpg new file mode 100644 index 0000000..9688fb2 Binary files /dev/null and b/shine-UI/assets/demo-avatars/u4.jpg differ diff --git a/shine-UI/assets/demo-avatars/u6.jpg b/shine-UI/assets/demo-avatars/u6.jpg new file mode 100644 index 0000000..970a9fe Binary files /dev/null and b/shine-UI/assets/demo-avatars/u6.jpg differ diff --git a/shine-UI/assets/demo-avatars/u7.jpg b/shine-UI/assets/demo-avatars/u7.jpg new file mode 100644 index 0000000..b530fdb Binary files /dev/null and b/shine-UI/assets/demo-avatars/u7.jpg differ diff --git a/shine-UI/docs/design/messages-list-v2.md b/shine-UI/docs/design/messages-list-v2.md new file mode 100644 index 0000000..827da4f --- /dev/null +++ b/shine-UI/docs/design/messages-list-v2.md @@ -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/статусы — фон остаётся прежним. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 1d0eba7..af12a85 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -63,6 +63,7 @@ import * as appLogView from './pages/app-log-view.js'; import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js'; import * as solanaUsersInitView from './pages/solana-users-init-view.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 chatView from './pages/chat-view.js'; import * as userProfileView from './pages/user-profile-view.js'; @@ -105,6 +106,7 @@ const routes = { 'pwa-diagnostics-view': pwaDiagnosticsView, 'solana-users-init-view': solanaUsersInitView, 'messages-list': messagesList, + 'dm-lab-chat': dmLabChat, 'contact-search-view': contactSearchView, 'chat-view': chatView, user: userProfileView, @@ -151,6 +153,7 @@ const GUEST_ALLOWED_PAGES = new Set([ 'channel-thread-view', 'user', 'contact-search-view', + 'dm-lab-chat', // demo-чат лаборатории ЛС (мок, без сессии) ]); setClientErrorTransport((payload) => authService.reportClientUiError(payload)); @@ -687,7 +690,10 @@ function renderApp() { const route = getRoute(); 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'); return; } diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 36380a1..b8f0df3 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -1,15 +1,10 @@ -import { renderHeader } from '../components/header.js'; import { directMessages } from '../mock-data.js'; -import { - getChatMessages, - isSessionInvalidError, - setContacts, - state, - terminateCurrentSession, -} from '../state.js'; -import { loadCurrentRelations } from '../services/user-connections.js'; +import { state } from '../state.js'; import { renderUserAvatar } from '../components/avatar-image.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: 'Личные сообщения' }; const dmAvatarSnapshotCache = new Map(); @@ -36,24 +31,36 @@ async function loadDmAvatarSnapshot(login) { return pending; } -function createDmAvatar(login) { +// Аватар: реальное фото через renderUserAvatar (lazy), при отсутствии — аккуратные инициалы. +// Ошибка загрузки снапшота не ломает карточку (catch → инициалы остаются). +function createDmAvatar(login, { upgrade = true, name = '', photo = '' } = {}) { const cleanLogin = String(login || '').trim(); const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; - const avatarEl = renderUserAvatar({ - login: cleanLogin || 'unknown', - size: 'small', - title, - }); - if (!cleanLogin) return avatarEl; + // Инициалы для fallback берём из имени диалога («Марина К.» → «МК»), а не из служебного id. + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + const firstName = parts[0] || ''; + const lastName = parts[1] || ''; + const avatarEl = renderUserAvatar({ login: cleanLogin || 'unknown', firstName, lastName, size: 'small', title }); + // Тестовое фото (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) => { if (!avatarEl.isConnected) return; const upgraded = renderUserAvatar({ login: cleanLogin, + firstName, lastName, 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, size: 'small', title, @@ -64,149 +71,162 @@ function createDmAvatar(login) { return avatarEl; } -function formatChatRowTime(ts) { - const value = Number(ts || 0); - if (!Number.isFinite(value) || value <= 0) return '-'; - return new Intl.DateTimeFormat('ru-RU', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(value)); -} +// Иконки: галочка-подтверждён (gold, БЕЗ текста), цепочка (значок «связь через кого»), шеврон. +const SVG_CHECK = ''; +const SVG_LINK = ''; +const SVG_CHEVRON = ''; -export function render({ navigate }) { +export function render({ navigate, route }) { const screen = document.createElement('section'); - screen.className = 'stack dm-screen dm-list-screen'; + screen.className = 'dm-screen dm-list-screen'; - screen.append( - renderHeader({ - title: 'Личные сообщения', - leftLabel: String(state.session.login || '').trim(), - rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }], - }), - ); + // demo/lab: гость без сессии (маршрут /messages-list/lab или ?demo=1). В demo НЕ ходим в сеть за фото + // профиля — иначе висящие listUserParams не дают сети уйти в idle и ломают скриншоты (остаются initials). + const isDemo = route?.params?.mode === 'lab' + || (typeof window !== 'undefined' && /[?&]demo=1(?:&|$)/.test(window.location.search || '')); + + // Слева сверху — имя владельца аккаунта (на проде реальный логин; в 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 = ` +
+
${(login[0] || 'A').toUpperCase()}
+
+ ${login} +
+
+

Shine

+ + `; + 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'); - list.className = 'stack dm-list'; + list.className = 'dm-list'; 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'); - row.className = 'list-item dm-dialog-card'; - const avatarEl = createDmAvatar(item.id); - avatarEl.classList.add('avatar'); + row.className = `dm-dialog-card${cardVariant}`; + row.tabIndex = 0; + row.setAttribute('role', 'button'); + + // Галочка-подтверждён — у имени, БЕЗ слова «Подтверждён». + const checkHtml = v.confirmed ? `${SVG_CHECK}` : ''; + const unreadHtml = v.unread ? `${v.unread.label}` : ''; row.innerHTML = `
-
- ${item.name} - ${item.notInContacts ? 'не в контактах' : ''} +
+ ${name} + ${checkHtml}
-

${item.lastMessage}

-
-
- ${item.unread ? `${item.unread}` : ''} - ${item.time} +

${preview}

+
${unreadHtml}${SVG_CHEVRON}
`; - 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 = `${SVG_LINK}`; // только иконка (без мини-аватара/«+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; } - async function loadList() { - try { - const relations = await loadCurrentRelations(); - const contacts = relations.outContacts || []; - setContacts(contacts); - 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'); - empty.className = 'card meta-muted'; - empty.textContent = 'Пока нет ни контактов, ни сообщений'; - list.append(empty); - return; - } - - rows.forEach((item) => list.append(renderRow(item))); - } 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); - } + // Оффлайн-демо: список из мока directMessages (с семантическими полями). + // На проде источник заменяется на реальные relations (relationFlagsForTarget/shineConfirmed/shine) — + // карточки и резолвер не меняются. + const items = Array.isArray(directMessages) ? directMessages : []; + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'card meta-muted'; + empty.textContent = 'Пока нет диалогов'; + list.append(empty); + } else { + items.forEach((item) => list.append(renderRow(item))); } - screen.append(list); - loadList(); + screen.append(head, divider, list); return screen; } diff --git a/shine-UI/js/pages/messages/dm-lab-chat.js b/shine-UI/js/pages/messages/dm-lab-chat.js new file mode 100644 index 0000000..fd7fbe8 --- /dev/null +++ b/shine-UI/js/pages/messages/dm-lab-chat.js @@ -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 = ` + + ${name} + demo + `; + 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 = ` + + + `; + 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; +} diff --git a/shine-UI/js/pages/messages/dm-lab-store.js b/shine-UI/js/pages/messages/dm-lab-store.js new file mode 100644 index 0000000..3c2d81d --- /dev/null +++ b/shine-UI/js/pages/messages/dm-lab-store.js @@ -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()); +} diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 9bc56d2..80f9c99 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -150,6 +150,15 @@ export function getRoute() { return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } }; } + // messages-list/: ловим второй сегмент как 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: {} }; } diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 2bbe08a..3398dc5 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -3553,13 +3553,24 @@ textarea.input { } .dm-dialog-card { - background: rgba(20, 25, 35, 0.4); - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - border: 1px solid rgba(212, 175, 55, 0.4); - border-radius: 20px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37); + position: relative; + display: grid; + grid-template-columns: 60px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + 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 { width: 48px; @@ -3582,66 +3593,169 @@ textarea.input { color: rgba(255, 255, 255, 0.5); } -.dm-status-line { - color: rgba(255, 255, 255, 0.5); +/* ===== «Личные сообщения» v2 — списочная форма «Связей» ===== */ +/* Токены-мост: НЕ придумываем цвета, наследуем канонический язык «Связей» (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 { - min-width: 26px; - height: 26px; - padding: 0 8px; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid rgba(212, 175, 55, 0.5); - background: rgba(212, 175, 55, 0.22); - color: rgba(255, 200, 50, 0.95); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); +/* DM-шапка через grid 1fr auto 1fr: бренд слева, title строго по центру, «+» справа. */ +.dm-head { + position: sticky; top: 0; z-index: 12; + display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; + padding: 14px 14px 0; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + background: linear-gradient(180deg, rgba(10,12,18,0.82), rgba(10,12,18,0.0)); +} +.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; } +.dm-head-hex { + width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; + 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 { - display: grid; - justify-items: end; - align-content: end; - gap: 6px; - min-width: 64px; - align-self: stretch; +/* правая зона: один статус сверху, ниже [unread + chevron] */ +/* правая зона: один горизонтальный ряд [статус][unread][chevron] на общей оси */ +.dm-dialog-card .dm-row-meta { display: inline-flex; align-items: center; gap: 8px; min-width: 0; white-space: nowrap; } +.dm-dialog-card .dm-row-meta .dm-chevron { margin-left: 4px; } /* отступ бейдж→chevron ≈ 12px */ +/* непрочитанные — отдельная violet-сфера (НЕ изумруд) */ +.dm-unread-badge { + 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; - display: grid; - grid-template-rows: auto auto; - gap: 4px; +/* Значок «связь через кого» — ТОЛЬКО иконка (кликабельная); детали пути в попапе ниже */ +.dm-via { + display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; + width: 24px; height: 24px; padding: 0; border-radius: 8px; cursor: pointer; + 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 { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} +/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и + даёт лишний скролл. Фон НЕ меняем — клиппим overflow на уровне страницы (как просит ТЗ, п.4). */ +html, body { overflow-x: hidden; } -.dm-row-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +/* ===== Demo-чат лаборатории ЛС (dm-lab-chat) — только demo, прод chat-view не затрагивает ===== */ +.dm-chat-screen { display: flex; flex-direction: column; min-height: 100%; } +.dm-chat-head { + 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-row-last-message { - margin-top: 0 !important; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-right: 6px; +.dm-chat-back { + flex: 0 0 auto; width: 40px; height: 40px; border-radius: 12px; + display: grid; place-items: center; font-size: 26px; line-height: 1; + color: var(--rel-family); border: 1px solid rgba(240, 184, 46, 0.4); + background: rgba(10, 12, 18, 0.6); cursor: pointer; } - -.dm-row-time { - font-size: 11px; - line-height: 1.2; - white-space: nowrap; +.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-chat-demo-tag { + flex: 0 0 auto; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; + color: rgba(244, 246, 255, 0.55); padding: 3px 8px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.12); } +.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 { gap: 12px; @@ -3777,34 +3891,6 @@ textarea.input { 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 { width: calc(100% - 40px); margin: 0 20px;