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
|
client.version=1.2.69
|
||||||
server.version=1.2.62
|
server.version=1.2.63
|
||||||
|
|||||||
@ -314,14 +314,12 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
|
|
||||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||||
const card = document.createElement('article');
|
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 author = node?.authorLogin || 'автор';
|
||||||
const text = resolveNodeText(node) || '(пусто)';
|
const text = resolveNodeText(node) || '(пусто)';
|
||||||
const likes = Number(node?.likesCount || 0);
|
const likes = Number(node?.likesCount || 0);
|
||||||
const replies = Number(node?.repliesCount || 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();
|
const headingText = String(heading || '').trim();
|
||||||
if (headingText) {
|
if (headingText) {
|
||||||
@ -331,18 +329,36 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
card.append(headingEl);
|
card.append(headingEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = document.createElement('p');
|
const authorTile = document.createElement('button');
|
||||||
meta.className = 'thread-node-meta';
|
authorTile.type = 'button';
|
||||||
meta.innerHTML = `
|
authorTile.className = 'channel-message-author-tile';
|
||||||
<span class="author-line-login">${author}</span>
|
|
||||||
<span class="author-line-num">· #${localNumber}</span>
|
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');
|
const body = document.createElement('p');
|
||||||
body.className = 'thread-node-body';
|
body.className = 'channel-message-body';
|
||||||
body.textContent = text;
|
body.textContent = text;
|
||||||
|
|
||||||
card.append(meta, body);
|
card.append(authorTile, body);
|
||||||
|
|
||||||
const target = buildTargetFromNode(node);
|
const target = buildTargetFromNode(node);
|
||||||
const refKey = messageRefKey(target);
|
const refKey = messageRefKey(target);
|
||||||
@ -358,15 +374,20 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
const isLiked = getMessageReactionState(target) === 'liked';
|
const isLiked = getMessageReactionState(target) === 'liked';
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'thread-node-actions';
|
actions.className = 'thread-node-actions channel-message-actions';
|
||||||
|
|
||||||
const likeButton = document.createElement('button');
|
const likeButton = document.createElement('button');
|
||||||
likeButton.type = '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');
|
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.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
if (!isLiked) {
|
if (!isLiked) {
|
||||||
@ -375,7 +396,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
}
|
}
|
||||||
await longPressFeel(event.currentTarget, 130);
|
await longPressFeel(event.currentTarget, 130);
|
||||||
likeButton.disabled = true;
|
likeButton.disabled = true;
|
||||||
likeButton.textContent = `❤️ ${likes}...`;
|
|
||||||
try {
|
try {
|
||||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -390,9 +410,14 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
|
|
||||||
const replyButton = document.createElement('button');
|
const replyButton = document.createElement('button');
|
||||||
replyButton.type = 'button';
|
replyButton.type = 'button';
|
||||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
replyButton.className = 'channel-action-item thread-reply-btn';
|
||||||
replyButton.textContent = `💬 ${replies}`;
|
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) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
openReplyModal({
|
openReplyModal({
|
||||||
navigate: handlers.navigate,
|
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');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
shareButton.className = 'secondary-btn thread-share-btn';
|
shareButton.className = 'channel-action-item thread-share-btn';
|
||||||
shareButton.textContent = '↗ Отправить';
|
shareButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||||
|
<span class="channel-action-label">Отправить</span>
|
||||||
|
`;
|
||||||
shareButton.addEventListener('click', async (event) => {
|
shareButton.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
@ -419,16 +440,28 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
|
|
||||||
const openThreadButton = document.createElement('button');
|
const openThreadButton = document.createElement('button');
|
||||||
openThreadButton.type = 'button';
|
openThreadButton.type = 'button';
|
||||||
openThreadButton.className = 'secondary-btn thread-open-btn';
|
openThreadButton.className = 'channel-action-item thread-open-btn';
|
||||||
openThreadButton.textContent = '🧵 В тред';
|
openThreadButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">#</span>
|
||||||
|
<span class="channel-action-label">Тред</span>
|
||||||
|
`;
|
||||||
openThreadButton.addEventListener('click', (event) => {
|
openThreadButton.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
handlers.onOpenThread(target);
|
handlers.onOpenThread(target);
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(likeButton, replyButton, changedButton, shareButton, openThreadButton);
|
actions.append(likeButton, replyButton, openThreadButton, shareButton);
|
||||||
card.append(actions);
|
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;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -766,36 +766,11 @@ function renderPostCard(post, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
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) {
|
if (channelData.reverseChannelMissingWarning) {
|
||||||
const reverseWarning = document.createElement('p');
|
const reverseWarning = document.createElement('p');
|
||||||
reverseWarning.className = 'channel-head-meta';
|
reverseWarning.className = 'channel-head-meta';
|
||||||
reverseWarning.textContent = channelData.reverseChannelMissingWarning;
|
reverseWarning.textContent = channelData.reverseChannelMissingWarning;
|
||||||
head.append(reverseWarning);
|
screen.append(reverseWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionButton = document.createElement('button');
|
const actionButton = document.createElement('button');
|
||||||
@ -850,9 +825,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
if (channelData.isOwnChannel || !channelData.isSubscribed) {
|
if (channelData.isOwnChannel || !channelData.isSubscribed) {
|
||||||
screen.append(head, actionButton, feed, backButton);
|
screen.append(actionButton, feed, backButton);
|
||||||
} else {
|
} else {
|
||||||
screen.append(head, feed, backButton);
|
screen.append(feed, backButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey);
|
applyPendingScroll(screen, routeKey);
|
||||||
@ -893,6 +868,17 @@ export function render({ navigate, route }) {
|
|||||||
statusBox.style.display = '';
|
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 rerender = () => {
|
||||||
const current = document.querySelector('section.channels-screen--channel');
|
const current = document.querySelector('section.channels-screen--channel');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
@ -990,12 +976,7 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
screen.append(
|
screen.append(header);
|
||||||
renderHeader({
|
|
||||||
title: '',
|
|
||||||
leftAction: { label: '<', onClick: () => navigateBack() },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
screen.append(statusBox);
|
screen.append(statusBox);
|
||||||
|
|
||||||
const skeleton = renderSkeleton(screen);
|
const skeleton = renderSkeleton(screen);
|
||||||
@ -1006,6 +987,15 @@ export function render({ navigate, route }) {
|
|||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
activeSelector = apiData?.selector || null;
|
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();
|
skeleton.remove();
|
||||||
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
|
||||||
onToggleLike: async (messageRef, action) => {
|
onToggleLike: async (messageRef, action) => {
|
||||||
|
|||||||
@ -2341,6 +2341,14 @@ textarea.input {
|
|||||||
backdrop-filter: blur(12px);
|
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 {
|
.thread-node-heading {
|
||||||
color: #f1dcab;
|
color: #f1dcab;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@ -2381,6 +2389,11 @@ textarea.input {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-screen--thread .thread-node-actions {
|
||||||
|
display: flex !important;
|
||||||
|
grid-template-columns: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.thread-node-level {
|
.thread-node-level {
|
||||||
--depth: 0;
|
--depth: 0;
|
||||||
margin-left: calc(var(--depth) * 12px);
|
margin-left: calc(var(--depth) * 12px);
|
||||||
@ -2389,11 +2402,16 @@ textarea.input {
|
|||||||
.thread-block {
|
.thread-block {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
border: 1px solid rgba(151, 174, 221, 0.2);
|
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));
|
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 {
|
.thread-block--ancestors > .section-title {
|
||||||
color: #b9cbef;
|
color: #b9cbef;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user