Дизайн Артёма

This commit is contained in:
AidarKC
2026-09-22 14:09:02 +03:00
parent cae64639bd
commit 8b12760dde
40 changed files with 3114 additions and 5008 deletions
+103 -46
View File
@@ -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;