SHA256
Дизайн Артёма
This commit is contained in:
@@ -18,8 +18,9 @@ import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
@@ -30,6 +31,7 @@ const DIARY_DISPLAY_NAME = 'Дневник';
|
||||
const CHANNELS_VIEW_ALL = 'all';
|
||||
const CHANNELS_VIEW_OWNED = 'owned';
|
||||
const CHANNELS_VIEW_FOLLOWING = 'following';
|
||||
const listQueries = new Map();
|
||||
|
||||
function channelMenuIcon(name) {
|
||||
const paths = {
|
||||
@@ -120,6 +122,7 @@ function createChannelAvatar(channel = {}) {
|
||||
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
|
||||
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'lg',
|
||||
className: 'avatar-plain',
|
||||
title: channel.displayTitle || channel.title || channel.name || 'Канал',
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
@@ -847,16 +850,20 @@ function toListModel(groups) {
|
||||
];
|
||||
}
|
||||
|
||||
function renderEmptyState() {
|
||||
function renderEmptyState(listState, navigate) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||
if (!state.session.isAuthorized) {
|
||||
return wrap;
|
||||
}
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = listState.query ? 'Ничего не найдено' : listState.viewMode === CHANNELS_VIEW_FOLLOWING ? 'Пока нет подписок' : listState.viewMode === CHANNELS_VIEW_OWNED ? 'Здесь будут ваши каналы' : 'Откройте свой первый канал';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = 'У вас пока нет доступных каналов.';
|
||||
wrap.append(text);
|
||||
text.textContent = state.session.isAuthorized ? 'Найдите канал по имени автора или создайте свой.' : 'Войдите, чтобы видеть свои каналы и подписки.';
|
||||
const action = document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.className = 'primary-btn';
|
||||
action.textContent = state.session.isAuthorized ? 'Найти по @автору' : 'Войти';
|
||||
action.addEventListener('click', () => state.session.isAuthorized ? openChannelFinderModal({ navigate }) : navigate('login-view'));
|
||||
wrap.append(heading, text, action);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
@@ -962,7 +969,7 @@ function renderChannelMain(channel) {
|
||||
|
||||
const technical = document.createElement('p');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = channel.technicalLabel || `@${channel.ownerName || ''}/${channel.channelName || ''}`;
|
||||
technical.textContent = `@${channel.ownerName || 'автор'}`;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
@@ -976,7 +983,7 @@ function renderChannelMain(channel) {
|
||||
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
|
||||
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
|
||||
|
||||
previewLine.append(preview);
|
||||
|
||||
@@ -997,10 +1004,15 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true;
|
||||
if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels';
|
||||
return true;
|
||||
}).filter((channel) => {
|
||||
const query = String(listState.query || '').trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
return [channel.title, channel.ownerName, channel.channelName, channel.technicalLabel]
|
||||
.some((value) => String(value || '').toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
container.append(renderEmptyState());
|
||||
container.append(renderEmptyState(listState, navigate));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1010,10 +1022,12 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
const rerenderList = () => renderListContent({ screen, container, listState, navigate, refreshFeed });
|
||||
|
||||
filtered.forEach((channel) => {
|
||||
const row = document.createElement('article');
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'channel-row';
|
||||
const countersVisible = listState.revealedCounters.has(channel.id);
|
||||
row.classList.toggle('is-counters-visible', countersVisible);
|
||||
row.classList.toggle('has-unread', Number(channel.unreadCount || 0) > 0);
|
||||
|
||||
const avatar = createChannelAvatar(channel);
|
||||
|
||||
@@ -1047,6 +1061,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
|
||||
if (listState.disposed) return;
|
||||
const seq = ++listState.loadSeq;
|
||||
if (!silent) renderSkeletonList(contentEl, 5);
|
||||
|
||||
if (!state.session.isAuthorized) {
|
||||
@@ -1072,6 +1088,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
|
||||
|
||||
try {
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
if (listState.disposed || seq !== listState.loadSeq) return;
|
||||
|
||||
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
|
||||
// The server/API implementation is preserved so the feature can be restored later.
|
||||
@@ -1096,7 +1113,12 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate, silen
|
||||
navigate,
|
||||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||||
});
|
||||
if (Number.isFinite(listState.restorePosition)) {
|
||||
restoreChannelPosition(listState.restorePosition);
|
||||
listState.restorePosition = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (listState.disposed || seq !== listState.loadSeq) return;
|
||||
if (silent) return;
|
||||
setChannelsFeed(null, {});
|
||||
contentEl.innerHTML = '';
|
||||
@@ -1118,52 +1140,81 @@ export function render({ navigate, route, chrome }) {
|
||||
const notificationsState = readChannelNotificationsState();
|
||||
|
||||
const isGuest = !state.session.isAuthorized;
|
||||
const positionKey = `${state.session.login}:channels:${normalizeChannelsViewMode(route)}`;
|
||||
const listState = {
|
||||
restorePosition: readChannelPosition(positionKey),
|
||||
disposed: false,
|
||||
loadSeq: 0,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
channels: [],
|
||||
viewMode: normalizeChannelsViewMode(route),
|
||||
query: listQueries.get(positionKey) || '',
|
||||
};
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'channels-list-content';
|
||||
|
||||
const topTitle = document.createElement('button');
|
||||
topTitle.type = 'button';
|
||||
topTitle.className = 'channels-top-title channels-filter-title';
|
||||
|
||||
const channelsFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: topTitle,
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
|
||||
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
|
||||
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
|
||||
],
|
||||
});
|
||||
|
||||
const topBarEl = createTopBar({
|
||||
center: topTitle,
|
||||
title: 'Каналы',
|
||||
className: 'topbar--root',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Ещё действия',
|
||||
ariaLabel: 'Ещё действия',
|
||||
className: 'channels-top-more-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
|
||||
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
|
||||
],
|
||||
},
|
||||
label: '+',
|
||||
title: 'Создать канал',
|
||||
ariaLabel: 'Создать канал',
|
||||
className: 'channels-create-btn',
|
||||
onClick: () => navigate('add-channel-view'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'channels-list-controls';
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'channels-inline-search';
|
||||
searchWrap.innerHTML = '<span aria-hidden="true">⌕</span><span class="sr-only">Поиск каналов</span>';
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'search';
|
||||
searchInput.value = listState.query;
|
||||
searchInput.placeholder = 'В вашем списке';
|
||||
searchInput.setAttribute('aria-label', 'Найти канал или автора');
|
||||
const serverSearchButton = document.createElement('button');
|
||||
serverSearchButton.type = 'button';
|
||||
serverSearchButton.className = 'text-btn channels-server-search';
|
||||
serverSearchButton.textContent = 'По @автору';
|
||||
serverSearchButton.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
||||
searchWrap.append(searchInput, serverSearchButton);
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'tabs tabs--three';
|
||||
tabs.setAttribute('role', 'tablist');
|
||||
tabs.setAttribute('aria-label', 'Фильтр каналов');
|
||||
tabs.addEventListener('keydown', (event) => {
|
||||
const buttons = [...tabs.querySelectorAll('button')];
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
|
||||
buttons[next].focus();
|
||||
});
|
||||
[
|
||||
[CHANNELS_VIEW_ALL, 'Все'],
|
||||
[CHANNELS_VIEW_FOLLOWING, 'Подписки'],
|
||||
[CHANNELS_VIEW_OWNED, 'Мои'],
|
||||
].forEach(([mode, label]) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'tab-btn';
|
||||
button.textContent = label;
|
||||
button.setAttribute('role', 'tab');
|
||||
const selected = listState.viewMode === mode;
|
||||
button.classList.toggle('active', selected);
|
||||
button.setAttribute('aria-selected', String(selected));
|
||||
button.addEventListener('click', () => navigate(buildChannelsViewRoute(mode)));
|
||||
tabs.append(button);
|
||||
});
|
||||
controls.append(searchWrap, tabs);
|
||||
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
@@ -1192,13 +1243,16 @@ export function render({ navigate, route, chrome }) {
|
||||
refreshFeed: reloadFeed,
|
||||
});
|
||||
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
topMenuBtn.style.display = '';
|
||||
|
||||
};
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
listState.restorePosition = undefined;
|
||||
listState.query = searchInput.value;
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl);
|
||||
screen.append(controls, contentEl);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
@@ -1210,9 +1264,12 @@ export function render({ navigate, route, chrome }) {
|
||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
screen.cleanup = () => {
|
||||
if (listState.disposed) return;
|
||||
rememberChannelPosition(positionKey);
|
||||
listQueries.set(positionKey, listState.query);
|
||||
listState.disposed = true;
|
||||
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
|
||||
unsubscribeCountersChanged();
|
||||
channelsFilterMenu.destroy();
|
||||
};
|
||||
|
||||
return screen;
|
||||
|
||||
Reference in New Issue
Block a user