UI: адаптация шапки «Связи», кнопка поиска каналов, локальный тестовый вход

- Связи: верхняя панель не выходит за границы экрана — кнопки назад/Найти/«?»
  не обрезаются на узких устройствах; заголовок сокращается вместо сдвига кнопок.
- Каналы: emoji-иконка поиска заменена на контурную кнопку-лупу в стиле панели
  (подсказка «Найти канал»); окно поиска без изменений.
- Локальный тестовый вход: на localhost/127.0.0.1/::1 — кнопка демо-входа
  (сеанс local-tester, автономные локальные состояния ЛС/каналов/профиля, без
  сервера и пароля); на shineup.me и тестовых доменах кнопка отсутствует.
- Заметки на ручную проверку добавлены в Dev_Docs/Pending_Features.
- VERSION: client 1.2.262.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pixel
2026-07-15 12:40:16 +03:00
co-authored by Claude Opus 4.8
parent 127c561a41
commit 8c4997ee2e
19 changed files with 1066 additions and 143 deletions
+27 -12
View File
@@ -754,22 +754,25 @@ function renderErrorState(container, error, onRetry) {
container.append(errCard);
}
function renderDemoFallback(container, navigate, error, onRetry) {
function renderDemoFallback(container, navigate, error, onRetry, { localOnly = false } = {}) {
const info = document.createElement('div');
info.className = 'card stack';
info.innerHTML = `
<strong>Включен демо-режим</strong>
<p class="meta-muted">Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.</p>
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
<strong>${localOnly ? 'Локальный тестовый режим' : 'Включен демо-режим'}</strong>
<p class="meta-muted">${localOnly
? 'Показаны мок-каналы. Подключение к серверу отключено.'
: 'Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.'}</p>
${localOnly ? '' : `<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>`}
`;
const retry = document.createElement('button');
retry.className = 'secondary-btn';
retry.type = 'button';
retry.textContent = 'Повторить запрос к серверу';
retry.addEventListener('click', onRetry);
info.append(retry);
if (!localOnly && typeof onRetry === 'function') {
const retry = document.createElement('button');
retry.className = 'secondary-btn';
retry.type = 'button';
retry.textContent = 'Повторить запрос к серверу';
retry.addEventListener('click', onRetry);
info.append(retry);
}
container.append(info);
const groups = mapMockGroups();
@@ -1090,6 +1093,14 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
return;
}
if (state.session.isLocalDemo) {
setChannelsFeed(null, {});
listState.channels = [];
contentEl.innerHTML = '';
renderDemoFallback(contentEl, navigate, null, null, { localOnly: true });
return;
}
try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
const groups = mapApiFeed(feed, listState.notificationsState);
@@ -1160,8 +1171,12 @@ export function render({ navigate, route }) {
const findChannelBtn = document.createElement('button');
findChannelBtn.type = 'button';
findChannelBtn.className = 'icon-btn channels-top-search-btn';
findChannelBtn.textContent = '🔎';
findChannelBtn.setAttribute('aria-label', 'Найти канал');
findChannelBtn.title = 'Найти канал';
const findChannelIcon = document.createElement('span');
findChannelIcon.className = 'channels-search-icon';
findChannelIcon.setAttribute('aria-hidden', 'true');
findChannelBtn.append(findChannelIcon);
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
const createInMyBtn = document.createElement('button');
+18 -1
View File
@@ -1,5 +1,10 @@
import { renderHeader } from '../components/header.js';
import { saveEntrySettings, state } from '../state.js';
import {
authorizeLocalDemoSession,
isLocalDemoAvailable,
saveEntrySettings,
state,
} from '../state.js';
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js';
export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false };
@@ -141,6 +146,18 @@ export function render({ navigate }) {
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
if (isLocalDemoAvailable()) {
const localDemoButton = document.createElement('button');
localDemoButton.className = 'ghost-btn preauth-local-demo-btn';
localDemoButton.type = 'button';
localDemoButton.textContent = 'Открыть локальный тестовый режим';
localDemoButton.addEventListener('click', () => {
if (!authorizeLocalDemoSession()) return;
navigate('messages-list');
});
actions.append(localDemoButton);
}
const serverUiButton = document.createElement('button');
serverUiButton.className = 'ghost-btn';
serverUiButton.type = 'button';
+3 -9
View File
@@ -4,7 +4,7 @@ export const pageMeta = { id: 'login-view', title: 'Войти', showAppChrome:
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack auth-screen auth-screen--lower';
screen.className = 'stack auth-screen auth-screen--lower login-choice-screen';
const loginButton = document.createElement('button');
loginButton.className = 'ghost-btn';
@@ -22,20 +22,14 @@ export function render({ navigate }) {
actions.className = 'auth-actions login-actions-wide';
actions.append(loginButton, otherDeviceButton);
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
const panel = document.createElement('section');
panel.className = 'login-panel stack';
panel.innerHTML = '<h1 class="login-panel-title">Войти</h1>';
panel.append(actions, backButton);
panel.append(actions);
screen.append(
renderHeader({
title: 'Войти',
title: '',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
panel,
+8
View File
@@ -132,6 +132,14 @@ export function render({ navigate }) {
}
async function loadList() {
if (state.session.isLocalDemo) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Локальный тестовый режим: реальные сообщения и контакты не загружаются.';
list.replaceChildren(empty);
return;
}
try {
const relations = await loadCurrentRelations();
const contacts = relations.outContacts || [];
+1 -1
View File
@@ -96,7 +96,7 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Редактирование профиля',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
+58 -22
View File
@@ -7,7 +7,6 @@ import {
} from '../services/user-profile-params.js';
import { buildIdentityLines } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -30,20 +29,49 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;');
}
function renderProfileInfoText(text) {
const [intro, details] = String(text || '').split(/\n\n/, 2);
if (!details) {
return `<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>`;
}
const [sectionTitle, ...items] = details.split('\n').filter(Boolean);
return `
<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>
<section class="profile-info-modal__section" aria-label="${escapeHtml(sectionTitle)}">
<p class="profile-info-modal__section-title">${escapeHtml(sectionTitle)}</p>
<ol class="profile-info-modal__list">
${items.map((item) => `<li>${escapeHtml(item.replace(/^\d+\)\s*/, ''))}</li>`).join('')}
</ol>
</section>
`;
}
function openProfileInfoModal({ title, text }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="profile-info-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title)}</h3>
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
</div>
<div class="modal profile-info-modal" id="profile-info-modal" role="dialog" aria-modal="true" aria-labelledby="profile-info-title">
<section class="modal-card profile-info-modal__card stack">
<header class="profile-info-modal__header">
<div>
<p class="profile-info-modal__eyebrow">Информация</p>
<h3 class="modal-title profile-info-modal__title" id="profile-info-title">${escapeHtml(title)}</h3>
</div>
<button class="profile-info-modal__close" type="button" id="profile-info-close-icon" aria-label="Закрыть" title="Закрыть">&times;</button>
</header>
<div class="profile-info-modal__content">
${renderProfileInfoText(text)}
</div>
<footer class="profile-info-modal__footer">
<button class="secondary-btn profile-info-modal__confirm" type="button" id="profile-info-close">Готово</button>
</footer>
</section>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#profile-info-close')?.addEventListener('click', close);
root.querySelector('#profile-info-close-icon')?.addEventListener('click', close);
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'profile-info-modal') close();
});
@@ -73,22 +101,9 @@ export function render({ navigate }) {
const topActions = document.createElement('div');
topActions.className = 'profile-top-actions';
topActions.innerHTML = `
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="edit">Редактировать профиль</button>
<button class="ghost-btn profile-top-action-btn" type="button" data-top-action="settings">Настройки</button>
<button class="ghost-btn profile-top-action-btn profile-top-menu-btn" type="button" data-top-action="settings" aria-label="Настройки" title="Настройки"></button>
`;
topActions.querySelector('[data-top-action="edit"]')?.addEventListener('click', () => navigate('profile-edit-view'));
topActions.querySelector('[data-top-action="settings"]')?.addEventListener('click', () => navigate('settings-view'));
screen.append(topActions);
const bottomActions = document.createElement('div');
bottomActions.className = 'profile-bottom-actions';
bottomActions.innerHTML = `
<button class="ghost-btn profile-top-action-btn" type="button" data-bottom-action="wallet">Кошелёк</button>
<button class="ghost-btn profile-top-action-btn profile-links-two-line" type="button" data-bottom-action="links">Показать\nсвязи</button>
`;
bottomActions.querySelector('[data-bottom-action="wallet"]')?.addEventListener('click', () => navigate('wallet-view'));
bottomActions.querySelector('[data-bottom-action="links"]')?.addEventListener('click', () => navigate(makeProfileLinksRoute(login)));
screen.append(bottomActions);
const card = document.createElement('div');
card.className = 'card stack profile-main-card';
@@ -203,6 +218,27 @@ export function render({ navigate }) {
}
async function refreshProfileSnapshot() {
if (state.session.isLocalDemo) {
currentFields = [
{ key: 'first_name', label: 'Имя', value: 'Тестовый' },
{ key: 'last_name', label: 'Фамилия', value: 'Пользователь' },
{ key: 'address', label: 'Адрес', value: 'Локальный режим' },
{ key: 'website', label: 'Веб', value: '127.0.0.1' },
{ key: 'phone', label: 'Телефон', value: profile.phone },
];
currentToggles = [
{ key: 'official', enabled: false },
{ key: 'shine', enabled: false },
];
currentGender = 'unknown';
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
syncIdentity();
updateAvatarUi();
updateTogglesUi();
renderFields(currentFields);
return;
}
try {
const snapshot = await loadProfileSnapshot(login);
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
@@ -218,7 +254,7 @@ export function render({ navigate }) {
}
}
card.append(topRow, badgesRow, listWrap);
card.append(topActions, topRow, badgesRow, listWrap);
screen.append(card);
updateAvatarUi();
+33 -62
View File
@@ -14,7 +14,6 @@ import {
PASSWORD_WORDS_COUNT,
} from '../services/password-words.js';
import { openRegistrationFaq } from './registration-faq-view.js';
import { defaultServerHttp } from '../deploy-config.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
@@ -53,21 +52,18 @@ function createWordsLayout({ words, onInput }) {
hint.textContent =
'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.';
const preview = document.createElement('p');
preview.className = 'status-line';
section.append(grid, hint);
return { section, inputs, preview };
return { section, inputs };
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack registration-screen';
clearAuthMessages();
const form = document.createElement('div');
form.className = 'card stack';
form.className = 'card stack registration-form';
let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single';
let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords);
@@ -95,7 +91,6 @@ export function render({ navigate }) {
const {
section: wordsSection,
inputs: wordInputs,
preview: wordsPreview,
} = createWordsLayout({
words: passwordWords,
onInput: (index, value) => {
@@ -118,53 +113,22 @@ export function render({ navigate }) {
passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel);
const statusText = document.createElement('p');
statusText.className = 'meta-muted';
statusText.textContent = 'Проверка логина: не выполнена';
const serverNotice = document.createElement('div');
serverNotice.className = 'card stack';
serverNotice.innerHTML = `
<p class="field-label">Первый сервер SHiNE</p>
<p class="meta-muted">При регистрации адресом вашего первого сервера будет: <strong>${state.entrySettings.shineServerHttp || defaultServerHttp}</strong>.</p>
`;
const faqCard = document.createElement('div');
faqCard.className = 'card stack registration-faq-card';
const faqTitle = document.createElement('p');
faqTitle.className = 'field-label';
faqTitle.textContent = 'Частые вопросы перед регистрацией';
const faqText = document.createElement('p');
faqText.className = 'meta-muted';
faqText.textContent = 'Если хотите подробнее понять схему деривации, ключи, первый сервер и формат 12 слов, откройте отдельный экран с вопросами.';
statusText.className = 'status-line registration-login-status';
statusText.style.display = 'none';
const faqButton = document.createElement('button');
faqButton.className = 'ghost-btn';
faqButton.className = 'registration-faq-link';
faqButton.type = 'button';
faqButton.textContent = 'Частые вопросы';
faqButton.textContent = 'Вопросы о регистрации';
faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation'));
faqCard.append(faqTitle, faqText, faqButton);
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
formError.style.display = 'none';
const checkButton = document.createElement('button');
checkButton.className = 'ghost-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить логин';
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('start-view'));
const nextButton = document.createElement('button');
nextButton.className = 'primary-btn';
nextButton.type = 'button';
@@ -172,10 +136,12 @@ export function render({ navigate }) {
let passwordField = null;
const passwordLengthText = document.createElement('p');
passwordLengthText.className = 'status-line';
passwordLengthText.className = 'password-length-hint';
let lastCheckedLogin = '';
let lastCheckedFree = false;
let lastCheckedClassName = '';
let loginCheckTimer = null;
let loginCheckRunId = 0;
let generationRunId = 0;
function getCurrentPassword() {
@@ -185,7 +151,6 @@ export function render({ navigate }) {
function updateWordsPreview() {
const password = getCurrentPassword();
const text = `Итоговая длина пароля: ${password.length} символов.`;
wordsPreview.textContent = text;
passwordLengthText.textContent = text;
}
@@ -207,7 +172,8 @@ export function render({ navigate }) {
async function runAvailabilityCheck() {
const login = loginInput.value.trim();
if (!login) {
statusText.textContent = 'Введите логин';
statusText.textContent = '';
statusText.style.display = 'none';
formError.style.display = 'none';
return false;
}
@@ -229,12 +195,15 @@ export function render({ navigate }) {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'status-line is-unavailable';
}
statusText.style.display = '';
formError.style.display = 'none';
return lastCheckedFree && lastCheckedClassName === 'free';
}
checkButton.disabled = true;
checkButton.textContent = 'Проверка...';
const runId = ++loginCheckRunId;
statusText.className = 'status-line registration-login-status';
statusText.textContent = 'Проверяем логин...';
statusText.style.display = '';
try {
const check = await checkLoginExistsOnSolana({
login,
@@ -255,6 +224,7 @@ export function render({ navigate }) {
precheckWarning = formatSolanaErrorDetails(precheckError);
}
}
if (runId !== loginCheckRunId) return false;
lastCheckedLogin = login;
lastCheckedFree = isFree;
lastCheckedClassName = className;
@@ -276,25 +246,31 @@ export function render({ navigate }) {
statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌';
statusText.className = 'status-line is-unavailable';
}
statusText.style.display = '';
formError.style.display = 'none';
return isFree && className === 'free';
} catch (error) {
if (runId !== loginCheckRunId) return false;
const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error);
statusText.textContent = `${base}. Детали: ${details}`;
statusText.className = 'status-line is-unavailable';
statusText.style.display = '';
return false;
} finally {
checkButton.disabled = false;
checkButton.textContent = 'Проверить логин';
}
}
checkButton.addEventListener('click', runAvailabilityCheck);
loginInput.addEventListener('input', () => {
syncDraftState();
lastCheckedLogin = '';
loginCheckRunId += 1;
if (loginCheckTimer) window.clearTimeout(loginCheckTimer);
statusText.textContent = '';
statusText.style.display = 'none';
if (!loginInput.value.trim()) return;
loginCheckTimer = window.setTimeout(() => {
runAvailabilityCheck();
}, 450);
});
passwordInput.addEventListener('input', () => {
@@ -353,8 +329,6 @@ export function render({ navigate }) {
});
function renderInputStage() {
serverNotice.style.display = '';
faqCard.style.display = '';
form.innerHTML = `
<label class="stack"><span class="field-label">Логин</span></label>
<label class="stack registration-password-single"><span class="field-label">Пароль</span></label>
@@ -362,17 +336,15 @@ export function render({ navigate }) {
const loginField = form.children[0];
passwordField = form.children[1];
loginField.append(loginInput);
passwordField.append(passwordInput);
form.append(passwordModeToggle, passwordLengthText, wordsSection, statusText, checkButton, formError);
passwordField.append(passwordInput, passwordLengthText);
form.append(passwordModeToggle, wordsSection, statusText, formError);
actions.innerHTML = '';
actions.append(backButton, nextButton);
actions.append(nextButton);
updatePasswordModeVisibility();
syncDraftState();
}
async function startGenerationStage() {
serverNotice.style.display = 'none';
faqCard.style.display = 'none';
const runId = ++generationRunId;
form.innerHTML = '';
@@ -478,8 +450,7 @@ export function render({ navigate }) {
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
form,
serverNotice,
faqCard,
faqButton,
actions,
);
+10 -1
View File
@@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js';
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut } from '../state.js';
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut, state } from '../state.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -40,6 +41,9 @@ export function render({ navigate }) {
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<button class="text-btn" type="button" id="settings-profile-edit">Редактировать профиль</button>
<button class="text-btn" type="button" id="settings-wallet">Кошелёк</button>
<button class="text-btn" type="button" id="settings-links">Показать связи</button>
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
@@ -47,6 +51,11 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
card.querySelector('#settings-profile-edit').addEventListener('click', () => navigate('profile-edit-view'));
card.querySelector('#settings-wallet').addEventListener('click', () => navigate('wallet-view'));
card.querySelector('#settings-links').addEventListener('click', () => {
navigate(makeProfileLinksRoute(state.session.login));
});
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
+12 -8
View File
@@ -6,45 +6,49 @@ export function render({ navigate }) {
clearStartHint();
const screen = document.createElement('section');
screen.className = 'auth-screen stack';
screen.className = 'auth-screen auth-screen--welcome';
const logoWrap = document.createElement('div');
logoWrap.className = 'auth-logo-wrap';
const logo = document.createElement('img');
logo.className = 'auth-logo';
logo.src = './img/logo.jpg';
logo.src = './img/shine-logo-transparent-final.png';
logo.alt = 'Логотип Сияние';
logoWrap.append(logo);
const title = document.createElement('h1');
title.className = 'auth-brand';
title.textContent = 'Сияние';
const actions = document.createElement('div');
actions.className = 'auth-actions';
actions.className = 'auth-actions shine-actions';
const loginButton = document.createElement('button');
loginButton.className = 'primary-btn';
loginButton.className = 'shine-btn shine-btn--primary';
loginButton.type = 'button';
loginButton.textContent = 'Войти';
loginButton.addEventListener('click', () => navigate('login-view'));
const registerButton = document.createElement('button');
registerButton.className = 'ghost-btn';
registerButton.className = 'shine-btn shine-btn--register';
registerButton.type = 'button';
registerButton.textContent = 'Зарегистрироваться';
registerButton.addEventListener('click', () => navigate('register-view'));
const guestViewButton = document.createElement('button');
guestViewButton.className = 'ghost-btn';
guestViewButton.className = 'shine-btn shine-btn--view';
guestViewButton.type = 'button';
guestViewButton.textContent = 'Только просмотр';
guestViewButton.addEventListener('click', () => navigate('network-view'));
const settingsButton = document.createElement('button');
settingsButton.className = 'ghost-btn';
settingsButton.className = 'shine-btn shine-btn--settings';
settingsButton.type = 'button';
settingsButton.textContent = 'Настройки';
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, guestViewButton, settingsButton);
screen.append(logo, title, actions);
screen.append(logoWrap, title, actions);
return screen;
}
+1 -1
View File
@@ -81,7 +81,7 @@ export function render({ navigate }) {
screen.append(
renderHeader({
title: 'Кошелёк',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
content,
status,