Обновить UI профиля и навигации

This commit is contained in:
2026-09-08 16:13:04 +03:00
parent 023d61a1e9
commit 7d27bfdcaf
90 changed files with 14931 additions and 13565 deletions
+26 -293
View File
@@ -16,13 +16,12 @@ import { makeShineChannelShortRoute } from '../services/shine-routes.js';
import { renderAvatar } from '../components/avatar-image.js';
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';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
@@ -311,7 +310,7 @@ function renderSuggestions(container, values, onPick) {
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.className = 'ui-button channel-search-item';
btn.textContent = value;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
@@ -581,7 +580,7 @@ function openChannelFinderModal({ navigate }) {
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.className = 'ui-button channel-search-item';
btn.textContent = value.label;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
@@ -998,252 +997,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
container.append(list);
}
function closeChannelMenu(listState, clearOpenMenuId = true) {
if (typeof listState.menuCleanup === 'function') {
listState.menuCleanup();
}
listState.menuCleanup = null;
const root = document.getElementById('modal-root');
if (root) {
const overlay = root.querySelector(`#${MENU_OVERLAY_ID}`);
if (overlay) overlay.remove();
}
if (clearOpenMenuId) {
listState.openMenuId = null;
}
}
function closeTopChannelsMenu(listState) {
if (typeof listState.topMenuCleanup === 'function') {
listState.topMenuCleanup();
}
listState.topMenuCleanup = null;
const root = document.getElementById('modal-root');
if (root) {
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
if (overlay) overlay.remove();
}
}
function openTopChannelsMenu({
listState,
anchorEl,
navigate,
onFindChannel,
}) {
closeTopChannelsMenu(listState);
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28));
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 250;
const titleAnchor = document.querySelector('.channels-filter-title');
const titleRect = titleAnchor?.getBoundingClientRect?.();
let top = (titleRect?.bottom || rect.bottom) + 7;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
}
const overlay = document.createElement('div');
overlay.id = TOP_MENU_OVERLAY_ID;
overlay.className = 'channels-menu-overlay';
const menu = document.createElement('div');
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.width = `${Math.round(menuWidth)}px`;
const items = [
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
];
items.forEach((item) => {
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
divider.style.height = '1px';
divider.style.background = 'rgba(255,255,255,0.08)';
divider.style.margin = '6px 0';
menu.append(divider);
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-menu-item';
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
btn.addEventListener('click', () => {
closeTopChannelsMenu(listState);
item.action?.();
});
menu.append(btn);
});
overlay.append(menu);
root.append(overlay);
const onOverlayClick = (event) => {
if (event.target === overlay) closeTopChannelsMenu(listState);
};
const onPeerDropdownOpen = (event) => {
if (event?.detail?.owner === anchorEl) return;
closeTopChannelsMenu(listState);
};
const onWindowResize = () => closeTopChannelsMenu(listState);
overlay.addEventListener('click', onOverlayClick);
window.addEventListener('resize', onWindowResize);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
listState.topMenuCleanup = () => {
overlay.removeEventListener('click', onOverlayClick);
window.removeEventListener('resize', onWindowResize);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
};
}
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
closeChannelMenu(listState, false);
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const menuWidth = Math.min(250, Math.max(220, window.innerWidth - 28));
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 210;
let top = rect.bottom + 8;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
}
const overlay = document.createElement('div');
overlay.id = MENU_OVERLAY_ID;
overlay.className = 'channels-menu-overlay';
const menu = document.createElement('div');
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.width = `${Math.round(menuWidth)}px`;
const canToggleSubscription = !channel.isOwnChannel;
const actionBtn = document.createElement('button');
actionBtn.type = 'button';
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
const actionLabel = document.createElement('span');
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
if (canToggleSubscription) {
actionLabel.textContent = channel.pending
? 'Выполняется...'
: channel.isSubscribed
? 'Отписаться'
: 'Подписаться';
actionBtn.disabled = !!channel.pending;
actionBtn.addEventListener('click', async (event) => {
event.stopPropagation();
if (channel.pending) return;
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
showToast('Сессия недействительна. Выполните вход заново.', { kind: 'error' });
return;
}
channel.pending = true;
actionBtn.disabled = true;
actionLabel.textContent = 'Выполняется...';
const nextSubscribed = !channel.isSubscribed;
try {
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: channel.ownerBlockchainName,
targetBlockNumber: channel.channelRootBlockNumber,
targetBlockHashHex: channel.channelRootBlockHash,
unfollow: !nextSubscribed,
});
channel.isSubscribed = nextSubscribed;
channel.pending = false;
softHaptic(15);
showToast(nextSubscribed ? 'Подписка на канал включена' : 'Подписка на канал отключена');
closeChannelMenu(listState);
await refreshFeed();
} catch (error) {
channel.pending = false;
actionBtn.disabled = false;
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
rerenderList();
}
});
} else {
actionLabel.textContent = 'Собственный канал';
actionBtn.disabled = true;
}
const toggleWrap = document.createElement('div');
toggleWrap.className = 'channel-menu-toggle';
const toggleLabel = document.createElement('span');
toggleLabel.className = 'channel-menu-toggle-label';
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = `channel-toggle-btn ${channel.notificationsEnabled ? 'is-on' : ''}`.trim();
toggleBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(toggleBtn);
channel.notificationsEnabled = !channel.notificationsEnabled;
const next = { ...listState.notificationsState, [channel.id]: channel.notificationsEnabled };
listState.notificationsState = next;
writeChannelNotificationsState(next);
toggleBtn.classList.toggle('is-on', channel.notificationsEnabled);
softHaptic(10);
});
toggleWrap.append(toggleLabel, toggleBtn);
menu.append(actionBtn, toggleWrap);
overlay.append(menu);
root.append(overlay);
const onOverlayClick = (event) => {
if (event.target === overlay) {
closeChannelMenu(listState);
rerenderList();
}
};
const onWindowResize = () => {
closeChannelMenu(listState);
rerenderList();
};
overlay.addEventListener('click', onOverlayClick);
window.addEventListener('resize', onWindowResize);
listState.menuCleanup = () => {
overlay.removeEventListener('click', onOverlayClick);
window.removeEventListener('resize', onWindowResize);
};
}
function renderChannelMain(channel) {
const main = document.createElement('div');
main.className = 'channel-row-main';
@@ -1350,7 +1103,6 @@ function updateBottomCta({ button }) {
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState);
renderSkeletonList(contentEl, 5);
if (!state.session.isAuthorized) {
@@ -1411,39 +1163,29 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--list';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
const createSuccessFlash = pullCreateSuccessFlash();
const notificationsState = readChannelNotificationsState();
const isGuest = !state.session.isAuthorized;
const listState = {
openMenuId: null,
topMenuCleanup: null,
notificationsState,
revealedCounters: new Set(),
channels: [],
menuCleanup: null,
viewMode: normalizeChannelsViewMode(route),
};
const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content';
const topBarEl = document.createElement('div');
topBarEl.className = 'channels-top-bar';
const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left';
const topTitle = document.createElement('button');
topTitle.type = 'button';
topTitle.className = 'channels-top-title channels-filter-title';
const channelsFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: topTitle,
align: 'left',
placement: 'bottom-start',
leftShift: 72,
minWidth: 220,
items: [
@@ -1453,28 +1195,25 @@ export function render({ navigate, route, chrome }) {
],
});
const topBarRight = document.createElement('div');
topBarRight.className = 'channels-top-right';
const topMenuBtn = document.createElement('button');
topMenuBtn.type = 'button';
topMenuBtn.className = 'icon-btn channels-top-more-btn';
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
topMenuBtn.title = 'Ещё действия';
topMenuBtn.append(createOverflowDots());
topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(topMenuBtn);
openTopChannelsMenu({
listState,
anchorEl: topMenuBtn,
navigate,
onFindChannel: () => openChannelFinderModal({ navigate }),
});
const topBarEl = createTopBar({
center: topTitle,
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') },
],
},
},
],
});
topBarRight.append(topMenuBtn);
topBarEl.append(topBarLeft, topTitle, topBarRight);
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
@@ -1483,9 +1222,7 @@ export function render({ navigate, route, chrome }) {
const rerenderList = () => {
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
renderListContent({
screen,
container: contentEl,
@@ -1496,7 +1233,6 @@ export function render({ navigate, route, chrome }) {
topTitle.textContent = channelsViewTitle(listState.viewMode);
topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
updateBottomCta({ button: bottomCta });
};
@@ -1516,10 +1252,7 @@ export function render({ navigate, route, chrome }) {
loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => {
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
channelsFilterMenu.destroy();
appScreen?.classList.remove('channels-scroll-clean');
channelsFilterMenu.destroy();
};
return screen;