SHA256
UI v4: новый стиль по макету V3/V4 и доводка экранов
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки. Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги), сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов – переключатель; уведомления – список как в чатах; фото – просмотр на весь экран. Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода, второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов), иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса. Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
import { formatListTime } from '../services/channels-ux.js';
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
|
||||
const CONNECTION_CLOSE_FRIEND = 10;
|
||||
const CONNECTION_UNCLOSE_FRIEND = 11;
|
||||
@@ -19,21 +21,21 @@ const profileSnapshotPending = new Map();
|
||||
|
||||
function connectionActionLabel(typeCode) {
|
||||
switch (Number(typeCode)) {
|
||||
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья.';
|
||||
case CONNECTION_UNCLOSE_FRIEND: return 'Удалил(а) вас из близких друзей.';
|
||||
case CONNECTION_FRIEND: return 'Добавил(а) вас в друзья.';
|
||||
case CONNECTION_UNFRIEND: return 'Удалил(а) вас из друзей.';
|
||||
case CONNECTION_SHINE_CONFIRMED: return 'Подтвердил(а), что вы Сияющий.';
|
||||
case CONNECTION_SHINE_UNCONFIRMED: return 'Снял(а) подтверждение «Сияющий».';
|
||||
case CONNECTION_OFFICIAL_CONFIRMED: return 'Подтвердил(а) официальный статус аккаунта.';
|
||||
case CONNECTION_OFFICIAL_UNCONFIRMED: return 'Снял(а) подтверждение официального статуса.';
|
||||
default: return 'Изменил(а) связь с вами.';
|
||||
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья';
|
||||
case CONNECTION_UNCLOSE_FRIEND: return 'Убрал(а) вас из близких друзей';
|
||||
case CONNECTION_FRIEND: return 'Добавил(а) вас в друзья';
|
||||
case CONNECTION_UNFRIEND: return 'Убрал(а) вас из друзей';
|
||||
case CONNECTION_SHINE_CONFIRMED: return 'Подтвердил(а), что вы сияющий';
|
||||
case CONNECTION_SHINE_UNCONFIRMED: return 'Снял(а) подтверждение «сияющий»';
|
||||
case CONNECTION_OFFICIAL_CONFIRMED: return 'Подтвердил(а) официальный статус аккаунта';
|
||||
case CONNECTION_OFFICIAL_UNCONFIRMED: return 'Снял(а) подтверждение официального статуса';
|
||||
default: return 'Изменил(а) связь с вами';
|
||||
}
|
||||
}
|
||||
|
||||
function eventActionLabel(typeCode) {
|
||||
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала.';
|
||||
return 'Подписался(-ась) на ваш канал.';
|
||||
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала';
|
||||
return 'Подписался(-ась) на ваш канал';
|
||||
}
|
||||
|
||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||
@@ -58,23 +60,6 @@ function normalizeItem(item) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatRelativeTime(value) {
|
||||
const ts = Number(value || 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) return '';
|
||||
|
||||
const diffMs = Math.max(0, Date.now() - ts);
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const week = 7 * day;
|
||||
|
||||
if (diffMs < minute) return 'сейчас';
|
||||
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`;
|
||||
if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`;
|
||||
if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`;
|
||||
return `${Math.max(1, Math.floor(diffMs / week))} нед.`;
|
||||
}
|
||||
|
||||
function profileField(snapshot, key) {
|
||||
const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : [])
|
||||
.find((field) => String(field?.key || '') === key);
|
||||
@@ -153,7 +138,7 @@ async function enrichItem(item, activeTab) {
|
||||
|
||||
function renderEmpty(activeTab) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack notification-empty-state';
|
||||
card.className = 'stack notification-empty-state';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
@@ -163,72 +148,50 @@ function renderEmpty(activeTab) {
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderIdentity(item) {
|
||||
function renderAvatar(item) {
|
||||
const profile = item.profile;
|
||||
const firstName = profileField(profile, 'first_name');
|
||||
const lastName = profileField(profile, 'last_name');
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь';
|
||||
const avatar = profile?.avatar?.txId
|
||||
? {
|
||||
ar: String(profile.avatar.txId || '').trim(),
|
||||
sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'notification-identity';
|
||||
header.append(renderUserAvatar({
|
||||
return renderUserAvatar({
|
||||
login: item.sourceLogin || 'unknown',
|
||||
firstName,
|
||||
lastName,
|
||||
firstName: profileField(profile, 'first_name'),
|
||||
lastName: profileField(profile, 'last_name'),
|
||||
avatar,
|
||||
size: 'md',
|
||||
className: 'notification-avatar',
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.className = 'notification-identity-text';
|
||||
|
||||
const primary = document.createElement('div');
|
||||
primary.className = 'notification-identity-primary';
|
||||
// Строка как в списке чатов: имя и время, ниже – что произошло и текст ответа.
|
||||
function renderHead(item) {
|
||||
const profile = item.profile;
|
||||
const fullName = [profileField(profile, 'first_name'), profileField(profile, 'last_name')].filter(Boolean).join(' ')
|
||||
|| item.sourceLogin || 'Пользователь';
|
||||
const head = document.createElement('div');
|
||||
head.className = 'notification-card__head';
|
||||
const name = document.createElement('strong');
|
||||
name.className = 'notification-person-name';
|
||||
name.className = 'notification-card__name';
|
||||
name.textContent = fullName;
|
||||
primary.append(name);
|
||||
|
||||
const login = String(item.sourceLogin || '').trim();
|
||||
if (login) {
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'notification-login';
|
||||
loginEl.textContent = `@${login}`;
|
||||
primary.append(loginEl);
|
||||
}
|
||||
|
||||
const relative = formatRelativeTime(item.createdAtMs);
|
||||
if (relative) {
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'notification-time-separator';
|
||||
separator.textContent = '·';
|
||||
const time = document.createElement('span');
|
||||
time.className = 'notification-time';
|
||||
time.textContent = relative;
|
||||
primary.append(separator, time);
|
||||
}
|
||||
|
||||
text.append(primary);
|
||||
header.append(text);
|
||||
return header;
|
||||
head.append(name);
|
||||
const time = document.createElement('span');
|
||||
time.className = 'notification-card__time';
|
||||
time.textContent = formatListTime(item.createdAtMs);
|
||||
head.append(time);
|
||||
return head;
|
||||
}
|
||||
|
||||
function renderEngagement(engagement) {
|
||||
if (!engagement) return null;
|
||||
|
||||
const stats = [
|
||||
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
|
||||
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
|
||||
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
|
||||
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
|
||||
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
|
||||
{ key: 'likesCount', icon: 'heart', label: 'Лайки' },
|
||||
{ key: 'repliesCount', icon: 'message', label: 'Ответы' },
|
||||
{ key: 'repostsCount', icon: 'refresh', label: 'Репосты' },
|
||||
{ key: 'sharesCount', icon: 'forward', label: 'Отправки' },
|
||||
].filter(({ key }) => Number(engagement[key] || 0) > 0);
|
||||
|
||||
if (!stats.length) return null;
|
||||
@@ -243,7 +206,7 @@ function renderEngagement(engagement) {
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'notification-engagement-icon';
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
iconEl.textContent = icon;
|
||||
iconEl.innerHTML = iconHtml(icon);
|
||||
|
||||
const countEl = document.createElement('span');
|
||||
countEl.className = 'notification-engagement-count';
|
||||
@@ -289,31 +252,31 @@ function bindNotificationNavigation(row, routePath, navigate) {
|
||||
|
||||
function renderItem(item, activeTab, navigate) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'card stack notification-card';
|
||||
row.className = 'notification-card';
|
||||
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
|
||||
row.append(renderIdentity(item));
|
||||
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-action';
|
||||
if (activeTab === 'connections') {
|
||||
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
|
||||
} else if (activeTab === 'events') {
|
||||
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
|
||||
} else {
|
||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||
}
|
||||
row.append(action);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'notification-card__body';
|
||||
body.append(renderHead(item));
|
||||
|
||||
if (activeTab === 'replies') {
|
||||
const body = document.createElement('p');
|
||||
body.className = 'notification-content';
|
||||
body.textContent = item.sourceText || 'Ответ без текста.';
|
||||
row.append(body);
|
||||
|
||||
// Во вкладке «Ответы» всё – ответы: подпись «ответил(а)» не нужна, сразу текст.
|
||||
const text = document.createElement('p');
|
||||
text.className = 'notification-card__text';
|
||||
text.textContent = item.sourceText || 'Ответ без текста';
|
||||
body.append(text);
|
||||
const engagement = renderEngagement(item.engagement);
|
||||
if (engagement) row.append(engagement);
|
||||
if (engagement) body.append(engagement);
|
||||
} else {
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-card__action';
|
||||
action.textContent = activeTab === 'connections'
|
||||
? connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType)
|
||||
: eventActionLabel(item.sourceMsgSubType, item);
|
||||
body.append(action);
|
||||
}
|
||||
|
||||
row.append(renderAvatar(item), body);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -333,7 +296,7 @@ export function render({ navigate, chrome } = {}) {
|
||||
screen.append(tabs);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
list.className = 'notifications-list';
|
||||
let payloadCache = null;
|
||||
let requestSeq = 0;
|
||||
let observer = null;
|
||||
@@ -407,14 +370,14 @@ export function render({ navigate, chrome } = {}) {
|
||||
const items=await Promise.all(base.map(x=>enrichItem(x,tab)));
|
||||
const unread=items.filter(x=>x.createdAtMs>seenAt); const old=items.filter(x=>x.createdAtMs<=seenAt);
|
||||
const nodes=[];
|
||||
const groupTitle=(text)=>{const el=document.createElement('h2');el.className='notification-group-title';el.textContent=text;return el;};
|
||||
if(unread.length) nodes.push(groupTitle(`Новые · ${unread.length}`));
|
||||
unread.forEach(x=>{const n=renderItem(x,tab,navigate);n.classList.add('notification-card--new');n.dataset.createdAtMs=String(x.createdAtMs);nodes.push(n);});
|
||||
let divider=null;
|
||||
if(unread.length){divider=document.createElement('div');divider.className='notification-new-divider';divider.textContent=`НОВЫЕ · ${unread.length}`;nodes.push(divider);}
|
||||
if(unread.length && old.length) nodes.push(groupTitle('Ранее'));
|
||||
old.forEach(x=>nodes.push(renderItem(x,tab,navigate))); list.replaceChildren(...nodes);
|
||||
if(unread.length && 'IntersectionObserver' in window){
|
||||
observer=new IntersectionObserver(entries=>{ entries.forEach(e=>{ if(e.isIntersecting && e.intersectionRatio>=0.5){ const ts=Number(e.target.dataset.createdAtMs||0); if(ts>0){e.target.classList.remove('notification-card--new');scheduleSeen(tab,ts);} } }); },{threshold:[0.5]});
|
||||
list.querySelectorAll('.notification-card--new').forEach(n=>observer.observe(n));
|
||||
requestAnimationFrame(()=>divider?.scrollIntoView({block:'end'}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user