SHA256
UI v4: новый стиль по макету V3/V4 и доводка экранов
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки. Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги), сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов – переключатель; уведомления – список как в чатах; фото – просмотр на весь экран. Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода, второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов), иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса. Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
authService,
|
||||
authorizeSession,
|
||||
@@ -23,45 +24,16 @@ import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-v
|
||||
import { clearStoredMessages } from '../services/message-store.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'login-other-device-view', title: 'Войти через другое устройство', showAppChrome: false };
|
||||
|
||||
function setStatus(statusEl, message, kind = 'info') {
|
||||
statusEl.classList.toggle('is-unavailable', kind === 'error');
|
||||
statusEl.classList.toggle('is-available', kind !== 'error');
|
||||
statusEl.textContent = message;
|
||||
statusEl.style.display = message ? '' : 'none';
|
||||
}
|
||||
|
||||
function codeCardHtml() {
|
||||
return `
|
||||
<div class="card stack">
|
||||
<p class="field-label">Код подключения</p>
|
||||
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.12em;">00 00 00 00 00</div>
|
||||
<p class="meta-muted" id="pairing-status-hint">Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».</p>
|
||||
<p class="meta-muted" id="pairing-online-hint"></p>
|
||||
<p class="meta-muted" id="pairing-expire-hint"></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
export const pageMeta = { id: 'login-other-device-view', title: 'Вход с другого устройства', showAppChrome: false };
|
||||
|
||||
function formatRemaining(ms) {
|
||||
const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000));
|
||||
const minutes = Math.floor(safe / 60);
|
||||
const seconds = safe % 60;
|
||||
return `${minutes} мин ${seconds} сек`;
|
||||
}
|
||||
|
||||
function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) {
|
||||
resultWrap.style.display = 'none';
|
||||
shortCodeEl.textContent = formatPairingShortCode('');
|
||||
statusHintEl.textContent = 'Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».';
|
||||
onlineHintEl.textContent = '';
|
||||
expireHintEl.textContent = '';
|
||||
const safe = Math.max(0, Math.ceil(Number(ms || 0) / 1000));
|
||||
return `${Math.floor(safe / 60)}:${String(safe % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack auth-screen auth-screen--other-device';
|
||||
screen.className = 'stack pairing-login';
|
||||
let pollTimer = 0;
|
||||
let countdownTimer = 0;
|
||||
let activePairingId = '';
|
||||
@@ -71,67 +43,72 @@ export function render({ navigate }) {
|
||||
|
||||
clearAuthMessages();
|
||||
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'login-panel login-panel--wide stack';
|
||||
panel.innerHTML = `
|
||||
<button class="login-panel-inline-back" type="button" id="login-other-device-back" aria-label="Назад">
|
||||
<span aria-hidden="true">←</span>
|
||||
<span>Войти через другое устройство</span>
|
||||
</button>
|
||||
const who = document.createElement('p');
|
||||
who.className = 'pairing-who';
|
||||
|
||||
// Код и то, что с ним делать.
|
||||
const codeBlock = document.createElement('div');
|
||||
codeBlock.className = 'pairing-code-block';
|
||||
codeBlock.innerHTML = `
|
||||
<div class="pairing-code-card">
|
||||
<p class="pairing-code-card__label">Код для входа</p>
|
||||
<div class="pairing-code" id="pairing-short-code"></div>
|
||||
<div class="pairing-timer" aria-live="off">
|
||||
<div class="pairing-timer__bar"><span id="pairing-timer-fill"></span></div>
|
||||
<span class="pairing-timer__text" id="pairing-timer-text"></span>
|
||||
</div>
|
||||
<p class="pairing-online" id="pairing-online"></p>
|
||||
</div>
|
||||
<ol class="pairing-steps">
|
||||
<li>Откройте Сияние, где вы уже вошли</li>
|
||||
<li>Настройки → Устройства</li>
|
||||
<li>«Подключить по коду» – введите код</li>
|
||||
</ol>
|
||||
<p class="pairing-waiting"><span class="pairing-spinner" aria-hidden="true"></span>Ждём подтверждения…</p>
|
||||
`;
|
||||
|
||||
panel.querySelector('#login-other-device-back')?.addEventListener('click', () => { void cancelActivePairingAndBack(); });
|
||||
// Сообщение о ходе дела или об ошибке — вместо кода.
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'pairing-notice';
|
||||
notice.setAttribute('role', 'status');
|
||||
const noticeSpinner = document.createElement('span');
|
||||
noticeSpinner.className = 'pairing-spinner';
|
||||
noticeSpinner.setAttribute('aria-hidden', 'true');
|
||||
const noticeText = document.createElement('p');
|
||||
notice.append(noticeSpinner, noticeText);
|
||||
|
||||
const formCard = document.createElement('div');
|
||||
formCard.className = 'card stack login-device-preparation';
|
||||
formCard.innerHTML = `
|
||||
<p class="auth-copy" id="pair-login-label"></p>
|
||||
<input id="pair-login" type="hidden" value="" />
|
||||
<input id="pair-use-password" type="checkbox" hidden />
|
||||
<label class="stack" id="pair-password-wrap" style="display:none;">
|
||||
<span class="field-label">Дополнительный пароль подключения</span>
|
||||
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
|
||||
</label>
|
||||
<button class="primary-btn" type="button" id="pair-start-btn" style="display:none;">Получить код</button>
|
||||
<p class="meta-muted" id="pair-mode-hint">Создаём код для входа…</p>
|
||||
const passwordWrap = document.createElement('label');
|
||||
passwordWrap.className = 'stack pairing-password';
|
||||
passwordWrap.innerHTML = `
|
||||
<span class="field-label">Пароль подключения</span>
|
||||
<input class="input" type="password" autocomplete="current-password" placeholder="Задан на другом устройстве" />
|
||||
`;
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
const startBtn = document.createElement('button');
|
||||
startBtn.className = 'primary-btn';
|
||||
startBtn.type = 'button';
|
||||
startBtn.textContent = 'Получить код';
|
||||
|
||||
const resultWrap = document.createElement('div');
|
||||
resultWrap.className = 'stack';
|
||||
resultWrap.style.display = 'none';
|
||||
resultWrap.innerHTML = codeCardHtml();
|
||||
const loginInput = { value: String(state.loginDraft.login || '').trim() };
|
||||
const usePasswordInput = { checked: false };
|
||||
const passwordInput = passwordWrap.querySelector('input');
|
||||
who.textContent = loginInput.value ? `Вход для @${loginInput.value}` : '';
|
||||
const shortCodeEl = codeBlock.querySelector('#pairing-short-code');
|
||||
const timerFillEl = codeBlock.querySelector('#pairing-timer-fill');
|
||||
const timerTextEl = codeBlock.querySelector('#pairing-timer-text');
|
||||
const onlineEl = codeBlock.querySelector('#pairing-online');
|
||||
let activeStartedAtMs = 0;
|
||||
|
||||
const loginInput = formCard.querySelector('#pair-login');
|
||||
const loginLabelEl = formCard.querySelector('#pair-login-label');
|
||||
const usePasswordInput = formCard.querySelector('#pair-use-password');
|
||||
const passwordInput = formCard.querySelector('#pair-password');
|
||||
const startBtn = formCard.querySelector('#pair-start-btn');
|
||||
const modeHintEl = formCard.querySelector('#pair-mode-hint');
|
||||
|
||||
loginInput.value = String(state.loginDraft.login || '').trim();
|
||||
loginLabelEl.textContent = loginInput.value ? `Вход для @${loginInput.value}` : '';
|
||||
const shortCodeEl = resultWrap.querySelector('#pairing-short-code');
|
||||
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
|
||||
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
|
||||
const expireHintEl = resultWrap.querySelector('#pairing-expire-hint');
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'ghost-btn';
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.textContent = 'Отмена';
|
||||
cancelBtn.style.display = 'none';
|
||||
|
||||
const syncPasswordUi = () => {
|
||||
const usePassword = !!usePasswordInput.checked;
|
||||
passwordInput.parentElement.style.display = usePassword ? '' : 'none';
|
||||
startBtn.style.display = usePassword ? '' : 'none';
|
||||
modeHintEl.textContent = usePassword
|
||||
? 'На доверённом устройстве для входа по коду включён дополнительный пароль.'
|
||||
: 'Код можно подтвердить на уже подключённом устройстве. Если оно сейчас не в сети, заявка будет ждать до истечения срока.';
|
||||
if (!usePassword) passwordInput.value = '';
|
||||
// Этапы: creating → code → approving; отдельно password (нужен пароль) и retry (истекло, отклонено, ошибка).
|
||||
const showStage = (stage, message = '', { error = false, action = '' } = {}) => {
|
||||
codeBlock.hidden = stage !== 'code';
|
||||
passwordWrap.hidden = stage !== 'password';
|
||||
notice.hidden = stage === 'code' || (stage === 'password' && !message);
|
||||
noticeSpinner.hidden = !(stage === 'creating' || stage === 'approving');
|
||||
noticeText.textContent = message;
|
||||
notice.classList.toggle('is-error', error);
|
||||
startBtn.hidden = !(stage === 'password' || stage === 'retry');
|
||||
if (action) startBtn.textContent = action;
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
@@ -156,18 +133,17 @@ export function render({ navigate }) {
|
||||
activePairingId = '';
|
||||
activeExpiresAtMs = 0;
|
||||
startBtn.disabled = false;
|
||||
startBtn.style.display = '';
|
||||
startBtn.textContent = 'Получить новый код';
|
||||
cancelBtn.style.display = 'none';
|
||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
||||
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
||||
showStage('retry', 'Время ожидания истекло', { error: true, action: 'Получить новый код' });
|
||||
return;
|
||||
}
|
||||
expireHintEl.textContent = `Код действителен ещё ${formatRemaining(leftMs)}.`;
|
||||
const totalMs = Math.max(1, activeExpiresAtMs - activeStartedAtMs);
|
||||
timerFillEl.style.width = `${Math.min(100, (leftMs / totalMs) * 100).toFixed(1)}%`;
|
||||
timerTextEl.textContent = formatRemaining(leftMs);
|
||||
};
|
||||
|
||||
const startCountdown = (expiresAtMs) => {
|
||||
activeExpiresAtMs = Number(expiresAtMs || 0);
|
||||
activeStartedAtMs = Date.now();
|
||||
stopCountdown();
|
||||
updateCountdown();
|
||||
countdownTimer = window.setInterval(updateCountdown, 1000);
|
||||
@@ -178,8 +154,6 @@ export function render({ navigate }) {
|
||||
stopCountdown();
|
||||
activePairingId = '';
|
||||
activeExpiresAtMs = 0;
|
||||
cancelBtn.style.display = 'none';
|
||||
resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl);
|
||||
};
|
||||
|
||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||
@@ -255,7 +229,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
if (stateValue === 'approved') {
|
||||
stopCountdown();
|
||||
setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info');
|
||||
showStage('approving', 'Вход подтверждён. Подключаем устройство…');
|
||||
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial);
|
||||
const decodedType = String(decoded?.type || '');
|
||||
if (decodedType === 'shine-esp-session-attach') {
|
||||
@@ -271,45 +245,43 @@ export function render({ navigate }) {
|
||||
if (stateValue === 'rejected') {
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error');
|
||||
showStage('retry', 'Вход отклонён на другом устройстве', { error: true, action: 'Получить новый код' });
|
||||
return;
|
||||
}
|
||||
if (stateValue === 'canceled') {
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
setStatus(status, 'Ожидание подключения отменено.', 'error');
|
||||
showStage('retry', 'Заявка отменена', { error: true, action: 'Получить новый код' });
|
||||
return;
|
||||
}
|
||||
if (stateValue === 'expired') {
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
|
||||
showStage('retry', 'Время ожидания истекло', { error: true, action: 'Получить новый код' });
|
||||
return;
|
||||
}
|
||||
schedulePoll();
|
||||
} catch (error) {
|
||||
stopPolling();
|
||||
clearActivePairing();
|
||||
startBtn.disabled = false;
|
||||
const message = toUserMessage(error, 'Не удалось проверить статус pairing.');
|
||||
const message = toUserMessage(error, 'Не удалось проверить, подтверждён ли вход.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
showStage('retry', message, { error: true, action: 'Получить новый код' });
|
||||
}
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
usePasswordInput.addEventListener('change', syncPasswordUi);
|
||||
syncPasswordUi();
|
||||
|
||||
startBtn.addEventListener('click', async () => {
|
||||
const login = String(loginInput.value || '').trim();
|
||||
const usePassword = !!usePasswordInput.checked;
|
||||
const password = String(passwordInput.value || '');
|
||||
if (!login) {
|
||||
setStatus(status, 'Введите логин.', 'error');
|
||||
navigate('login-view');
|
||||
return;
|
||||
}
|
||||
if (usePassword && !password) {
|
||||
setStatus(status, 'Введите пароль подключения.', 'error');
|
||||
showStage('password', 'Введите пароль подключения', { error: true, action: 'Получить код' });
|
||||
passwordInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,8 +289,8 @@ export function render({ navigate }) {
|
||||
setAuthBusy(true);
|
||||
setAuthError('');
|
||||
setAuthInfo('');
|
||||
setStatus(status, 'Создаём код для входа…', 'info');
|
||||
clearActivePairing();
|
||||
showStage('creating', 'Создаём код для входа…');
|
||||
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
@@ -346,29 +318,27 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || '');
|
||||
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить по коду.';
|
||||
onlineHintEl.textContent = payload?.trustedSessionOnline
|
||||
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
|
||||
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
|
||||
resultWrap.style.display = '';
|
||||
shortCodeEl.setAttribute('aria-label', `Код для входа: ${String(payload?.shortCode || '').split('').join(' ')}`);
|
||||
const online = !!payload?.trustedSessionOnline;
|
||||
onlineEl.textContent = online ? 'Устройство в сети' : 'Устройство не в сети – заявка подождёт';
|
||||
onlineEl.classList.toggle('is-online', online);
|
||||
showStage('code');
|
||||
startCountdown(payload?.expiresAtMs);
|
||||
state.loginDraft.login = login;
|
||||
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
|
||||
schedulePoll();
|
||||
} catch (error) {
|
||||
startBtn.disabled = false;
|
||||
if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID' && !usePassword) {
|
||||
usePasswordInput.checked = true;
|
||||
syncPasswordUi();
|
||||
modeHintEl.textContent = 'Для этого аккаунта включён дополнительный пароль подключения. Введите его, чтобы получить код.';
|
||||
setStatus(status, 'Введите дополнительный пароль подключения.', 'info');
|
||||
showStage('password', 'Для входа по коду на другом устройстве задан пароль подключения.', { action: 'Получить код' });
|
||||
window.setTimeout(() => passwordInput.focus(), 0);
|
||||
} else if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID') {
|
||||
showStage('password', 'Неверный пароль подключения', { error: true, action: 'Получить код' });
|
||||
window.setTimeout(() => passwordInput.focus(), 0);
|
||||
} else {
|
||||
startBtn.style.display = '';
|
||||
startBtn.textContent = 'Повторить';
|
||||
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
|
||||
setAuthError(message);
|
||||
setStatus(status, message, 'error');
|
||||
showStage('retry', message, { error: true, action: 'Повторить' });
|
||||
}
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
@@ -400,8 +370,21 @@ export function render({ navigate }) {
|
||||
}
|
||||
};
|
||||
|
||||
panel.append(formCard, status, resultWrap);
|
||||
screen.append(panel);
|
||||
passwordInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') startBtn.click();
|
||||
});
|
||||
|
||||
screen.append(
|
||||
createTopBar({
|
||||
title: 'Вход с другого устройства',
|
||||
back: { label: '←', onClick: () => { void cancelActivePairingAndBack(); } },
|
||||
}),
|
||||
who,
|
||||
codeBlock,
|
||||
notice,
|
||||
passwordWrap,
|
||||
startBtn,
|
||||
);
|
||||
|
||||
if (!String(loginInput.value || '').trim()) {
|
||||
window.setTimeout(() => navigate('login-view'), 0);
|
||||
|
||||
Reference in New Issue
Block a user