SHA256
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:
@@ -1,5 +1,7 @@
|
||||
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 { getPreviousTrackedPath } from '../router.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
@@ -13,6 +15,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
dayKey,
|
||||
formatClockTime,
|
||||
formatDayLabel,
|
||||
formatRelativeTime,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
@@ -39,7 +44,7 @@ import {
|
||||
} from '../services/shine-routes.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
@@ -828,7 +833,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
<div class="modal" id="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>
|
||||
@@ -874,8 +879,8 @@ function renderDraftAttachments(container, attachments) {
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
const ok = window.confirm('Отменить вложение?');
|
||||
button.addEventListener('click', async () => {
|
||||
const ok = await confirmDialog({ title: 'Убрать вложение?', text: item.name, confirmLabel: 'Убрать', danger: true });
|
||||
if (!ok) return;
|
||||
attachments.splice(index, 1);
|
||||
renderDraftAttachments(container, attachments);
|
||||
@@ -1063,7 +1068,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
|
||||
if (list) {
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
|
||||
list.append(empty);
|
||||
} else {
|
||||
@@ -1169,18 +1174,19 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
|
||||
const typeWrap = document.createElement('label');
|
||||
typeWrap.className = 'channel-editor__type';
|
||||
typeWrap.textContent = 'Тип сообщения';
|
||||
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'select';
|
||||
typeSelect.className = 'select channel-editor__type-select';
|
||||
typeSelect.title = 'Тип сообщения';
|
||||
typeSelect.innerHTML = `
|
||||
<option value="10">Публикация</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление</option>
|
||||
`;
|
||||
typeWrap.append(typeSelect);
|
||||
|
||||
return openChannelEditor({
|
||||
id: 'channel-message-modal',
|
||||
title: 'Новое сообщение',
|
||||
title: 'Новая запись',
|
||||
submitLabel: 'Опубликовать',
|
||||
placeholder: 'Напишите сообщение',
|
||||
key: `channel-post:${channelName}`,
|
||||
@@ -1611,14 +1617,15 @@ function mapChannelMetaEvent(event, fallbackChannel) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMetaEventCard(event) {
|
||||
function renderChannelMetaEventCard(event, dayLabel = '') {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card channel-system-event-card';
|
||||
const label = event.kind === 'created'
|
||||
? 'Создан канал'
|
||||
? 'Канал создан'
|
||||
: 'Изменено описание канала';
|
||||
const shownLabel = dayLabel ? `${label} · ${dayLabel.toLocaleLowerCase('ru-RU')}` : label;
|
||||
card.innerHTML = `
|
||||
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
|
||||
<span class="channel-system-event-card__label">${escapeHtml(shownLabel)}</span>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
openChannelMetaDetailsModal({
|
||||
@@ -1856,6 +1863,7 @@ function renderPostCard(post, {
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('span');
|
||||
editedMarker.type = 'button';
|
||||
@@ -1906,6 +1914,15 @@ function renderPostCard(post, {
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
|
||||
const isOwnerPost = !!selector
|
||||
&& !isDiarySelector(selector)
|
||||
&& !!post.messageRef?.blockchainName
|
||||
&& String(post.messageRef.blockchainName).trim().toLowerCase() === String(selector.ownerBlockchainName || '').trim().toLowerCase();
|
||||
if (isOwnerPost) {
|
||||
card.classList.add('channel-message-card--owner');
|
||||
authorTile.hidden = true;
|
||||
}
|
||||
|
||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||
const parsedBody = parseMessageAttachments(post.body);
|
||||
|
||||
@@ -1990,7 +2007,7 @@ function renderPostCard(post, {
|
||||
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">${post.likesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || ''}</span>
|
||||
`;
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
|
||||
@@ -2015,7 +2032,7 @@ function renderPostCard(post, {
|
||||
discussionButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Обсуждение</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
|
||||
discussionButton.addEventListener('click', (event) => {
|
||||
@@ -2030,7 +2047,7 @@ function renderPostCard(post, {
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
@@ -2067,7 +2084,8 @@ function renderPostCard(post, {
|
||||
await onShare(route);
|
||||
});
|
||||
|
||||
actions.append(shareButton, replyButton);
|
||||
actions.append(shareButton);
|
||||
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
|
||||
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
|
||||
const originalBtn = document.createElement('button');
|
||||
originalBtn.type = 'button';
|
||||
@@ -2081,8 +2099,6 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
navigate(makeShineMessageRoute({
|
||||
ownerLogin,
|
||||
messageBlockchainName: post.targetRef.blockchainName,
|
||||
@@ -2135,13 +2151,28 @@ function renderPostCard(post, {
|
||||
});
|
||||
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 onEdit(post.messageRef, '', { isDelete: true }); }
|
||||
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
}
|
||||
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
|
||||
attachMessageMenu(card, headRow, menuItems);
|
||||
if (isOwnerPost) {
|
||||
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'channel-message-meta';
|
||||
const moreButton = headRow.querySelector('.channel-message-more');
|
||||
meta.append(timestamp);
|
||||
if (moreButton) meta.append(moreButton);
|
||||
actions.append(meta);
|
||||
if (!headRow.querySelector('.channel-message-type-button')) headRow.hidden = true;
|
||||
}
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
@@ -2166,8 +2197,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
|
||||
const addMessageButton = document.createElement('button');
|
||||
addMessageButton.type = 'button';
|
||||
addMessageButton.className = 'primary-btn channel-main-action channel-main-action--compose';
|
||||
addMessageButton.textContent = 'Добавить сообщение';
|
||||
addMessageButton.className = 'channel-compose-bar';
|
||||
addMessageButton.setAttribute('aria-label', 'Написать в канал');
|
||||
addMessageButton.innerHTML = `<span class="channel-compose-bar__text">Написать в канал…</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
|
||||
addMessageButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
handlers.onAddMessage();
|
||||
@@ -2199,7 +2231,17 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
});
|
||||
|
||||
if (feedItems.length) {
|
||||
let lastDayKey = '';
|
||||
feedItems.forEach((item) => {
|
||||
const itemDayKey = dayKey(item.timestampMs);
|
||||
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
|
||||
if (startsNewDay) lastDayKey = itemDayKey;
|
||||
if (startsNewDay && item.type !== 'meta') {
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'channel-day-separator';
|
||||
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
|
||||
feed.append(separator);
|
||||
}
|
||||
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
||||
const unreadLine = document.createElement('div');
|
||||
unreadLine.className = 'card channel-unread-line';
|
||||
@@ -2208,7 +2250,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
unreadLineInserted = true;
|
||||
}
|
||||
if (item.type === 'meta') {
|
||||
feed.append(renderChannelMetaEventCard(item.event));
|
||||
feed.append(renderChannelMetaEventCard(item.event, startsNewDay ? formatDayLabel(item.timestampMs) : ''));
|
||||
return;
|
||||
}
|
||||
const row = renderPostCard(item.post, {
|
||||
@@ -2232,7 +2274,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
});
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = channelData.isDiary
|
||||
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
|
||||
: 'Ждем ваших начинаний';
|
||||
@@ -2400,26 +2442,6 @@ export function render({ navigate, route, chrome }) {
|
||||
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
|
||||
const donateRoute = makeShineChannelDonateRoute(routeArgs);
|
||||
const items = [];
|
||||
if (apiData?.isOwnChannel && !apiData?.isDiary) {
|
||||
items.push({
|
||||
label: 'Добавить сообщение',
|
||||
action: () => {
|
||||
openAddMessageModal({
|
||||
channelName: apiData?.channel?.name || '',
|
||||
navigate,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||
try {
|
||||
await onAddPost(bodyText, msgSubType);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
||||
items.push({
|
||||
label: 'Оглавление',
|
||||
@@ -2479,9 +2501,6 @@ export function render({ navigate, route, chrome }) {
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.');
|
||||
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
|
||||
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
|
||||
if (!ok) return;
|
||||
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
@@ -2694,7 +2713,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
|
||||
const result = await shareOrCopyLink({
|
||||
title: 'SHiNE · Каналы',
|
||||
text: 'Тред из канала SHiNE',
|
||||
text: 'Обсуждение в Сиянии',
|
||||
url: buildAbsoluteRouteUrl(routeToShare),
|
||||
});
|
||||
if (disposed) return;
|
||||
@@ -2806,7 +2825,13 @@ export function render({ navigate, route, chrome }) {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
const hadContent = !!screen.querySelector('.channel-feed');
|
||||
const restorePosition = hadContent ? getChannelScrollRoot()?.scrollTop : readChannelPosition(positionKey);
|
||||
// Прежнее место в ленте восстанавливаем только при возврате из обсуждения / «О канале».
|
||||
// Открытие канала из списка или другого раздела ведёт к свежим постам (или к непрочитанным).
|
||||
const previousPath = getPreviousTrackedPath() || '';
|
||||
const returnedFromChannelPage = /^\/(SHiNE|thread)\//.test(previousPath) || /\/(about|donate)$/.test(previousPath);
|
||||
const restorePosition = hadContent
|
||||
? getChannelScrollRoot()?.scrollTop
|
||||
: (returnedFromChannelPage ? readChannelPosition(positionKey) : undefined);
|
||||
if (!hadContent) clearContent();
|
||||
activeChannelData = null;
|
||||
activeOpenEntrypointHistory = null;
|
||||
|
||||
Reference in New Issue
Block a user