UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра

- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов,
  цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли.
- Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие —
  редактор цветов с экспортом/импортом.
- Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале»,
  создание канала с адресом из названия; лента открывается на свежих постах.
- Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке.
- Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка;
  настройки, кошелёк, сеансы — меню-списки.
- Нижняя панель: иконки без подписей, бейджи на иконках.
- confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу.
- docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
q
2026-09-26 10:05:26 +03:00
co-authored by Claude Opus 5.5
parent 6e5b57fd7c
commit f8900e531a
94 changed files with 3337 additions and 2432 deletions
+48 -33
View File
@@ -1,3 +1,4 @@
import { confirmDialog } from '../components/confirm-dialog.js';
import { iconHtml } from '../components/ui-icon.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
import { attachMessageMenu } from '../components/message-menu.js';
@@ -8,6 +9,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
showToast,
@@ -25,7 +27,7 @@ import {
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
export const pageMeta = { id: 'channel-thread-view', title: 'Обсуждение', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
@@ -200,6 +202,13 @@ function allFeedSummaries() {
});
}
const channelTitleByLabel = new Map();
function rememberChannelTitle(label, channel) {
const title = String(channel?.displayName || channel?.displayTitle || '').trim();
if (label && title) channelTitleByLabel.set(label, title);
}
function resolveChannelDisplayName(channelSelector) {
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
@@ -214,7 +223,9 @@ function resolveChannelDisplayName(channelSelector) {
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
));
if (!found) return '';
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
const label = `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
rememberChannelTitle(label, found.channel);
return label;
}
function resolveChannelHeadingFromNode(node) {
@@ -299,7 +310,9 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
if (!row?.channel?.channelName) return '';
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
const label = `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
rememberChannelTitle(label, row.channel);
return label;
} catch {
return '';
}
@@ -446,7 +459,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<div class="modal" id="thread-blockchain-details-modal">
<div class="modal-card stack blockchain-details-card">
<h3 class="modal-title">Данные блокчейна сообщения</h3>
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
<div class="blockchain-details-grid">
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
@@ -702,7 +715,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
headRow.append(authorTile);
@@ -786,7 +800,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${likes}</span>
<span class="channel-action-counter">${Number(likes) > 0 ? likes : ''}</span>
`;
likeButton.setAttribute('aria-pressed', String(isLiked));
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
@@ -817,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
<span class="channel-action-label">Ответить</span>
<span class="channel-action-counter">${replies}</span>
<span class="channel-action-counter">${Number(replies) > 0 ? replies : ''}</span>
`;
setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
@@ -856,7 +870,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const discussionButton = document.createElement('button');
discussionButton.type = 'button';
discussionButton.className = 'ui-button channel-action-item';
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span><span>${replies}</span>`;
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span>${Number(replies) > 0 ? `<span>${replies}</span>` : ''}`;
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
actions.append(likeButton, discussionButton, shareButton, replyButton);
@@ -871,8 +885,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => {
event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?');
if (!ok) return;
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
if (!ownerLogin) return;
handlers.navigate(makeShineMessageRoute({
@@ -927,7 +939,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
});
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
if (!await confirmDialog({
title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
confirmLabel: 'Удалить',
danger: true,
})) return;
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
} });
@@ -1026,21 +1043,18 @@ export function render({ navigate, route, chrome }) {
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
threadHeaderButton.textContent = 'Тред в канале: ...';
const threadHeaderTitle = document.createElement('span');
threadHeaderTitle.className = 'channel-header-title';
threadHeaderTitle.textContent = 'Обсуждение';
const threadHeaderChannel = document.createElement('span');
threadHeaderChannel.className = 'channel-header-owner';
threadHeaderChannel.textContent = '…';
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
threadHeaderButton.disabled = true;
const header = createTopBar({
center: threadHeaderButton,
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [
{
label: '↑',
title: 'К списку каналов',
ariaLabel: 'К списку каналов',
className: 'channel-thread-list-btn',
onClick: () => navigate('channels-list'),
},
],
});
header.classList.add('channel-thread-topbar');
chrome?.setTopbar(header);
@@ -1050,7 +1064,7 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = 'none';
const ensureActive = () => {
if (disposed) throw new Error('Экран треда уже закрыт.');
if (disposed) throw new Error('Экран обсуждения уже закрыт.');
};
const showStatus = (message) => {
@@ -1175,10 +1189,10 @@ export function render({ navigate, route, chrome }) {
onShare: async (target) => {
try {
const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
if (!routePath) throw new Error('Не удалось подготовить ссылку на обсуждение.');
const result = await shareOrCopyLink({
title: 'SHiNE · Тред',
text: 'Сообщение из треда SHiNE',
title: 'Сияние · Обсуждение',
text: 'Сообщение из обсуждения в Сиянии',
url: buildAbsoluteRouteUrl(routePath),
});
if (disposed) return;
@@ -1192,7 +1206,7 @@ export function render({ navigate, route, chrome }) {
onOpenThread: (target) => {
const routePath = buildThreadRouteFromTarget(target, selector);
if (!routePath) {
showStatus('Не удалось определить путь до треда.');
showStatus('Не удалось определить ссылку на обсуждение.');
return;
}
navigate(routePath);
@@ -1264,14 +1278,14 @@ export function render({ navigate, route, chrome }) {
showStatus('');
selector = parseThreadSelector(route);
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderChannel.textContent = '…';
threadHeaderButton.disabled = true;
threadHeaderButton.onclick = null;
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
invalid.textContent = 'Обсуждение не найдено: неверная ссылка.';
screen.append(invalid);
return;
}
@@ -1382,7 +1396,8 @@ export function render({ navigate, route, chrome }) {
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) {
threadHeaderButton.textContent = `Обсуждение · ${resolvedChannelTitle}`;
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
threadHeaderButton.title = 'Открыть канал';
threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => {
event.preventDefault();
@@ -1422,8 +1437,8 @@ export function render({ navigate, route, chrome }) {
composer.className = 'channel-composer';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'primary-btn';
reply.textContent = state.session.isAuthorized ? 'Написать ответ' : 'Войти и ответить';
reply.className = 'channel-compose-bar';
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
reply.addEventListener('click', () => {
const parsed = parseMessageAttachments(resolveNodeText(focus));
openReplyModal({
@@ -1448,7 +1463,7 @@ export function render({ navigate, route, chrome }) {
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.className = 'empty-note';
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
descendantsWrap.append(empty);
}
@@ -1478,7 +1493,7 @@ export function render({ navigate, route, chrome }) {
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
failed.textContent = `Не удалось загрузить обсуждение: ${toUserMessage(error, 'неизвестная ошибка')}`;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'primary-btn';