ЛС: редизайн списка (фото-аватары, галочка/значок связи у имени, попап-цепочка→профиль) + demo-чат и lab-маршрут

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel
2026-06-19 19:48:27 +03:00
co-authored by Claude Opus 4.8
parent aea6bbcb0e
commit 6904ac8b7c
13 changed files with 583 additions and 229 deletions
+70
View 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;
}
@@ -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());
}