UI: шапка channel owner/name и унификация карточек треда
This commit is contained in:
parent
3e62a2a01c
commit
1e1cdd9e76
@ -0,0 +1,27 @@
|
||||
# Шапка канала и унификация карточек в треде
|
||||
|
||||
- Краткое описание:
|
||||
- В `channel-view` убрана отдельная кнопка `О канале` из тела экрана.
|
||||
- В шапке добавлена одна кнопка-лейбл формата `owner/channel` на уровне стрелки назад.
|
||||
- Нажатие по кнопке `owner/channel` открывает тот же модал «О канале».
|
||||
- В `channel-thread-view` карточки сообщений приведены к виду, аналогичному карточкам в канале:
|
||||
- верхняя плитка автора (аватар, логин, номер, время),
|
||||
- действия `Лайк`, `Ответить`, `Тред`, `Отправить`.
|
||||
- Клик по телу карточки в треде теперь открывает вложенный (более глубокий) тред этого сообщения.
|
||||
- Уменьшены отступы между карточками/блоками в треде.
|
||||
|
||||
- Что проверять:
|
||||
- В канале в шапке справа отображается единая кнопка `owner/channel`.
|
||||
- Кнопка `owner/channel` открывает модал «О канале».
|
||||
- Старой кнопки `О канале` в контенте экрана нет.
|
||||
- В треде визуал карточек совпадает по паттерну с каналом.
|
||||
- В треде клик по телу сообщения ведёт глубже в тред.
|
||||
- Клик по плитке автора в треде ведёт в профиль пользователя.
|
||||
- Межкарточные отступы в треде компактнее.
|
||||
|
||||
- Ожидаемый результат:
|
||||
- Шапка канала и карточки треда выглядят и работают единообразно.
|
||||
- Навигация по вложенным тредам выполняется кликом по сообщению.
|
||||
|
||||
- Статус:
|
||||
- `pending`
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.68
|
||||
server.version=1.2.62
|
||||
client.version=1.2.69
|
||||
server.version=1.2.63
|
||||
|
||||
@ -314,14 +314,12 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
|
||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack thread-node-card';
|
||||
card.className = 'card stack thread-node-card channel-message-card';
|
||||
|
||||
const author = node?.authorLogin || 'автор';
|
||||
const text = resolveNodeText(node) || '(пусто)';
|
||||
const likes = Number(node?.likesCount || 0);
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const versions = Number(node?.versionsTotal || 1);
|
||||
const changes = Math.max(0, versions - 1);
|
||||
|
||||
const headingText = String(heading || '').trim();
|
||||
if (headingText) {
|
||||
@ -331,18 +329,36 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
card.append(headingEl);
|
||||
}
|
||||
|
||||
const meta = document.createElement('p');
|
||||
meta.className = 'thread-node-meta';
|
||||
meta.innerHTML = `
|
||||
<span class="author-line-login">${author}</span>
|
||||
<span class="author-line-num">· #${localNumber}</span>
|
||||
`;
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
authorTile.className = 'channel-message-author-tile';
|
||||
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'channel-message-avatar';
|
||||
avatar.textContent = String(author || 'A').trim().charAt(0).toUpperCase() || 'A';
|
||||
|
||||
const authorBlock = document.createElement('div');
|
||||
authorBlock.className = 'channel-message-author';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'channel-message-title author-line';
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'author-line-login';
|
||||
loginEl.textContent = author;
|
||||
const numberEl = document.createElement('span');
|
||||
numberEl.className = 'author-line-num';
|
||||
numberEl.textContent = `· #${localNumber}`;
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
||||
title.append(loginEl, numberEl);
|
||||
authorBlock.append(title, timestamp);
|
||||
authorTile.append(avatar, authorBlock);
|
||||
|
||||
const body = document.createElement('p');
|
||||
body.className = 'thread-node-body';
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = text;
|
||||
|
||||
card.append(meta, body);
|
||||
card.append(authorTile, body);
|
||||
|
||||
const target = buildTargetFromNode(node);
|
||||
const refKey = messageRefKey(target);
|
||||
@ -358,15 +374,20 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const isLiked = getMessageReactionState(target) === 'liked';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'thread-node-actions';
|
||||
actions.className = 'thread-node-actions channel-message-actions';
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'secondary-btn thread-like-btn';
|
||||
likeButton.className = 'channel-action-item thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.textContent = isPending ? `❤️ ${likes}...` : `${isLiked ? '❤️' : '🤍'} ${likes}`;
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${likes}</span>
|
||||
`;
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
if (!isLiked) {
|
||||
@ -375,7 +396,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
await longPressFeel(event.currentTarget, 130);
|
||||
likeButton.disabled = true;
|
||||
likeButton.textContent = `❤️ ${likes}...`;
|
||||
try {
|
||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||
} catch (error) {
|
||||
@ -390,9 +410,14 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||
replyButton.textContent = `💬 ${replies}`;
|
||||
replyButton.className = 'channel-action-item thread-reply-btn';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">💬</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${replies}</span>
|
||||
`;
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
openReplyModal({
|
||||
navigate: handlers.navigate,
|
||||
@ -400,17 +425,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
});
|
||||
});
|
||||
|
||||
const changedButton = document.createElement('button');
|
||||
changedButton.type = 'button';
|
||||
changedButton.className = 'secondary-btn thread-version-btn';
|
||||
changedButton.textContent = `✏️ ${changes}`;
|
||||
changedButton.disabled = true;
|
||||
changedButton.style.display = changes > 0 ? '' : 'none';
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'secondary-btn thread-share-btn';
|
||||
shareButton.textContent = '↗ Отправить';
|
||||
shareButton.className = 'channel-action-item thread-share-btn';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
`;
|
||||
shareButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
@ -419,16 +440,28 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
const openThreadButton = document.createElement('button');
|
||||
openThreadButton.type = 'button';
|
||||
openThreadButton.className = 'secondary-btn thread-open-btn';
|
||||
openThreadButton.textContent = '🧵 В тред';
|
||||
openThreadButton.className = 'channel-action-item thread-open-btn';
|
||||
openThreadButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">#</span>
|
||||
<span class="channel-action-label">Тред</span>
|
||||
`;
|
||||
openThreadButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
handlers.onOpenThread(target);
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton, changedButton, shareButton, openThreadButton);
|
||||
actions.append(likeButton, replyButton, openThreadButton, shareButton);
|
||||
card.append(actions);
|
||||
authorTile.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const login = String(node?.authorLogin || '').trim();
|
||||
if (!login) return;
|
||||
handlers.navigate(`user/${encodeRoutePart(login)}`);
|
||||
});
|
||||
card.addEventListener('click', () => {
|
||||
handlers.onOpenThread(target);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
|
||||
@ -766,36 +766,11 @@ function renderPostCard(post, {
|
||||
}
|
||||
|
||||
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
const head = document.createElement('div');
|
||||
head.className = 'card channel-head-card';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.className = 'channel-head-title';
|
||||
title.textContent = String(channelData.channel.name || '').trim();
|
||||
|
||||
const owner = document.createElement('p');
|
||||
owner.className = 'channel-head-meta';
|
||||
owner.textContent = `Владелец: ${channelData.channel.ownerName}`;
|
||||
|
||||
const headActions = document.createElement('div');
|
||||
headActions.className = 'channel-head-actions';
|
||||
const aboutButton = document.createElement('button');
|
||||
aboutButton.type = 'button';
|
||||
aboutButton.className = 'secondary-btn small-btn';
|
||||
aboutButton.textContent = 'О канале';
|
||||
aboutButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAboutChannelModal(channelData.channel);
|
||||
});
|
||||
headActions.append(aboutButton);
|
||||
|
||||
head.append(title);
|
||||
head.append(owner, headActions);
|
||||
if (channelData.reverseChannelMissingWarning) {
|
||||
const reverseWarning = document.createElement('p');
|
||||
reverseWarning.className = 'channel-head-meta';
|
||||
reverseWarning.textContent = channelData.reverseChannelMissingWarning;
|
||||
head.append(reverseWarning);
|
||||
screen.append(reverseWarning);
|
||||
}
|
||||
|
||||
const actionButton = document.createElement('button');
|
||||
@ -850,9 +825,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||
|
||||
if (channelData.isOwnChannel || !channelData.isSubscribed) {
|
||||
screen.append(head, actionButton, feed, backButton);
|
||||
screen.append(actionButton, feed, backButton);
|
||||
} else {
|
||||
screen.append(head, feed, backButton);
|
||||
screen.append(feed, backButton);
|
||||
}
|
||||
|
||||
applyPendingScroll(screen, routeKey);
|
||||
@ -893,6 +868,17 @@ export function render({ navigate, route }) {
|
||||
statusBox.style.display = '';
|
||||
};
|
||||
|
||||
const header = renderHeader({
|
||||
title: '',
|
||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
||||
rightActions: [{ label: 'Канал', onClick: () => {} }],
|
||||
});
|
||||
const channelHeaderButton = header.querySelector('.header-actions .icon-btn');
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.classList.add('channel-header-route-btn');
|
||||
channelHeaderButton.disabled = true;
|
||||
}
|
||||
|
||||
const rerender = () => {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
if (!current) return;
|
||||
@ -990,12 +976,7 @@ export function render({ navigate, route }) {
|
||||
rerender();
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: '',
|
||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
||||
}),
|
||||
);
|
||||
screen.append(header);
|
||||
screen.append(statusBox);
|
||||
|
||||
const skeleton = renderSkeleton(screen);
|
||||
@ -1006,6 +987,15 @@ export function render({ navigate, route }) {
|
||||
try {
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
activeSelector = apiData?.selector || null;
|
||||
const channelRouteLabel = `${apiData?.channel?.ownerName || 'owner'}/${apiData?.channel?.name || 'channel'}`;
|
||||
if (channelHeaderButton) {
|
||||
channelHeaderButton.textContent = channelRouteLabel;
|
||||
channelHeaderButton.disabled = false;
|
||||
channelHeaderButton.onclick = (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
openAboutChannelModal(apiData.channel);
|
||||
};
|
||||
}
|
||||
skeleton.remove();
|
||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||
onToggleLike: async (messageRef, action) => {
|
||||
|
||||
@ -2341,6 +2341,14 @@ textarea.input {
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.channel-header-route-btn {
|
||||
max-width: 68vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.thread-node-heading {
|
||||
color: #f1dcab;
|
||||
font-size: 15px;
|
||||
@ -2381,6 +2389,11 @@ textarea.input {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.channels-screen--thread .thread-node-actions {
|
||||
display: flex !important;
|
||||
grid-template-columns: none !important;
|
||||
}
|
||||
|
||||
.thread-node-level {
|
||||
--depth: 0;
|
||||
margin-left: calc(var(--depth) * 12px);
|
||||
@ -2389,11 +2402,16 @@ textarea.input {
|
||||
.thread-block {
|
||||
gap: 8px;
|
||||
border-radius: 15px;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(151, 174, 221, 0.2);
|
||||
background: linear-gradient(160deg, rgba(10, 21, 43, 0.72), rgba(8, 16, 31, 0.78));
|
||||
}
|
||||
|
||||
.channels-screen--thread .thread-node-card {
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.thread-block--ancestors > .section-title {
|
||||
color: #b9cbef;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user