SHA256
Каналы: типы 0/1/100/200, CreateChannel v3, state для chat200, новые API и деплой на prod
This commit is contained in:
@@ -11,6 +11,9 @@ import {
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_PUBLIC = 1;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const CHANNEL_TYPE_GROUP = 200;
|
||||
|
||||
function persistCreateSuccessFlash(message) {
|
||||
try {
|
||||
@@ -45,7 +48,15 @@ export function render({ navigate }) {
|
||||
form.innerHTML = `
|
||||
<strong class="channel-head-title">Создание канала</strong>
|
||||
<p class="channel-head-meta">Можно использовать только латиницу, цифры, _ и -.</p>
|
||||
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
|
||||
<p class="channel-head-meta">Длина названия: от 3 до 32 символов.</p>
|
||||
|
||||
<label for="channel-type">Тип канала</label>
|
||||
<select id="channel-type" class="input">
|
||||
<option value="${CHANNEL_TYPE_PUBLIC}">Публичный (1)</option>
|
||||
<option value="${CHANNEL_TYPE_PERSONAL}">Персональный (100)</option>
|
||||
<option value="${CHANNEL_TYPE_GROUP}">Групповой (200)</option>
|
||||
</select>
|
||||
<div id="channel-type-hint" class="meta-muted">Публичный канал видят все. Писать может только владелец.</div>
|
||||
|
||||
<label for="channel-name">Название канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="Например: my_channel-1" required />
|
||||
@@ -64,6 +75,8 @@ export function render({ navigate }) {
|
||||
`;
|
||||
|
||||
const nameEl = form.querySelector('#channel-name');
|
||||
const typeEl = form.querySelector('#channel-type');
|
||||
const typeHintEl = form.querySelector('#channel-type-hint');
|
||||
const descriptionEl = form.querySelector('#channel-description');
|
||||
const nameErrorEl = form.querySelector('#channel-name-error');
|
||||
const descriptionErrorEl = form.querySelector('#channel-description-error');
|
||||
@@ -79,10 +92,27 @@ export function render({ navigate }) {
|
||||
submitEl.disabled = submitInFlight;
|
||||
cancelEl.disabled = submitInFlight;
|
||||
nameEl.disabled = submitInFlight;
|
||||
typeEl.disabled = submitInFlight;
|
||||
descriptionEl.disabled = submitInFlight;
|
||||
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
||||
};
|
||||
|
||||
const updateTypeHint = () => {
|
||||
const typeCode = Number(typeEl.value || CHANNEL_TYPE_PUBLIC);
|
||||
if (typeCode === CHANNEL_TYPE_PERSONAL) {
|
||||
typeHintEl.textContent = 'Для персонального канала название должно быть login собеседника.';
|
||||
nameEl.placeholder = 'Например: aidar';
|
||||
return;
|
||||
}
|
||||
if (typeCode === CHANNEL_TYPE_GROUP) {
|
||||
typeHintEl.textContent = 'Для группового канала участников добавляют командами /.add и /.remove.';
|
||||
nameEl.placeholder = 'Например: team_room';
|
||||
return;
|
||||
}
|
||||
typeHintEl.textContent = 'Публичный канал видят все. Писать может только владелец.';
|
||||
nameEl.placeholder = 'Например: my_channel-1';
|
||||
};
|
||||
|
||||
const updateValidation = () => {
|
||||
const nameCheck = validateChannelDisplayName(nameEl.value);
|
||||
const descriptionCheck = validateDescription(descriptionEl.value);
|
||||
@@ -124,11 +154,22 @@ export function render({ navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
const channelType = Number(typeEl.value || CHANNEL_TYPE_PUBLIC);
|
||||
if (channelType === CHANNEL_TYPE_PERSONAL) {
|
||||
const targetLogin = normalizeChannelDisplayName(check.name);
|
||||
const foundUser = await authService.getUser(targetLogin);
|
||||
if (!foundUser?.exists) {
|
||||
throw new Error('Логин для персонального канала не найден.');
|
||||
}
|
||||
}
|
||||
|
||||
await authService.addBlockCreateChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
channelName: normalizeChannelDisplayName(check.name),
|
||||
channelDescription: normalizeChannelDescription(check.description),
|
||||
channelType,
|
||||
channelTypeVersion: 1,
|
||||
});
|
||||
|
||||
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
|
||||
@@ -141,9 +182,11 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
cancelEl.addEventListener('click', () => navigate('channels-list'));
|
||||
typeEl.addEventListener('change', updateTypeHint);
|
||||
|
||||
screen.append(form);
|
||||
nameEl.focus();
|
||||
updateTypeHint();
|
||||
updateValidation();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@ function renderPostCard(post, {
|
||||
navigate,
|
||||
routeKey,
|
||||
selector,
|
||||
canWrite,
|
||||
onToggleLike,
|
||||
onReply,
|
||||
onShare,
|
||||
@@ -579,52 +580,55 @@ function renderPostCard(post, {
|
||||
|
||||
if (!post.messageRef || !selector) return card;
|
||||
|
||||
const actionKey = makeReactionActionKey(post.messageRef);
|
||||
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'channel-message-actions';
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'channel-action-item channel-action-like';
|
||||
const isLiked = post.reactionState === 'liked';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||
`;
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
if (!isLiked) {
|
||||
const ok = window.confirm('Поставить лайк?');
|
||||
if (!ok) return;
|
||||
}
|
||||
revealCounters();
|
||||
await longPressFeel(event.currentTarget, 130);
|
||||
likeButton.disabled = true;
|
||||
const labelEl = likeButton.querySelector('.channel-action-label');
|
||||
if (labelEl) labelEl.textContent = 'Лайк...';
|
||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
||||
});
|
||||
if (canWrite) {
|
||||
const actionKey = makeReactionActionKey(post.messageRef);
|
||||
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'channel-action-item channel-action-reply';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⟳</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
`;
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
revealCounters();
|
||||
openReplyModal({
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'channel-action-item channel-action-like';
|
||||
const isLiked = post.reactionState === 'liked';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||
`;
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
if (!isLiked) {
|
||||
const ok = window.confirm('Поставить лайк?');
|
||||
if (!ok) return;
|
||||
}
|
||||
revealCounters();
|
||||
await longPressFeel(event.currentTarget, 130);
|
||||
likeButton.disabled = true;
|
||||
const labelEl = likeButton.querySelector('.channel-action-label');
|
||||
if (labelEl) labelEl.textContent = 'Лайк...';
|
||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
||||
});
|
||||
});
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'channel-action-item channel-action-reply';
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⟳</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
`;
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
revealCounters();
|
||||
openReplyModal({
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
});
|
||||
});
|
||||
actions.append(likeButton, replyButton);
|
||||
}
|
||||
|
||||
const openThreadButton = document.createElement('button');
|
||||
openThreadButton.type = 'button';
|
||||
@@ -656,7 +660,7 @@ function renderPostCard(post, {
|
||||
await onShare(route);
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton, openThreadButton, shareButton);
|
||||
actions.append(openThreadButton, shareButton);
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
@@ -704,6 +708,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
navigate,
|
||||
routeKey,
|
||||
selector: channelData.selector,
|
||||
canWrite: channelData.isOwnChannel,
|
||||
onToggleLike: handlers.onToggleLike,
|
||||
onReply: handlers.onReply,
|
||||
onShare: handlers.onShare,
|
||||
|
||||
@@ -15,6 +15,9 @@ export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const TAB_ORDER = ['feed', 'dialogs', 'my'];
|
||||
|
||||
function isChannelsDemoMode() {
|
||||
try {
|
||||
@@ -40,16 +43,18 @@ function normalizeLoginInput(value) {
|
||||
}
|
||||
|
||||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
||||
const ownerBch = summary?.channel?.ownerBlockchainName;
|
||||
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
|
||||
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
|
||||
if (ownerBch && rootBlockNumber != null) {
|
||||
return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`;
|
||||
}
|
||||
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
||||
const channelName = String(summary?.channel?.channelName || '').trim();
|
||||
if (ownerLogin && channelName) {
|
||||
return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`;
|
||||
}
|
||||
const ownerBch = summary?.channel?.ownerBlockchainName;
|
||||
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
|
||||
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
|
||||
if (!ownerBch || rootBlockNumber == null) return `channel-view/${fallbackId}`;
|
||||
return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`;
|
||||
return `channel-view/${fallbackId}`;
|
||||
}
|
||||
|
||||
function avatarLetterFromName(name = '') {
|
||||
@@ -526,7 +531,11 @@ function mapMockGroups() {
|
||||
const mapRow = (channel) => ({
|
||||
...channel,
|
||||
route: `channel-view/${channel.id}`,
|
||||
tabCategory: channel.kind === 'own' || channel.kind === 'own-personal' ? 'my' : 'subscriptions',
|
||||
tabCategory: channel.kind === 'own'
|
||||
? 'my'
|
||||
: channel.kind === 'own-personal'
|
||||
? 'dialogs'
|
||||
: 'feed',
|
||||
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
|
||||
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
|
||||
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
|
||||
@@ -545,11 +554,11 @@ function mapMockGroups() {
|
||||
|
||||
const followedUserChannels = mockChannels
|
||||
.filter((channel) => channel.kind === 'followed-user-channel')
|
||||
.map((item) => ({ ...mapRow(item), tabCategory: 'authors' }));
|
||||
.map((item) => ({ ...mapRow(item), tabCategory: 'feed' }));
|
||||
|
||||
const subscribedChannels = mockChannels
|
||||
.filter((channel) => channel.kind === 'subscribed')
|
||||
.map((item) => ({ ...mapRow(item), tabCategory: 'subscriptions' }));
|
||||
.map((item) => ({ ...mapRow(item), tabCategory: 'feed' }));
|
||||
|
||||
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
|
||||
}
|
||||
@@ -561,18 +570,14 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
|
||||
const channelName = summary?.channel?.channelName || '(без названия)';
|
||||
const channelDescription = String(summary?.channel?.channelDescription || '').trim();
|
||||
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
|
||||
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
|
||||
const isOwn = bucketKey === 'own';
|
||||
const tabCategory = bucketKey === 'own'
|
||||
? 'my'
|
||||
: bucketKey === 'followedUsers'
|
||||
? 'authors'
|
||||
: 'subscriptions';
|
||||
const tabCategory = isOwn
|
||||
? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my')
|
||||
: 'feed';
|
||||
|
||||
const title = isOwn
|
||||
? channelName
|
||||
: tabCategory === 'authors'
|
||||
? channelName
|
||||
: `${ownerLogin}/${channelName}`;
|
||||
const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
@@ -585,6 +590,8 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
title,
|
||||
channelName,
|
||||
channelDescription,
|
||||
channelTypeCode,
|
||||
channelTypeVersion,
|
||||
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
|
||||
messagesCount: Number(summary?.messagesCount || 0),
|
||||
unreadCount: Number(summary?.unreadCount || 0),
|
||||
@@ -597,20 +604,6 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
};
|
||||
}
|
||||
|
||||
function isSyntheticDefaultChannel(row) {
|
||||
if (!row || !row.isOwnChannel) return false;
|
||||
const name = String(row.channelName || '').trim();
|
||||
if (name !== '0') return false;
|
||||
|
||||
const hasDescription = Boolean(String(row.channelDescription || '').trim());
|
||||
const hasMessages = Number(row.messagesCount || 0) > 0;
|
||||
const hasTimestamp = Number(row.lastMessageAt || 0) > 0;
|
||||
const preview = String(row.messagePreview || '').trim();
|
||||
const hasCustomPreview = preview && preview !== 'Ждем ваших начинаний';
|
||||
|
||||
return !hasDescription && !hasMessages && !hasTimestamp && !hasCustomPreview;
|
||||
}
|
||||
|
||||
function pullCreateSuccessFlash() {
|
||||
try {
|
||||
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
|
||||
@@ -624,8 +617,7 @@ function pullCreateSuccessFlash() {
|
||||
function mapApiFeed(feed, notificationsState) {
|
||||
const index = {};
|
||||
const ownChannels = (feed?.ownedChannels || [])
|
||||
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState))
|
||||
.filter((row) => !isSyntheticDefaultChannel(row));
|
||||
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
|
||||
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
|
||||
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index, notificationsState));
|
||||
|
||||
@@ -645,12 +637,14 @@ function renderEmptyState(activeTab, navigate) {
|
||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
if (activeTab === 'subscriptions') {
|
||||
text.textContent = 'Вы пока не подписаны на каналы.';
|
||||
if (activeTab === 'feed') {
|
||||
text.textContent = 'Нет подписок и найденных каналов.';
|
||||
} else if (activeTab === 'dialogs') {
|
||||
text.textContent = 'У вас пока нет персональных каналов.';
|
||||
} else if (activeTab === 'my') {
|
||||
text.textContent = 'У вас пока нет каналов.';
|
||||
} else {
|
||||
text.textContent = 'Пока нет каналов авторов.';
|
||||
text.textContent = 'Пусто.';
|
||||
}
|
||||
wrap.append(text);
|
||||
|
||||
@@ -882,7 +876,7 @@ function renderChannelMain(channel, activeTab) {
|
||||
const main = document.createElement('div');
|
||||
main.className = 'channel-row-main';
|
||||
|
||||
if (activeTab === 'authors') {
|
||||
if (activeTab === 'feed') {
|
||||
const author = document.createElement('p');
|
||||
author.className = 'channel-row-author';
|
||||
author.textContent = `@${channel.ownerName}`;
|
||||
@@ -907,7 +901,7 @@ function renderChannelMain(channel, activeTab) {
|
||||
title.className = 'channel-row-title';
|
||||
title.textContent = activeTab === 'my' ? channel.channelName : channel.title;
|
||||
|
||||
if (activeTab === 'my' && channel.channelDescription) {
|
||||
if ((activeTab === 'my' || activeTab === 'dialogs') && channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'channel-row-description';
|
||||
desc.textContent = channel.channelDescription;
|
||||
@@ -1009,7 +1003,7 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
|
||||
const tab = listState.activeTab;
|
||||
const baseClass = `primary-btn channels-bottom-action${isTabEmpty && tab !== 'my' ? ' is-empty-lift' : ''}`;
|
||||
|
||||
if (tab === 'subscriptions') {
|
||||
if (tab === 'feed') {
|
||||
button.textContent = 'Подписаться на канал';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => openSimpleSubscribeModal({
|
||||
@@ -1021,16 +1015,23 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab === 'authors') {
|
||||
button.textContent = '🔍 Поиск каналов';
|
||||
if (tab === 'dialogs') {
|
||||
button.textContent = 'Новый персональный канал';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => openChannelFinderModal({ navigate });
|
||||
button.onclick = () => navigate('add-channel-view');
|
||||
return;
|
||||
}
|
||||
|
||||
button.textContent = 'Создать канал';
|
||||
if (tab === 'my') {
|
||||
button.textContent = 'Создать канал';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => navigate('add-channel-view');
|
||||
return;
|
||||
}
|
||||
|
||||
button.textContent = 'Поиск каналов';
|
||||
button.className = baseClass;
|
||||
button.onclick = () => navigate('add-channel-view');
|
||||
button.onclick = () => openChannelFinderModal({ navigate });
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
@@ -1065,7 +1066,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
export function render({ navigate, route }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--list';
|
||||
const appScreen = document.getElementById('app-screen');
|
||||
@@ -1075,7 +1076,9 @@ export function render({ navigate }) {
|
||||
const notificationsState = readChannelNotificationsState();
|
||||
|
||||
const listState = {
|
||||
activeTab: 'subscriptions',
|
||||
activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
|
||||
? String(route?.params?.mode).trim()
|
||||
: 'dialogs',
|
||||
openMenuId: null,
|
||||
notificationsState,
|
||||
revealedCounters: new Set(),
|
||||
@@ -1083,9 +1086,6 @@ export function render({ navigate }) {
|
||||
menuCleanup: null,
|
||||
};
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'channels-tabs channels-tabs--sticky';
|
||||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'channels-list-content';
|
||||
|
||||
@@ -1095,12 +1095,16 @@ export function render({ navigate }) {
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
const rerenderList = () => {
|
||||
const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab);
|
||||
try {
|
||||
const expectedHash = `#/channels-list/${listState.activeTab}`;
|
||||
if (window.location.hash !== expectedHash) {
|
||||
window.history.replaceState({}, '', expectedHash);
|
||||
}
|
||||
} catch {
|
||||
// ignore history errors
|
||||
}
|
||||
|
||||
tabItems.forEach((tab) => {
|
||||
const btn = tabs.querySelector(`[data-tab="${tab.key}"]`);
|
||||
if (btn) btn.classList.toggle('is-active', tab.key === listState.activeTab);
|
||||
});
|
||||
const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab);
|
||||
|
||||
closeChannelMenu(listState);
|
||||
|
||||
@@ -1121,28 +1125,28 @@ export function render({ navigate }) {
|
||||
});
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'subscriptions', label: 'Каналы' },
|
||||
{ key: 'my', label: 'Мои' },
|
||||
{ key: 'authors', label: 'Авторы' },
|
||||
];
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
contentEl.addEventListener('touchstart', (event) => {
|
||||
const p = event.changedTouches?.[0];
|
||||
if (!p) return;
|
||||
touchStartX = p.clientX;
|
||||
touchStartY = p.clientY;
|
||||
}, { passive: true });
|
||||
contentEl.addEventListener('touchend', (event) => {
|
||||
const p = event.changedTouches?.[0];
|
||||
if (!p) return;
|
||||
const dx = p.clientX - touchStartX;
|
||||
const dy = p.clientY - touchStartY;
|
||||
if (Math.abs(dx) < 45 || Math.abs(dx) < Math.abs(dy)) return;
|
||||
const index = TAB_ORDER.indexOf(listState.activeTab);
|
||||
if (index < 0) return;
|
||||
if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1];
|
||||
if (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1];
|
||||
rerenderList();
|
||||
}, { passive: true });
|
||||
|
||||
tabItems.forEach((tab) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.dataset.tab = tab.key;
|
||||
btn.className = `channels-tab-btn ${tab.key === listState.activeTab ? 'is-active' : ''}`;
|
||||
btn.textContent = tab.label;
|
||||
btn.addEventListener('click', () => {
|
||||
if (listState.activeTab === tab.key) return;
|
||||
listState.activeTab = tab.key;
|
||||
animatePress(btn);
|
||||
rerenderList();
|
||||
});
|
||||
tabs.append(btn);
|
||||
});
|
||||
|
||||
screen.append(tabs, contentEl, bottomCta);
|
||||
screen.append(contentEl, bottomCta);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
|
||||
Reference in New Issue
Block a user