Compare commits

..
1 Commits
Author SHA256 Message Date
qandClaude Opus 5.5 2e62616b46 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>
2026-09-29 01:36:52 +03:00
84 changed files with 2507 additions and 1583 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.14.0
client.version=1.14.1
server.version=1.12.0
+65
View File
@@ -0,0 +1,65 @@
# UI v3–v4: что нужно от сервера и что знать разработчику
Макет V3/V4 перенесён в настоящий код (v3) и доведён по экранам (v4): палитра «Сияние», лента канала, обсуждение,
личный чат, вход и регистрация, уведомления, настройки, просмотр фото. Всё сделано на текущем API без его изменений.
Там, где интерфейсу не хватает сервера, UI к этому готов: нет данных – элемент просто не появляется.
## Нужно от сервера
1. **Кто недавно ответил** – миниатюры рядом со счётчиком обсуждения в ленте канала.
Поле `recentRepliers: string[]` (до двух логинов, свежие первыми) у сообщений в `GetChannelMessages`
и `GetMessageThread`. UI: `channel-view.js`, `mapApiMessageToPost` → `recentRepliers`. Нет поля – нет миниатюр.
2. **«Не уведомлять об ответах» в обсуждении.** Пункт меню обсуждения сейчас запоминает выбор только на устройстве
(`localStorage`, ключ `shine-ui-thread-muted-v1`) и на уведомления не влияет. Нужна настройка пользователя
«без уведомлений об ответах в этом обсуждении» (по ссылке на исходную запись), учитываемая при создании
уведомлений `reply`. Удобно через существующий `UpsertUserSetting`.
3. **Ссылка на комментарий.** «Поделиться комментарием» даёт ссылку на обсуждение с якорем `#comment-<blockNumber>`.
Роутер якорь игнорирует. Нужно: при открытии обсуждения с таким якорем прокрутить к комментарию и подсветить
(в UI для этого есть `jumpTo` в `channel-thread-view.js`).
4. **Уведомления об ответах – на что ответили** (по желанию). В уведомлении есть только текст ответа (`sourceText`).
Чтобы показать «в ответ на: …», нужен короткий текст исходного сообщения (`targetText`) в `GetNotifications`.
5. **Стоимость публикации** (экран «Публикация · подтверждение» макета). Шага подтверждения в приложении нет –
запись уходит сразу. Если публикация станет платной или будет ждать Arweave, нужна цена до отправки.
## Нужно от разработчика
6. **Удалить `pages/key-storage-view.js` и маршрут `/key-storage`.** Экран никуда не подключён: в поля вписаны
описания ключей, «OK» вызывает `authorizeSession()` без данных. Настоящий выбор ключей – `registration-keys-view.js`
(переделан в v4). Вместе с ним можно убрать `.key-storage-option*` / `.key-storage-note*` в `developer-tools.css`.
## Что важно знать об изменениях v4
- **Второстепенные кнопки** – одна роль во всём приложении, включая экраны устройств и серверов: прозрачная, рамка, цвет `--action`.
- **Полоса прокрутки скрыта по умолчанию** (`scrollbar: 'hidden'` в режимах оболочки `app.js`) – как было у каналов,
ленты и обсуждения. Прокрутка работает, контент не прыгает при появлении полосы.
- **Один зазор под шапкой:** `--page-top-gap` в `main.css` (12 px). Его берут `app-shell.css`, каналы и экраны входа –
свои отступы сверху экранам не нужны.
- **Шапка корневых разделов** (Чаты, Каналы, Связи, Уведомления, Профиль) – заголовок по центру, 24 px (`.topbar--root`).
- **Переключатели `.tabs`** – высотой с поле ввода (44 px, кнопки 34 px); `.tabs--dense` – для 4–5 коротких пунктов
(фильтр чатов вместо чипов).
- **Новые общие компоненты:** `components/media-viewer.js` + `styles/components/media-viewer.css` (фото/видео на весь экран,
«1/3» и «Скачать» под фото; заменил `openAttachmentViewer` и кнопку скачивания на фото), `components/shine-wordmark.js`
(надпись «СИЯНИЕ» в контурах, шрифт Tenor Sans, OFL), переключатель `.switch` (перенесён из `settings.css` в `primitives.css`).
- **Иконки** – только из `ui-icon.js` (добавлены `eye`, `eyeOff`; `iconHtml(name, filled, className)`). Текстовые символы
вместо иконок («↻», «+», «✕») заменены.
- **«Дополнительные настройки»** теперь открываются пятью быстрыми нажатиями на заголовок «Настройки»
(логотип над настройками убран).
- **Стартовый экран:** знак по центру, кнопки внизу; сияние мандалы – два вращающихся слоя в `preauth.css`
(`.auth-logo-wrap::before/::after`), фаза случайная; при `prefers-reduced-motion` неподвижно.
- **Тексты:** в строках интерфейса только среднее тире с пробелами «–» (длинное «—» заменено везде, кроме комментариев).
- Уведомления – список как в чатах; «Настройки входа» – одна карточка серверов со статусом-пилюлей, проверка при открытии;
вход с другого устройства – код, таймер, три шага.
## Найденные ошибки (не наши)
7. `styles/components/avatar.css`: `url("../assets/glass_overlay_faithful.png")` ведёт в `styles/assets/…` – файла там нет,
стеклянный ободок аватара не загружается. Путь должен быть `../../assets/…`.
8. Обработчик ошибок в `index.html` показывал красную плашку «BOOT ERROR ResizeObserver loop…» – это предупреждение
браузера, не ошибка. В v3 отсеивается.
9. Пункты 1–2 из `ISSUES-for-dev.md` (подписки у логинов с заглавными, превью «Сообщение недоступно») по-прежнему актуальны.
10. Вход по паролю на `http://` по адресу в локальной сети не работает («Криптография браузера недоступна»): WebCrypto есть
только на https и localhost. Это ограничение браузера – для проверки с телефона нужен https.
+16 -14
View File
@@ -6,7 +6,7 @@
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual"
/>
<meta name="theme-color" content="#12141f" />
<meta name="theme-color" content="#121419" />
<base href="/" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" type="image/jpeg" href="./img/logo.jpg" />
@@ -45,7 +45,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
<script>
(function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/chips.css', './styles/components/nav-list.css', './styles/components/palette-editor.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/profile-page.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/chips.css', './styles/components/nav-list.css', './styles/components/palette-editor.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/compose.css', './styles/components/attachments.css', './styles/components/media-viewer.css', './styles/components/channel-editor.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/profile-page.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
cssFiles.forEach((file) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
@@ -208,19 +208,19 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
const formatReportText = (report) => {
const lines = [
`Ошибка: ${report.title || report.kind || 'unknown'}`,
`Описание: ${report.message || '—'}`,
`Окно: ${report.pageTitle || '—'}`,
`Маршрут: ${report.route || '—'}`,
`URL: ${report.href || '—'}`,
`Видимость: ${report.pageVisibility || '—'}`,
`Описание: ${report.message || '–'}`,
`Окно: ${report.pageTitle || '–'}`,
`Маршрут: ${report.route || '–'}`,
`URL: ${report.href || '–'}`,
`Видимость: ${report.pageVisibility || '–'}`,
`Время: ${new Date(Number(report.clientTs || Date.now())).toISOString()}`,
`UA: ${report.userAgent || '—'}`,
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '—'}`,
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '—'}`,
`Источник: ${report.sourceUrl || '—'}`,
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '—'}`,
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '—'}`,
`Тип причины: ${report.reasonType || '—'}`,
`UA: ${report.userAgent || '–'}`,
`Экран: ${report.viewport ? `${report.viewport.width}x${report.viewport.height} @${report.viewport.dpr || 1}x` : '–'}`,
`Монитор: ${report.screenInfo ? `${report.screenInfo.width}x${report.screenInfo.height}` : '–'}`,
`Источник: ${report.sourceUrl || '–'}`,
`Строка: ${Number.isFinite(report.lineNumber) ? report.lineNumber : '–'}`,
`Колонка: ${Number.isFinite(report.columnNumber) ? report.columnNumber : '–'}`,
`Тип причины: ${report.reasonType || '–'}`,
];
if (report.stack) {
lines.push('Stack:', report.stack);
@@ -418,6 +418,8 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
} catch {}
};
window.addEventListener('error', (e) => {
// Предупреждение браузера о переносе замеров ResizeObserver на следующий кадр – не ошибка
if (/^ResizeObserver loop/.test(String(e?.message || ''))) return;
show('window.error', `${e?.message || ''}\n${e?.filename || ''}:${e?.lineno || ''}:${e?.colno || ''}`, {
kind: 'window_error',
message: e?.message || '',
+3 -3
View File
@@ -326,7 +326,7 @@ function syncViewportMetrics() {
0,
stableViewportHeightPx - Math.min(currentLayoutHeightPx, visualBottomPx),
);
// Address bar Android обычно даёт небольшую дельту; клавиатура — заметно больше.
// Address bar Android обычно даёт небольшую дельту; клавиатура – заметно больше.
const keyboardOffsetPx = textEntryFocused && rawKeyboardOffsetPx >= 100
? rawKeyboardOffsetPx
: 0;
@@ -433,7 +433,7 @@ const DEFAULT_SHELL_MODE = Object.freeze({
contentUnderTopbar: true,
contentUnderBottom: false,
scrollContainer: 'screen',
scrollbar: 'auto',
scrollbar: 'hidden', // как в мобильных приложениях: прокрутка есть, полосы нет и контент не прыгает
});
function normalizeShellMode(mode = {}, showAppChrome = true) {
@@ -1273,7 +1273,7 @@ window.addEventListener('error', (event) => {
},
});
// Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека —
// Кросс-ориджин ошибки приходят как «Script error.» без файла/строки/стека –
// алертом такие не показываем (в лог и captureClientError выше уже записаны).
const isOpaqueCrossOriginError = String(event.message || '') === 'Script error.'
&& !event.filename && !(event.error?.stack);
@@ -9,6 +9,7 @@ import {
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js';
import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js';
import { iconHtml } from './ui-icon.js';
import {
estimateTurboUploadPrice,
formatTurboCredits,
@@ -125,7 +126,7 @@ export function markArweaveAttachmentPlaced(login, attachment) {
export function formatArweaveHistoryTime(ms) {
const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '—';
if (!Number.isFinite(value) || value <= 0) return '–';
const diffMs = Date.now() - value;
if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) {
const minutes = Math.max(1, Math.floor(diffMs / 60000));
@@ -136,7 +137,7 @@ export function formatArweaveHistoryTime(ms) {
export function formatArweaveHistoryDateTime(ms) {
const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '—';
if (!Number.isFinite(value) || value <= 0) return '–';
return new Date(value).toLocaleString('ru-RU');
}
@@ -430,10 +431,10 @@ export function openArweaveAttachmentManager({
const renderTurboMeta = (mainMetaEl, previewMetaEl) => {
if (!mainMetaEl || !previewMetaEl) return;
const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim();
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—';
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '–';
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '–';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '–';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '–';
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
const summary = selectedFile
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
@@ -444,10 +445,10 @@ export function openArweaveAttachmentManager({
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
<details class="ar-attachment-details">
<summary>Подробнее</summary>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '–'))}</div>
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
<div>SOL на этом ключе: ${solBalance}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div>
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
</details>
@@ -458,8 +459,8 @@ export function openArweaveAttachmentManager({
<div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '–')}</div>
`;
}
};
@@ -520,7 +521,7 @@ export function openArweaveAttachmentManager({
mainMetaEl.innerHTML = `
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`}
<div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div>
<div>Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
`;
@@ -530,8 +531,8 @@ export function openArweaveAttachmentManager({
<div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '—')}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '–')}</div>
`;
}
};
@@ -624,7 +625,7 @@ export function openArweaveAttachmentManager({
const showUpload = async () => {
const turboMode = isTurboUpload();
const titleText = effectiveDialogTitle
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
|| (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'));
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
const canShowHistory = allowHistorySelection;
const canShowExisting = allowExistingTxInput;
@@ -633,11 +634,11 @@ export function openArweaveAttachmentManager({
<div class="modal-card stack ar-attachment-manager-card">
<div class="ar-attachment-manager-head">
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">✕</button>
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">${iconHtml('close')}</button>
</div>
<div class="tabs tabs--auto" role="radiogroup" aria-label="Способ загрузки">
<button class="tab-btn" role="radio" aria-checked="${turboMode}" type="button" data-action="switch-turbo">Turbo</button>
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк Arweave</button>
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк</button>
</div>
${turboMode
? `
+17 -4
View File
@@ -28,6 +28,7 @@ export function openChannelEditor({
submitLabel = 'Ответить',
placeholder = 'Напишите ответ',
context = null,
destination = '',
key = 'reply',
extraControl = null,
initialText = '',
@@ -38,7 +39,7 @@ export function openChannelEditor({
isActive = () => true,
} = {}) {
if (!state.session.isAuthorized) {
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
openAuthRequiredModal({ title: 'Нужен аккаунт', text: 'Чтобы публиковать и отвечать, войдите или зарегистрируйтесь.' });
return null;
}
const root = document.getElementById('modal-root');
@@ -77,11 +78,15 @@ export function openChannelEditor({
closeButton.type = 'button';
closeButton.className = 'icon-btn channel-editor__close';
closeButton.setAttribute('aria-label', 'Закрыть редактор');
closeButton.textContent = '×';
closeButton.innerHTML = iconHtml('close');
const heading = document.createElement('h2');
heading.id = `${id}-title`;
heading.textContent = title;
header.append(closeButton, heading, document.createElement('span'));
// Закрытие не теряет текст: черновик хранится до отправки
const draftMark = document.createElement('span');
draftMark.className = 'channel-editor__draft';
draftMark.textContent = 'Черновик';
header.append(closeButton, heading, draftMark);
const body = document.createElement('div');
body.className = 'channel-editor__body';
@@ -130,9 +135,17 @@ export function openChannelEditor({
const author = document.createElement('div');
author.className = 'channel-editor__author';
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
const authorText = document.createElement('span');
authorText.className = 'channel-editor__author-text';
const authorName = document.createElement('strong');
authorName.textContent = login || 'Гость';
author.append(authorName);
authorText.append(authorName);
if (destination) {
const where = document.createElement('small');
where.textContent = destination;
authorText.append(where);
}
author.append(authorText);
body.append(author);
body.append(textarea, attachmentsBox, error, clearButton);
+165
View File
@@ -0,0 +1,165 @@
// Полноэкранный просмотр фото и видео из вложений: листание, «Скачать».
// Закрывается крестиком, Esc, смахиванием вниз или переходом назад.
import { iconHtml } from './ui-icon.js';
let activeClose = null;
function safeFileName(name, kind) {
const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
if (clean) return clean;
return kind === 'video' ? 'video.mp4' : 'image.jpg';
}
// Arweave отдаёт файл с другого адреса: атрибут download там не работает, поэтому скачиваем через blob.
async function downloadMedia(item) {
const name = safeFileName(item.name, item.kind);
try {
const response = await fetch(item.url);
if (!response.ok) throw new Error(String(response.status));
const blobUrl = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = blobUrl;
link.download = name;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
} catch {
window.open(item.url, '_blank', 'noopener');
}
}
/**
* items: [{ url, kind: 'image' | 'video', name }], index – с какого начать.
*/
export function openMediaViewer(items = [], index = 0) {
const list = items.filter((item) => item?.url && (item.kind === 'image' || item.kind === 'video'));
if (!list.length) return;
activeClose?.();
let current = Math.max(0, Math.min(list.length - 1, index));
const root = document.getElementById('modal-root') || document.body;
const overlay = document.createElement('div');
overlay.className = 'media-viewer';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Просмотр');
// Сверху – только «закрыть»; счётчик и «Скачать» – сразу под фото, по центру.
overlay.innerHTML = `
<div class="media-viewer__bar">
<button class="media-viewer__btn" type="button" data-action="close" aria-label="Закрыть">${iconHtml('close')}</button>
</div>
<div class="media-viewer__stage">
<div class="media-viewer__frame"></div>
<div class="media-viewer__controls">
<span class="media-viewer__counter"></span>
<button class="media-viewer__btn media-viewer__btn--round" type="button" data-action="download" aria-label="Скачать" title="Скачать">${iconHtml('download')}</button>
</div>
</div>
<button class="media-viewer__nav media-viewer__nav--prev" type="button" data-action="prev" aria-label="Предыдущее">${iconHtml('back')}</button>
<button class="media-viewer__nav media-viewer__nav--next" type="button" data-action="next" aria-label="Следующее">${iconHtml('open')}</button>
`;
const stage = overlay.querySelector('.media-viewer__stage');
const frame = overlay.querySelector('.media-viewer__frame');
const counter = overlay.querySelector('.media-viewer__counter');
const prevBtn = overlay.querySelector('[data-action="prev"]');
const nextBtn = overlay.querySelector('[data-action="next"]');
const render = () => {
stage.querySelectorAll('video').forEach((video) => video.pause());
const item = list[current];
let media;
if (item.kind === 'video') {
media = document.createElement('video');
media.controls = true;
media.autoplay = true;
media.playsInline = true;
} else {
media = document.createElement('img');
media.alt = item.name || '';
media.decoding = 'async';
}
media.className = 'media-viewer__media';
media.src = item.url;
frame.replaceChildren(media);
counter.textContent = list.length > 1 ? `${current + 1}/${list.length}` : '';
counter.hidden = list.length <= 1;
prevBtn.hidden = current <= 0;
nextBtn.hidden = current >= list.length - 1;
};
const go = (delta) => {
const next = current + delta;
if (next < 0 || next >= list.length) return;
current = next;
render();
};
const onKey = (event) => {
if (event.key === 'Escape') close();
else if (event.key === 'ArrowLeft') go(-1);
else if (event.key === 'ArrowRight') go(1);
};
const onPopState = () => close();
const close = () => {
if (activeClose !== close) return;
activeClose = null;
document.removeEventListener('keydown', onKey);
window.removeEventListener('popstate', onPopState);
stage.querySelectorAll('video').forEach((video) => video.pause());
overlay.remove();
};
let swiped = false;
overlay.addEventListener('click', (event) => {
// Клик, который браузер присылает после смахивания, не должен прятать панель или закрывать просмотр.
if (swiped) { swiped = false; return; }
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'close') close();
else if (action === 'prev') go(-1);
else if (action === 'next') go(1);
else if (action === 'download') void downloadMedia(list[current]);
else if (event.target.closest('.media-viewer__media')?.tagName === 'IMG') overlay.classList.toggle('is-bare');
else if (event.target === stage || event.target === frame || event.target === overlay) close();
});
// Смахивание: вбок – листать, вниз – закрыть.
let startX = 0;
let startY = 0;
let tracking = false;
stage.addEventListener('pointerdown', (event) => {
if (event.target.tagName === 'VIDEO') return;
tracking = true;
startX = event.clientX;
startY = event.clientY;
});
stage.addEventListener('pointermove', (event) => {
if (!tracking) return;
const dy = event.clientY - startY;
if (dy > 0 && Math.abs(dy) > Math.abs(event.clientX - startX)) {
stage.style.transform = `translateY(${dy}px)`;
overlay.style.setProperty('--viewer-dim', String(Math.max(0.4, 1 - dy / 400)));
}
});
const endSwipe = (event) => {
if (!tracking) return;
tracking = false;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
swiped = Math.hypot(dx, dy) > 10;
stage.style.transform = '';
overlay.style.removeProperty('--viewer-dim');
if (dy > 90 && dy > Math.abs(dx)) close();
else if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) go(dx < 0 ? 1 : -1);
};
stage.addEventListener('pointerup', endSwipe);
stage.addEventListener('pointercancel', endSwipe);
document.addEventListener('keydown', onKey);
window.addEventListener('popstate', onPopState);
activeClose = close;
render();
root.append(overlay);
overlay.querySelector('[data-action="close"]')?.focus({ preventScroll: true });
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { createDropdownMenu } from './dropdown-menu.js';
import { iconHtml } from './ui-icon.js';
export function attachMessageMenu(card, head, items) {
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'icon-btn channel-message-more';
trigger.textContent = '⋯';
trigger.innerHTML = iconHtml('more');
trigger.setAttribute('aria-label', 'Действия сообщения');
head.append(trigger);
const menu = createDropdownMenu({ anchorEl: trigger, items });
+1 -1
View File
@@ -16,7 +16,7 @@ function statusChip({ kind, label, value, active }) {
const n = Number(value || 0);
return `
<button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}"
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число — сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число – сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
</button>`;
}
+63
View File
@@ -0,0 +1,63 @@
import { openSheet } from './sheet.js';
import { iconHtml } from './ui-icon.js';
import { showToast } from '../services/channels-ux.js';
// «Поделиться записью / комментарием»: что именно отправляется, ссылка и «Скопировать ссылку».
// Системное меню «Поделиться» — вторым действием, если браузер его умеет.
export function openShareSheet({ title = 'Поделиться', kicker = '', text = '', url = '' } = {}) {
return openSheet({
title,
className: 'share-sheet',
content: (sheet) => {
const wrap = document.createElement('div');
wrap.className = 'share-sheet__content';
const preview = document.createElement('div');
preview.className = 'share-sheet__preview';
const k = document.createElement('strong');
k.textContent = kicker;
const t = document.createElement('p');
t.textContent = text;
preview.append(k, t);
const label = document.createElement('label');
label.className = 'share-sheet__label';
label.textContent = 'Ссылка';
const field = document.createElement('input');
field.className = 'share-sheet__link';
field.readOnly = true;
field.value = url;
field.addEventListener('focus', () => field.select());
label.append(field);
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'primary-btn share-sheet__copy';
copy.innerHTML = `${iconHtml('copy')}<span>Скопировать ссылку</span>`;
copy.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(url);
showToast('Ссылка скопирована');
sheet.close();
} catch {
field.focus();
field.select();
showToast('Выделил ссылку – скопируйте её вручную', { kind: 'error' });
}
});
wrap.append(preview, label, copy);
if (navigator.share) {
const system = document.createElement('button');
system.type = 'button';
system.className = 'secondary-btn share-sheet__system';
system.innerHTML = `${iconHtml('share')}<span>Другое приложение…</span>`;
system.addEventListener('click', async () => {
try { await navigator.share({ title: kicker, text, url }); sheet.close(); } catch { /* отменили */ }
});
wrap.append(system);
}
return wrap;
},
});
}
+60
View File
@@ -0,0 +1,60 @@
import { iconHtml } from './ui-icon.js';
// Шторка снизу (на широком экране — окно по центру) с заголовком и «✕».
// Закрывается по «✕», Escape и тапу мимо; фокус возвращается туда, откуда открыли.
// Возвращает { root, body, footer, close }. content — Node или функция (sheet) => Node.
export function openSheet({ title = '', content = null, className = '', onClose = null } = {}) {
const opener = document.activeElement;
const modal = document.createElement('div');
modal.className = `modal sheet${className ? ` ${className}` : ''}`;
const card = document.createElement('div');
card.className = 'modal-card sheet__card';
card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true');
const head = document.createElement('div');
head.className = 'sheet__head';
const heading = document.createElement('h2');
heading.className = 'modal-title sheet__title';
heading.textContent = title;
heading.id = `sheet-title-${Math.random().toString(36).slice(2, 8)}`;
card.setAttribute('aria-labelledby', heading.id);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'icon-btn sheet__close';
closeBtn.setAttribute('aria-label', 'Закрыть');
closeBtn.innerHTML = iconHtml('close');
head.append(heading, closeBtn);
const body = document.createElement('div');
body.className = 'sheet__body';
const footer = document.createElement('div');
footer.className = 'sheet__footer';
footer.hidden = true;
card.append(head, body, footer);
modal.append(card);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
document.removeEventListener('keydown', onKeydown, true);
modal.remove();
opener?.focus?.();
onClose?.();
};
const onKeydown = (event) => {
if (event.key === 'Escape') { event.stopPropagation(); close(); }
};
modal.addEventListener('click', (event) => { if (event.target === modal) close(); });
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', onKeydown, true);
const sheet = { root: modal, body, footer, close };
const node = typeof content === 'function' ? content(sheet) : content;
if (node instanceof Node) body.append(node);
if (footer.childElementCount) footer.hidden = false;
document.body.append(modal);
(card.querySelector('[autofocus]') || closeBtn).focus();
return sheet;
}
+18
View File
@@ -0,0 +1,18 @@
// Надпись «СИЯНИЕ» вразрядку: Tenor Sans (OFL) в контурах — шрифт не скачивается.
// Цвет — currentColor, поэтому надпись берёт цвет текста темы.
const NS = 'http://www.w3.org/2000/svg';
const WIDTH = 7336;
const PATH = 'M721 -129Q619 -33 479 -33Q401 -33 341 -60Q281 -87 241 -132Q201 -177 180.5 -236Q160 -295 160 -359Q160 -427 181.5 -483.5Q203 -540 242 -581Q281 -622 334.5 -645Q388 -668 451 -668Q494 -668 529.5 -658Q565 -648 592.5 -632Q620 -616 637.5 -595.5Q655 -575 662 -554H736Q721 -588 697.5 -617Q674 -646 639 -667Q604 -688 557 -700Q510 -712 450 -712Q363 -712 291.5 -687Q220 -662 169 -615.5Q118 -569 90 -503.5Q62 -438 62 -357Q62 -287 84.5 -220.5Q107 -154 154 -102.5Q201 -51 274 -19.5Q347 12 448 12Q512 12 562.5 0Q613 -12 650.5 -29.5Q688 -47 713 -67Q738 -87 751 -102ZM1542 -82V-700H1451V0H1542L1957 -625V0H2048V-700H1957ZM3316 -700V0H3225V-309H3181Q3153 -309 3134.5 -304Q3116 -299 3101 -287Q3086 -275 3073 -256Q3060 -237 3042 -209L2963 -92Q2948 -69 2933.5 -50Q2919 -31 2902 -17.5Q2885 -4 2863.5 4Q2842 12 2812 12Q2790 12 2776 10Q2762 8 2748 0V-37Q2753 -36 2758 -35.5Q2763 -35 2768 -35Q2789 -35 2805 -41Q2821 -47 2835 -58.5Q2849 -70 2862 -86.5Q2875 -103 2889 -124Q2931 -185 2954 -221.5Q2977 -258 2990.5 -278Q3004 -298 3012.5 -307Q3021 -316 3033 -322L3043 -327V-331Q2995 -334 2960.5 -352Q2926 -370 2904 -397Q2882 -424 2872 -455.5Q2862 -487 2862 -517Q2862 -544 2872.5 -576Q2883 -608 2908.5 -635.5Q2934 -663 2978 -681.5Q3022 -700 3089 -700ZM3225 -656H3124Q3086 -656 3055.5 -645Q3025 -634 3003 -614.5Q2981 -595 2969.5 -568Q2958 -541 2958 -509Q2958 -482 2968.5 -454.5Q2979 -427 3000 -404.5Q3021 -382 3051.5 -368Q3082 -354 3122 -354H3225ZM4197 -348H4616V0H4707V-700H4616V-394H4197V-700H4106V0H4197ZM5588 -82V-700H5497V0H5588L6003 -625V0H6094V-700H6003ZM6884 -700V0H7336V-55H6975V-346H7285V-394H6975V-645H7336V-700Z';
export function createShineWordmark({ className = '' } = {}) {
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', `52 -722 ${WIDTH - 42} 744`);
svg.setAttribute('class', `shine-wordmark ${className}`.trim());
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'Сияние');
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', PATH);
path.setAttribute('fill', 'currentColor');
svg.append(path);
return svg;
}
+6 -6
View File
@@ -96,22 +96,22 @@ function navigateWithGuestRules(pageId, navigate) {
}
if (pageId === 'messages-list') {
openAuthRequiredModal({
title: 'Личные сообщения недоступны',
text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
title: 'Нужен аккаунт',
text: 'Чтобы писать личные сообщения, войдите или зарегистрируйтесь.',
});
return;
}
if (pageId === 'profile-view') {
openAuthRequiredModal({
title: 'Профиль недоступен',
text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
title: 'Нужен аккаунт',
text: 'Чтобы открыть профиль, войдите или зарегистрируйтесь.',
});
return;
}
if (pageId === 'notifications-view') {
openAuthRequiredModal({
title: 'Уведомления недоступны',
text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
title: 'Нужен аккаунт',
text: 'Чтобы получать уведомления, войдите или зарегистрируйтесь.',
});
return;
}
+3
View File
@@ -18,6 +18,9 @@ function createActionButton(action = {}, cleanupFns) {
button.append(action.iconNode);
} else if (action.icon) {
button.innerHTML = iconHtml(action.icon);
} else if (action.label === '⋮' || action.label === '⋯') {
// Меню экрана везде рисуется одной иконкой «⋯»
button.innerHTML = iconHtml('more');
} else {
button.textContent = String(action.label ?? '');
}
+28 -5
View File
@@ -1,11 +1,32 @@
const paths = {
heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z"/>',
message: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/>',
heart: '<path d="M12 21S2.3 15.5 2.3 8.7C2.3 3.6 8.1 1.9 12 6.5c3.9-4.6 9.7-2.9 9.7 2.2C21.7 15.5 12 21 12 21Z"/>',
message: '<path d="M5.2 2.35h13.6a2.7 2.7 0 0 1 2.7 2.7v9.7a2.7 2.7 0 0 1-2.7 2.7h-7.1L7.2 21v-3.55h-2a2.7 2.7 0 0 1-2.7-2.7v-9.7a2.7 2.7 0 0 1 2.7-2.7Z"/>',
more: '<circle cx="4" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="20" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
forward: '<path d="m14.4 3.1 7.2 7.1-7.2 6.6v-4.4C8.5 12.4 5 15.1 2.4 20c.4-7.6 4.4-11.7 12-12.4Z"/>',
download: '<path d="M12 4v11M7 10.5l5 5 5-5M5 20h14"/>',
reply: '<path d="M9 7 4 12l5 5"/><path d="M4 12h9a7 7 0 0 1 7 7"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.6v.1"/>',
trash: '<path d="M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2a1.9 1.9 0 0 0 1.9 1.8h5.4a1.9 1.9 0 0 0 1.9-1.8L17.5 7M10 11v6M14 11v6"/>',
'bell-off': '<path d="M8.5 4.2A6 6 0 0 1 18 9c0 4.3 1.1 6.2 2 7.2M18 17H3c0-2 3-2 3-9M10 21h4M3 3l18 18"/>',
phone: '<path d="M5 3.5h3.2l1.6 4.2-2.1 1.4a11 11 0 0 0 5.2 5.2l1.4-2.1 4.2 1.6V17a2.5 2.5 0 0 1-2.7 2.5A16 16 0 0 1 2.5 6.2 2.5 2.5 0 0 1 5 3.5Z"/>',
video: '<rect x="2.5" y="6" width="13" height="12" rx="2.5"/><path d="m15.5 10.5 6-3.5v10l-6-3.5"/>',
emoji: '<circle cx="12" cy="12" r="9"/><path d="M8.3 14.2a4.6 4.6 0 0 0 7.4 0"/><circle cx="9" cy="9.8" r=".9" fill="currentColor" stroke="none"/><circle cx="15" cy="9.8" r=".9" fill="currentColor" stroke="none"/>',
send: '<path d="M12 19V5M5.5 11.5 12 5l6.5 6.5"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/>',
image: '<rect x="3" y="4.5" width="18" height="15" rx="2.5"/><circle cx="8.5" cy="9.5" r="1.6"/><path d="m21 16-4.7-4.7L7 20.5"/>',
list: '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".9" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r=".9" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r=".9" fill="currentColor" stroke="none"/>',
doc: '<path d="M6.5 3h7.5L19 8v11.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 19.5v-15A1.5 1.5 0 0 1 6.5 3Z"/><path d="M14 3v5h5M8.5 13h7M8.5 16.5h5"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
alert: '<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.4v.1"/>',
'check-double': '<path d="m2.5 12.5 4 4L15 8"/><path d="m10.5 16 1 .5L21 8"/>',
compose: '<path d="M13 4H6.5A2.5 2.5 0 0 0 4 6.5v11A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V11"/><path d="m18.5 3.8 1.7 1.7a1.3 1.3 0 0 1 0 1.8L12.4 15H9v-3.4l7.7-7.8a1.3 1.3 0 0 1 1.8 0Z"/>',
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
share: '<path d="m8 12 8-8M9 4h7v7M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
share: '<path d="m14.4 3.1 7.2 7.1-7.2 6.6v-4.4C8.5 12.4 5 15.1 2.4 20c.4-7.6 4.4-11.7 12-12.4Z"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L12 5.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
gift: '<rect x="3" y="8" width="18" height="5" rx="1"/><path d="M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7M12 8v13M12 8S10.5 3 8 3.5 7 8 12 8Zm0 0s1.5-5 4-4.5S17 8 12 8Z"/>',
@@ -20,9 +41,11 @@ const paths = {
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
'user-plus': '<circle cx="10" cy="8" r="3.5"/><path d="M3.5 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6"/>',
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>',
eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',
eyeOff: '<path d="M10.2 5.7A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16.4 16.4 0 0 1-2.6 3.4M6.4 7A16.2 16.2 0 0 0 2.5 12S6 18.5 12 18.5a9 9 0 0 0 4.9-1.4"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2M3.5 3.5l17 17"/>',
clip: '<path d="m20.5 11.5-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>',
};
export function iconHtml(name, filled = false) {
return `<svg viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
export function iconHtml(name, filled = false, className = '') {
return `<svg${className ? ` class="${className}"` : ''} viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
}
+2 -2
View File
@@ -17,7 +17,7 @@ function createToggleRow({ id, title, hint, checked }) {
<strong class="settings-feature-toggle-title">${title}</strong>
<span class="settings-feature-toggle-hint">${hint}</span>
</span>
<input class="settings-feature-switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
<input class="switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
`;
return label;
}
@@ -34,7 +34,7 @@ export function render({ navigate, chrome }) {
const intro = document.createElement('div');
intro.className = 'settings-intro';
intro.innerHTML = `
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по заголовку «Настройки».</p>
`;
const developerRow = createToggleRow({
+1 -1
View File
@@ -119,7 +119,7 @@ export function render({navigate, chrome}) {
back: { onClick: () => navigate('settings-view') },
actions: [
{
label: '+',
icon: 'plus',
title: 'Добавить файл',
ariaLabel: 'Добавить файл',
className: 'arweave-uploads-add',
+10 -10
View File
@@ -163,18 +163,18 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
<select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
</select>
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
<div class="channel-support-address" id="channel-support-recipient-address">–</div>
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-support-sender-key">
<option value="client-key">Client key</option>
<option value="root-key">Root key</option>
</select>
<div class="channel-support-address" id="channel-support-sender-address">—</div>
<div class="channel-support-address" id="channel-support-sender-address">–</div>
<div class="channel-support-balance-row">
<span class="meta-muted">Баланс</span>
<strong id="channel-support-balance">—</strong>
<strong id="channel-support-balance">–</strong>
<button class="secondary-btn channel-support-refresh" id="channel-support-refresh" type="button">Обновить</button>
</div>
@@ -281,7 +281,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
if (busy || closed) return;
setBusy(true);
setStatus('Загрузка баланса…');
if (balanceEl) balanceEl.textContent = '—';
if (balanceEl) balanceEl.textContent = '–';
try {
const wallet = await resolveSenderWallet();
if (closed) return;
@@ -295,7 +295,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
setStatus('');
} catch (error) {
if (closed) return;
if (senderAddressEl) senderAddressEl.textContent = '—';
if (senderAddressEl) senderAddressEl.textContent = '–';
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
} finally {
if (!closed) setBusy(false);
@@ -304,8 +304,8 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
recipientSelect?.addEventListener('change', updateRecipientAddress);
senderSelect?.addEventListener('change', () => {
if (senderAddressEl) senderAddressEl.textContent = '—';
if (balanceEl) balanceEl.textContent = '—';
if (senderAddressEl) senderAddressEl.textContent = '–';
if (balanceEl) balanceEl.textContent = '–';
setStatus('');
void refreshBalance();
});
@@ -494,13 +494,13 @@ export function render({ navigate, route, chrome }) {
<div class="channel-about-hero">
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
<div class="channel-about-subline">@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
<div class="channel-about-subline">@${escapeHtml(channelName || ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
</div>
<div class="channel-about-tiles">
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
${subscriptionTile}
<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Донат</span></button>
${isOwnChannel ? '' : `<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Поддержать автора</span></button>`}
</div>
<div class="channel-about-list">
@@ -521,7 +521,7 @@ export function render({ navigate, route, chrome }) {
<span class="channel-about-row-label">Ссылка</span>
<span class="channel-about-link">${escapeHtml(shortLink)}</span>
</span>
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('link')}</span>
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('copy')}</span>
</button>
</div>
`;
+7 -7
View File
@@ -164,7 +164,7 @@ export function render({ navigate, route, chrome }) {
<select class="select" id="channel-donate-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
</select>
<div class="channel-donate-address" id="channel-donate-recipient-address">—</div>
<div class="channel-donate-address" id="channel-donate-recipient-address">–</div>
</section>
<section class="channel-donate-section">
@@ -173,11 +173,11 @@ export function render({ navigate, route, chrome }) {
<option value="client-key">Client key</option>
<option value="root-key">Root key</option>
</select>
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
<div class="channel-donate-address" id="channel-donate-sender-address">–</div>
<div class="channel-donate-balance-row">
<span class="meta-muted">Баланс</span>
<strong id="channel-donate-balance">—</strong>
<strong id="channel-donate-balance">–</strong>
<button class="secondary-btn channel-donate-refresh" id="channel-donate-refresh" type="button">Обновить</button>
</div>
</section>
@@ -267,7 +267,7 @@ export function render({ navigate, route, chrome }) {
if (busy || disposed) return;
setBusy(true);
setStatus('Загрузка баланса…');
if (balanceEl) balanceEl.textContent = '—';
if (balanceEl) balanceEl.textContent = '–';
try {
const wallet = await resolveSenderWallet();
if (disposed) return;
@@ -281,7 +281,7 @@ export function render({ navigate, route, chrome }) {
setStatus('');
} catch (error) {
if (disposed) return;
if (senderAddressEl) senderAddressEl.textContent = '—';
if (senderAddressEl) senderAddressEl.textContent = '–';
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
} finally {
if (!disposed) setBusy(false);
@@ -290,8 +290,8 @@ export function render({ navigate, route, chrome }) {
recipientSelect?.addEventListener('change', updateRecipientAddress);
senderSelect?.addEventListener('change', () => {
if (senderAddressEl) senderAddressEl.textContent = '—';
if (balanceEl) balanceEl.textContent = '—';
if (senderAddressEl) senderAddressEl.textContent = '–';
if (balanceEl) balanceEl.textContent = '–';
setStatus('');
void refreshBalance();
});
+407 -63
View File
@@ -9,6 +9,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
formatPostStamp,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
@@ -18,6 +19,9 @@ import {
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { openChannelEditor } from '../components/channel-editor.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createEmojiPicker } from '../components/emoji-picker.js';
import { openShareSheet } from '../components/share-sheet.js';
import {
composeMessageWithAttachments,
createAttachmentCarouselElement,
@@ -466,7 +470,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '–')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
@@ -623,7 +627,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
const text = String(item?.text || '').trim() || 'удалено';
row.innerHTML = `
<strong>Версия ${index + 1}</strong>
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}</div>
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '–'}</div>
<p class="channel-message-body">${text}</p>
`;
list.append(row);
@@ -715,7 +719,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '–';
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock);
@@ -961,6 +965,176 @@ function renderNodeCard(node, heading, handlers, localNumber) {
return card;
}
// ===== Обсуждение в раскладке V4: контекст записи, плоский список комментариев, строка ввода =====
// Дерево ответов → плоский список в порядке чтения; у каждого — на что он отвечает
function flattenDescendants(items, parent = null, out = []) {
(Array.isArray(items) ? items : []).forEach((branch) => {
if (!branch?.node) return;
out.push({ node: branch.node, parent });
flattenDescendants(branch.children, branch.node, out);
});
return out;
}
function nodeKey(node) {
return messageRefKey(buildTargetFromNode(node));
}
// Исходная запись сверху: две строки, «Развернуть» — только если текст правда обрезан
function renderThreadContext(node, { onOpenPost }) {
const parsed = parseMessageAttachments(resolveNodeText(node));
const box = document.createElement('section');
box.className = 'thread-context';
const text = document.createElement('p');
text.className = 'thread-context__text';
text.textContent = parsed.text || (parsed.attachments.length ? 'Запись с вложением' : '');
const meta = document.createElement('div');
meta.className = 'thread-context__meta';
const time = document.createElement('span');
time.textContent = formatPostStamp(node?.createdAtMs);
const expand = document.createElement('button');
expand.type = 'button';
expand.className = 'text-btn thread-context__expand';
expand.textContent = 'Развернуть';
expand.hidden = true;
expand.addEventListener('click', () => {
const open = box.classList.toggle('is-expanded');
expand.textContent = open ? 'Свернуть' : 'Развернуть';
});
const toPost = document.createElement('button');
toPost.type = 'button';
toPost.className = 'text-btn thread-context__open';
toPost.innerHTML = `<span>${parsed.attachments.length ? 'Запись с фото' : 'К записи'}</span>${iconHtml('open')}`;
toPost.addEventListener('click', onOpenPost);
meta.append(time, expand, toPost);
box.append(text, meta);
// обрезан ли текст — видно только после вставки в документ
requestAnimationFrame(() => { expand.hidden = text.scrollHeight <= text.clientHeight + 1; });
return box;
}
function renderComment({ node, parent, focus, channelOwner, handlers }) {
const target = buildTargetFromNode(node);
const key = messageRefKey(target);
const author = String(node?.authorLogin || 'автор');
const parsed = parseMessageAttachments(resolveNodeText(node));
const versions = Array.isArray(node?.versions) ? node.versions : [];
const isOwn = Boolean(state.session.login) && author.toLowerCase() === String(state.session.login).toLowerCase();
const item = document.createElement('article');
item.className = 'thread-comment channel-message-card';
if (key) item.dataset.messageKey = key;
const person = document.createElement('div');
person.className = 'thread-comment__person';
const who = document.createElement('button');
who.type = 'button';
who.className = 'thread-comment__who';
who.append(renderUserAvatar({ login: author, size: 'xs', className: 'thread-comment__avatar' }));
const name = document.createElement('span');
name.className = 'thread-comment__name';
name.textContent = author;
who.append(name);
who.addEventListener('click', () => handlers.navigate(makeProfileRoute(author)));
person.append(who);
if (channelOwner && author.toLowerCase() === channelOwner.toLowerCase()) {
const badge = document.createElement('span');
badge.className = 'thread-comment__badge';
badge.textContent = 'Автор';
person.append(badge);
}
const time = document.createElement('time');
time.textContent = formatPostStamp(node?.createdAtMs);
if (node?.createdAtMs) time.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
person.append(time);
item.append(person);
// Ответ на другой комментарий — цитата в две строки; нажатие ведёт к оригиналу
if (parent && parent !== focus) {
const quote = document.createElement('button');
quote.type = 'button';
quote.className = 'thread-quote';
const qa = document.createElement('strong');
qa.textContent = String(parent.authorLogin || '');
const qt = document.createElement('span');
qt.textContent = parseMessageAttachments(resolveNodeText(parent)).text || 'Вложение';
quote.append(qa, qt);
quote.setAttribute('aria-label', `Ответ на комментарий ${parent.authorLogin || ''}. Перейти к нему`);
quote.addEventListener('click', () => handlers.jumpTo(nodeKey(parent), key));
item.append(quote);
}
if (parsed.attachments.length) {
item.append(createAttachmentCarouselElement(parsed.attachments, { gateway: state.entrySettings.arweaveServer, messageTimestampMs: node?.createdAtMs }));
}
const text = document.createElement('p');
text.className = 'thread-comment__text';
text.textContent = parsed.text;
item.append(text);
const actions = document.createElement('div');
actions.className = 'thread-comment__actions';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'thread-comment__reply';
reply.textContent = 'Ответить';
reply.addEventListener('click', () => handlers.setReplyTo(node));
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
const liked = getMessageReactionState(target) === 'liked';
const likes = Number(node?.likesCount || 0);
const like = document.createElement('button');
like.type = 'button';
like.className = `channel-action-item thread-comment__like${liked ? ' is-liked' : ''}`;
like.innerHTML = `${iconHtml('heart', liked)}<span>${likes || ''}</span>`;
like.setAttribute('aria-pressed', String(liked));
like.setAttribute('aria-label', `${liked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
like.addEventListener('click', async () => {
like.disabled = true;
try { await handlers.onToggleLike(target, liked ? 'unlike' : 'like'); }
catch (error) { handlers.onActionError(error, liked ? 'unlike' : 'like'); }
finally { if (like.isConnected) like.disabled = false; }
});
const more = document.createElement('button');
more.type = 'button';
more.className = 'channel-action-item thread-comment__more';
more.innerHTML = iconHtml('more');
more.setAttribute('aria-label', `Действия с комментарием ${author}`);
const menuItems = [
{ label: 'Ответить', iconHtml: iconHtml('reply'), action: () => handlers.setReplyTo(node) },
{ label: 'Поделиться комментарием', iconHtml: iconHtml('share'), action: () => handlers.shareComment(node) },
{ label: 'Скопировать текст', iconHtml: iconHtml('copy'), action: async () => {
try { await navigator.clipboard.writeText(parsed.text); showToast('Текст скопирован'); }
catch { showToast('Не удалось скопировать', { kind: 'error' }); }
} },
{ label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => openBlockchainDetailsModal(buildBlockchainDetails({ target, authorLogin: author, timestampMs: node?.createdAtMs, text: resolveNodeText(node), raw: node, localNumber: 0, msgSubType: Number(node?.msgSubType || 0) }), { isActive: handlers.isActive }) },
];
if (versions.length > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions }) });
if (isOwn) {
menuItems.splice(1, 0, { label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => openEditMessageModal({
isActive: handlers.isActive,
draftKey: `edit:${key}`,
initialText: parsed.text,
allowEmptyText: parsed.attachments.length > 0,
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsed.attachments), { isChannelPost: false }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }),
}) });
menuItems.push({ divider: true }, { label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => {
if (!await confirmDialog({ title: 'Удалить комментарий?', text: 'Он скроется из обсуждения. Прежние версии останутся в блокчейне.', confirmLabel: 'Удалить', danger: true })) return;
try { await handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }); }
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить комментарий.')); }
} });
}
const menu = createDropdownMenu({ anchorEl: more, items: menuItems });
item.cleanup = () => menu.destroy();
actions.append(reply, like, more);
item.append(actions);
return item;
}
function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
const wrap = document.createElement('div');
wrap.className = 'stack';
@@ -1025,6 +1199,124 @@ function renderSkeleton(screen) {
return wrap;
}
// «Не уведомлять об ответах» — пока только на этом устройстве (серверной настройки нет, см. docs/UI-Design)
const THREAD_MUTE_KEY = 'shine-ui-thread-muted-v1';
function readMuted() { try { return JSON.parse(localStorage.getItem(THREAD_MUTE_KEY) || '{}') || {}; } catch { return {}; } }
function isThreadMuted(key) { return Boolean(readMuted()[key]); }
function setThreadMuted(key, muted) {
const all = readMuted();
if (muted) all[key] = Date.now(); else delete all[key];
try { localStorage.setItem(THREAD_MUTE_KEY, JSON.stringify(all)); } catch { /* приватный режим */ }
}
// Черновик и адресат ответа принадлежат конкретному обсуждению
const threadDrafts = new Map();
function createThreadComposer({ draftKey, isAuthorized, onLogin, onSend }) {
const root = document.createElement('div');
root.className = 'channel-composer thread-composer';
if (!isAuthorized) {
const login = document.createElement('button');
login.type = 'button';
login.className = 'primary-btn channel-main-action';
login.textContent = 'Войти и ответить';
login.addEventListener('click', onLogin);
root.append(login);
return { root, setReplyTo() {} };
}
const draft = threadDrafts.get(draftKey) || { text: '', replyTo: null };
threadDrafts.set(draftKey, draft);
const replying = document.createElement('div');
replying.className = 'thread-replying';
const row = document.createElement('div');
row.className = 'compose-row';
const field = document.createElement('div');
field.className = 'compose-field';
const input = document.createElement('textarea');
input.rows = 1;
input.className = 'compose-field__input';
input.value = draft.text;
const emojiBtn = document.createElement('button');
emojiBtn.type = 'button';
emojiBtn.className = 'compose-field__emoji';
emojiBtn.setAttribute('aria-label', 'Эмодзи');
emojiBtn.innerHTML = iconHtml('emoji');
field.append(input, emojiBtn);
const send = document.createElement('button');
send.type = 'button';
send.className = 'compose-send';
send.setAttribute('aria-label', 'Отправить');
send.innerHTML = iconHtml('send');
row.append(field, send);
const emojiSlot = document.createElement('div');
emojiSlot.className = 'thread-composer__emoji';
emojiSlot.hidden = true;
root.append(replying, row, emojiSlot);
const sync = () => {
draft.text = input.value;
send.disabled = !input.value.trim();
input.placeholder = draft.replyTo ? `Ответить ${draft.replyTo.authorLogin || ''}…` : 'Написать комментарий…';
replying.replaceChildren();
replying.hidden = !draft.replyTo;
if (draft.replyTo) {
const quote = document.createElement('div');
quote.className = 'thread-quote';
const a = document.createElement('strong');
a.textContent = String(draft.replyTo.authorLogin || '');
const t = document.createElement('span');
t.textContent = parseMessageAttachments(resolveNodeText(draft.replyTo)).text;
quote.append(a, t);
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.className = 'thread-replying__cancel';
cancel.setAttribute('aria-label', 'Не отвечать на комментарий');
cancel.innerHTML = iconHtml('close');
cancel.addEventListener('click', () => { draft.replyTo = null; sync(); });
replying.append(quote, cancel);
}
};
input.addEventListener('input', sync);
emojiBtn.addEventListener('click', () => {
if (!emojiSlot.childElementCount) {
emojiSlot.append(createEmojiPicker({ onSelect: (emoji) => {
const at = input.selectionStart ?? input.value.length;
input.value = `${input.value.slice(0, at)}${emoji}${input.value.slice(at)}`;
sync();
} }));
}
emojiSlot.hidden = !emojiSlot.hidden;
});
const submit = async () => {
const text = input.value.trim();
if (!text) return;
send.disabled = true;
try {
await onSend(text, draft.replyTo);
draft.text = '';
draft.replyTo = null;
input.value = '';
emojiSlot.hidden = true;
} catch (error) {
showToast(toUserMessage(error, 'Комментарий не отправлен. Текст сохранён.'), { kind: 'error' });
} finally {
sync();
}
};
send.addEventListener('click', submit);
bindSubmitOnPlainEnter(input, submit);
sync();
return {
root,
setReplyTo(node) {
draft.replyTo = node;
sync();
input.focus();
},
};
}
export function render({ navigate, route, chrome }) {
let selector = parseThreadSelector(route);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
@@ -1042,19 +1334,31 @@ export function render({ navigate, route, chrome }) {
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
threadHeaderButton.className = 'topbar-identity channel-header-route-btn';
const threadHeaderText = document.createElement('span');
threadHeaderText.className = 'topbar-identity__text';
const threadHeaderTitle = document.createElement('span');
threadHeaderTitle.className = 'channel-header-title';
threadHeaderTitle.className = 'topbar-identity__title';
threadHeaderTitle.textContent = 'Обсуждение';
const threadHeaderChannel = document.createElement('span');
threadHeaderChannel.className = 'channel-header-owner';
threadHeaderChannel.className = 'topbar-identity__sub';
threadHeaderChannel.textContent = '…';
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
threadHeaderText.append(threadHeaderTitle, threadHeaderChannel);
threadHeaderButton.append(threadHeaderText);
threadHeaderButton.disabled = true;
// Меню обсуждения (V4): к записи, уведомления об ответах, поделиться записью
let threadMenuActions = null;
const header = createTopBar({
center: threadHeaderButton,
className: 'topbar--identity',
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [{
label: '⋯',
ariaLabel: 'Меню обсуждения',
className: 'thread-menu-btn',
menu: { minWidth: 240, items: () => threadMenuActions?.() || [] },
}],
});
header.classList.add('channel-thread-topbar');
chrome?.setTopbar(header);
@@ -1396,7 +1700,7 @@ export function render({ navigate, route, chrome }) {
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
if (threadHeaderButton) {
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
threadHeaderChannel.textContent = channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle;
threadHeaderButton.title = 'Открыть канал';
threadHeaderButton.disabled = false;
threadHeaderButton.onclick = (event) => {
@@ -1409,74 +1713,114 @@ export function render({ navigate, route, chrome }) {
}
clearContent();
let localSeq = 0;
const nextNumber = () => {
localSeq += 1;
return localSeq;
const channelOwner = extractLoginFromBlockchainName(selector?.channel?.ownerBlockchainName || focus?.channelInfo?.ownerBlockchainName || '');
const openPost = () => {
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel);
if (routeToChannel) navigate(routeToChannel);
};
const focusTarget = focus ? buildTargetFromNode(focus) : null;
threadMenuActions = () => {
const muted = isThreadMuted(routeKey);
return [
{ label: 'Перейти к записи', iconHtml: iconHtml('doc'), action: openPost },
{ label: muted ? 'Уведомлять об ответах' : 'Не уведомлять об ответах', iconHtml: iconHtml(muted ? 'bell' : 'bell-off'), action: () => {
setThreadMuted(routeKey, !muted);
showToast(muted ? 'Уведомления об ответах включены' : 'Ответы в этом обсуждении не будут приходить в уведомления');
} },
{ label: 'Поделиться записью', iconHtml: iconHtml('share'), action: () => {
if (!focusTarget) return;
openShareSheet({
title: 'Поделиться записью',
kicker: `${resolvedChannelTitle} · запись`,
text: parseMessageAttachments(resolveNodeText(focus)).text,
url: buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector)),
});
} },
];
};
let ancestorsWrap = null;
if (ancestors.length) {
ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
ancestors.forEach((node, index) => {
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
});
const ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'thread-block thread-block--ancestors';
ancestors.forEach((node) => ancestorsWrap.append(renderThreadContext(node, { onOpenPost: openPost })));
screen.append(ancestorsWrap);
}
let focusWrap = null;
if (focus) {
focusWrap = document.createElement('div');
focusWrap.className = 'stack thread-block thread-block--focus';
const focusTitle = document.createElement('h3');
focusTitle.className = 'section-title';
focusTitle.textContent = 'Исходное сообщение';
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
const composer = document.createElement('div');
composer.className = 'channel-composer';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'channel-compose-bar';
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
reply.addEventListener('click', () => {
const parsed = parseMessageAttachments(resolveNodeText(focus));
openReplyModal({
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
isActive: () => !disposed,
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
});
});
composer.append(reply);
chrome?.setComposer(composer);
const focusWrap = document.createElement('div');
focusWrap.className = 'thread-block thread-block--focus';
focusWrap.append(renderThreadContext(focus, { onOpenPost: openPost }));
screen.append(focusWrap);
}
const descendantsWrap = document.createElement('div');
descendantsWrap.className = 'stack thread-block thread-block--replies';
const descendantsTitle = document.createElement('h3');
descendantsTitle.className = 'section-title';
descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
descendantsWrap.append(descendantsTitle);
const comments = flattenDescendants(descendants);
const listWrap = document.createElement('div');
listWrap.className = 'thread-block thread-block--replies';
const listTitle = document.createElement('h3');
listTitle.className = 'thread-section-title';
listTitle.textContent = `Комментарии · ${comments.length}`;
listWrap.append(listTitle);
if (descendants.length) {
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
// Возврат к ответу после перехода по цитате
const returnBtn = document.createElement('button');
returnBtn.type = 'button';
returnBtn.className = 'thread-return';
returnBtn.hidden = true;
returnBtn.textContent = 'Вернуться к ответу';
let returnKey = '';
const highlight = (el) => {
screen.querySelectorAll('.thread-comment.is-highlight').forEach((x) => x.classList.remove('is-highlight'));
el?.classList.add('is-highlight');
};
returnBtn.addEventListener('click', () => {
const el = listWrap.querySelector(`[data-message-key="${CSS.escape(returnKey)}"]`);
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
highlight(el);
returnBtn.hidden = true;
});
const commentHandlers = {
...handlers,
jumpTo: (key, fromKey) => {
const el = listWrap.querySelector(`[data-message-key="${CSS.escape(key)}"]`);
if (!el) return;
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
highlight(el);
returnKey = fromKey;
returnBtn.hidden = false;
},
setReplyTo: (node) => composerApi?.setReplyTo(node),
shareComment: (node) => openShareSheet({
title: 'Поделиться комментарием',
kicker: `${node.authorLogin} · комментарий`,
text: parseMessageAttachments(resolveNodeText(node)).text,
url: `${buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector))}#comment-${node?.messageRef?.blockNumber ?? ''}`,
}),
};
if (comments.length) {
comments.forEach(({ node, parent }) => {
try { listWrap.append(renderComment({ node, parent, focus, channelOwner, handlers: commentHandlers })); }
catch (error) { logThreadRuntimeError('render_comment', error); }
});
} else {
const empty = document.createElement('div');
empty.className = 'empty-note';
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
descendantsWrap.append(empty);
empty.className = 'empty-note thread-empty';
empty.textContent = 'Пока нет комментариев. Можно начать разговор.';
listWrap.append(empty);
}
screen.append(listWrap, returnBtn);
if (ancestorsWrap) {
screen.append(ancestorsWrap);
const divider = document.createElement('div');
divider.className = 'thread-history-divider';
screen.append(divider);
let composerApi = null;
if (focus) {
composerApi = createThreadComposer({
draftKey: routeKey,
isAuthorized: state.session.isAuthorized,
onLogin: () => { state.authReturnHash = window.location.pathname || '/channels'; navigate('login-view'); },
onSend: async (text, replyTo) => handlers.onReply(replyTo ? buildTargetFromNode(replyTo) : focusTarget, text),
});
chrome?.setComposer(composerApi.root);
}
if (focusWrap) screen.append(focusWrap);
screen.append(descendantsWrap);
const focusWrap = screen.querySelector('.thread-block--focus');
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
const hasPendingScroll = pendingThreadScroll.has(routeKey);
+61 -35
View File
@@ -16,8 +16,8 @@ import {
animatePress,
createSkeletonCard,
dayKey,
formatClockTime,
formatDayLabel,
formatPostStamp,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
@@ -644,7 +644,7 @@ function openChannelMetaDetailsModal({
: 'Не установлен';
const changedAtLabel = changedAtMs
? new Date(Number(changedAtMs)).toLocaleString('ru-RU')
: '—';
: '–';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="about-channel-modal">
@@ -658,7 +658,7 @@ function openChannelMetaDetailsModal({
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<span>Владелец</span><strong>${escapeHtml(channel.ownerName || 'автор')}</strong>
<span>Системное имя</span><code>${escapeHtml(channel.name || 'channel')}</code>
<span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '—')}</strong>
<span>Название</span><strong>${escapeHtml(channel.displayTitle || channel.displayName || channel.name || '–')}</strong>
<span>Описание</span><span>${escapeHtml(channel.description || 'Описание не задано.')}</span>
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
</div>
@@ -840,7 +840,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '–')}</code>
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
</div>
@@ -1079,7 +1079,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
item.className = 'ui-button entrypoint-history-item';
item.innerHTML = `
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
<span>#${escapeHtml(post.localNumber || '—')}</span>
<span>#${escapeHtml(post.localNumber || '–')}</span>
<p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p>
`;
item.addEventListener('click', () => {
@@ -1171,7 +1171,7 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
if (textEl) textEl.focus();
}
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
function openAddMessageModal({ channelName, channelTitle = '', onSubmit, navigate, isActive = () => true }) {
const typeWrap = document.createElement('label');
typeWrap.className = 'channel-editor__type';
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
@@ -1188,7 +1188,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () =>
id: 'channel-message-modal',
title: 'Новая запись',
submitLabel: 'Опубликовать',
placeholder: 'Напишите сообщение',
placeholder: 'Чем хотите поделиться?',
destination: channelTitle ? `В канал «${channelTitle}»` : '',
key: `channel-post:${channelName}`,
extraControl: typeWrap,
isActive,
@@ -1223,7 +1224,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
const text = String(item?.text || '').trim() || 'удалено';
row.innerHTML = `
<strong>Версия ${index + 1}</strong>
<div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '—'}</div>
<div class="meta-muted">${ts > 0 ? formatRelativeTime(ts) : '–'}</div>
<p class="channel-message-body">${text}</p>
`;
list.append(row);
@@ -1275,6 +1276,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
primaryLikesCount: Number(message?.primaryLikesCount || 0),
shiningLikesCount: Number(message?.shiningLikesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
// Кто недавно ответил — для миниатюр у счётчика обсуждения. Сервер пока не отдаёт (см. docs/UI-Design).
recentRepliers: Array.isArray(message?.recentRepliers) ? message.recentRepliers.slice(0, 2) : [],
ratingsCount: Number(message?.ratingsCount || 0),
timestampMs: resolveMessageTimestampMs(message),
messageRef,
@@ -1507,6 +1510,7 @@ async function loadFromApi(route, channelId) {
avaSha256: String(payload.channel?.avaSha256 || '').trim(),
avaSize: Number(payload.channel?.avaSize || 0),
metaUpdatedAtMs: Number(payload.channel?.metaUpdatedAtMs || 0),
subscribersCount: Number(payload.channel?.subscribersCount || 0),
ownerName: ownerLogin || 'неизвестно',
},
posts,
@@ -1862,7 +1866,7 @@ function renderPostCard(post, {
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '–';
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
if (versionsTotal > 1) {
const editedMarker = document.createElement('span');
@@ -2034,6 +2038,12 @@ function renderPostCard(post, {
<span class="channel-action-label">Обсуждение</span>
<span class="channel-action-counter">${post.repliesCount || ''}</span>
`;
if (post.recentRepliers.length) {
const faces = document.createElement('span');
faces.className = 'channel-action-faces';
post.recentRepliers.forEach((login) => faces.append(renderUserAvatar({ login, size: 'xs', className: 'channel-action-face' })));
discussionButton.append(faces);
}
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
discussionButton.addEventListener('click', (event) => {
event.stopPropagation();
@@ -2067,8 +2077,6 @@ function renderPostCard(post, {
// Rating/opinion action is intentionally hidden from UI for now.
// Backend/subtype/counters remain supported so the feature can be restored later.
actions.append(likeButton, discussionButton);
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'ui-button channel-action-item channel-action-share';
@@ -2084,8 +2092,9 @@ function renderPostCard(post, {
await onShare(route);
});
actions.append(shareButton);
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
// Порядок как в макете V4: лайк → поделиться → обсуждение; дата и «⋯» — справа
actions.append(likeButton, shareButton, discussionButton);
menuItems.unshift({ label: 'Ответить', iconHtml: iconHtml('reply'), action: () => replyButton.click() });
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button');
originalBtn.type = 'button';
@@ -2127,7 +2136,7 @@ function renderPostCard(post, {
msgSubType: post.msgSubType,
}), { isActive });
});
menuItems.push({ label: 'Данные блокчейна', action: () => detailsButton.click() });
menuItems.push({ label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => detailsButton.click() });
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
@@ -2149,8 +2158,9 @@ function renderPostCard(post, {
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
});
});
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
menuItems.unshift({ label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => editButton.click() });
menuItems.push({ divider: true });
menuItems.push({ label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => {
if (!await confirmDialog({
title: 'Удалить сообщение?',
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
@@ -2164,7 +2174,7 @@ function renderPostCard(post, {
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
attachMessageMenu(card, headRow, menuItems);
if (isOwnerPost) {
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
if (post.timestampMs) timestamp.textContent = formatPostStamp(post.timestampMs);
const meta = document.createElement('div');
meta.className = 'channel-message-meta';
const moreButton = headRow.querySelector('.channel-message-more');
@@ -2193,13 +2203,12 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const actionButton = document.createElement('button');
actionButton.type = 'button';
actionButton.className = 'primary-btn channel-main-action';
actionButton.textContent = state.session.isAuthorized ? 'Подписаться на канал' : 'Войти и подписаться';
actionButton.innerHTML = `${iconHtml('bell')}<span>${state.session.isAuthorized ? 'Подписаться' : 'Войти и подписаться'}</span>`;
const addMessageButton = document.createElement('button');
addMessageButton.type = 'button';
addMessageButton.className = 'channel-compose-bar';
addMessageButton.setAttribute('aria-label', 'Написать в канал');
addMessageButton.innerHTML = `<span class="channel-compose-bar__text">Написать в канал…</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
addMessageButton.className = 'channel-new-post';
addMessageButton.innerHTML = `${iconHtml('compose')}<span>Новая запись</span>`;
addMessageButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
handlers.onAddMessage();
@@ -2236,7 +2245,8 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const itemDayKey = dayKey(item.timestampMs);
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
if (startsNewDay) lastDayKey = itemDayKey;
if (startsNewDay && item.type !== 'meta') {
// В ленте канала дата стоит у каждой записи; плашки дней остаются только в дневнике
if (startsNewDay && item.type !== 'meta' && channelData.isDiary) {
const separator = document.createElement('div');
separator.className = 'channel-day-separator';
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
@@ -2300,7 +2310,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
composer.append(addMessageButton);
handlers.chrome?.setComposer(composer);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(actionButton, feed);
screen.append(feed);
const composer = document.createElement('div');
composer.className = 'channel-composer';
composer.append(actionButton);
handlers.chrome?.setComposer(composer);
} else {
screen.append(feed);
}
@@ -2412,17 +2426,18 @@ export function render({ navigate, route, chrome }) {
let activeChannelData = null;
let activeOpenEntrypointHistory = null;
let activeAddMessage = null;
const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.innerHTML = '<span class="channel-header-title">Канал</span><span class="channel-header-owner">Загрузка…</span>';
channelHeaderButton.className = 'topbar-identity channel-header-route-btn';
channelHeaderButton.innerHTML = '<span class="topbar-identity__text"><span class="topbar-identity__title">Канал</span><span class="topbar-identity__sub">Загрузка…</span></span>';
channelHeaderButton.disabled = true;
const header = createTopBar({
center: channelHeaderButton,
back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar',
className: 'channel-view-topbar topbar--identity',
actions: [
{
label: '⋮',
@@ -2442,16 +2457,20 @@ export function render({ navigate, route, chrome }) {
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
const donateRoute = makeShineChannelDonateRoute(routeArgs);
const items = [];
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
items.push({ label: 'О канале', iconHtml: iconHtml('info'), action: () => { if (aboutRoute) navigate(aboutRoute); } });
if (apiData?.isOwnChannel && !apiData?.isDiary) {
items.push({ label: 'Новая запись', iconHtml: iconHtml('compose'), action: () => activeAddMessage?.() });
}
items.push({
label: 'Оглавление',
iconHtml: iconHtml('list'),
action: () => {
if (activeOpenEntrypointHistory) activeOpenEntrypointHistory();
else showToast('В этом канале пока нет оглавления');
},
});
if (!apiData?.isOwnChannel) {
items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } });
items.push({ label: 'Поддержать автора', iconHtml: iconHtml('gift'), action: () => { if (donateRoute) navigate(donateRoute); } });
}
if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) {
if (apiData?.isSubscribed) {
@@ -2868,15 +2887,21 @@ export function render({ navigate, route, chrome }) {
};
activeOpenEntrypointHistory = openEntrypointHistory;
if (channelHeaderButton) {
const ownerLabel = String(apiData?.channel?.ownerName || '').trim();
// Короткий адрес канала и число подписчиков; владелец и полный путь — в «О канале»
const slug = String(apiData?.channel?.name || '').trim();
const subs = Number(apiData?.channel?.subscribersCount || 0);
channelHeaderButton.replaceChildren();
const text = document.createElement('span');
text.className = 'topbar-identity__text';
const titleNode = document.createElement('span');
titleNode.className = 'channel-header-title';
titleNode.className = 'topbar-identity__title';
titleNode.textContent = titleLabel;
const ownerNode = document.createElement('span');
ownerNode.className = 'channel-header-owner';
ownerNode.textContent = ownerLabel ? `@${ownerLabel}` : 'О канале';
channelHeaderButton.append(titleNode, ownerNode);
const subNode = document.createElement('span');
subNode.className = 'topbar-identity__sub';
subNode.textContent = apiData?.isDiary ? 'Только вам' : [slug ? `@${slug}` : '', subs ? String(subs) : ''].filter(Boolean).join(' · ');
text.append(titleNode, subNode);
channelHeaderButton.append(createChannelAvatarElement(apiData?.channel, 40), text);
channelHeaderButton.setAttribute('aria-label', `${titleLabel}. О канале`);
channelHeaderButton.disabled = false;
channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget);
@@ -2910,9 +2935,10 @@ export function render({ navigate, route, chrome }) {
restorePosition,
showStatus,
isActive: () => !disposed,
onAddMessage: () => {
onAddMessage: activeAddMessage = () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
channelTitle: apiData?.channel?.displayTitle || '',
navigate,
isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => {
+2 -2
View File
@@ -934,7 +934,7 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
</div>
<div class="channel-row-controls">
<span class="channel-row-time">—</span>
<span class="channel-row-time">–</span>
</div>
`;
row.prepend(createChannelAvatar(channel));
@@ -1163,7 +1163,7 @@ export function render({ navigate, route, chrome }) {
className: 'topbar--root',
actions: [
{
label: '+',
icon: 'plus',
title: 'Создать канал',
ariaLabel: 'Создать канал',
className: 'channels-create-btn',
+22 -6
View File
@@ -1,3 +1,4 @@
import { iconHtml } from '../components/ui-icon.js';
import { createTopBar } from '../components/topbar.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { renderUserAvatar } from '../components/avatar-image.js';
@@ -98,18 +99,18 @@ function createChatHeaderParts(login, navigate) {
const identityButton = document.createElement('button');
identityButton.type = 'button';
identityButton.className = 'chat-header-peer-btn';
identityButton.className = 'topbar-identity chat-header-peer-btn';
identityButton.title = `Меню ${cleanLogin}`;
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot';
const textWrap = document.createElement('span');
textWrap.className = 'chat-header-peer-text';
textWrap.className = 'topbar-identity__text chat-header-peer-text';
const nameEl = document.createElement('span');
nameEl.className = 'chat-header-peer-name';
nameEl.className = 'topbar-identity__title chat-header-peer-name';
const metaEl = document.createElement('span');
metaEl.className = 'chat-header-peer-meta';
metaEl.className = 'topbar-identity__sub chat-header-peer-meta';
textWrap.append(nameEl, metaEl);
identityButton.append(avatarSlot, textWrap);
@@ -119,7 +120,9 @@ function createChatHeaderParts(login, navigate) {
const fullName = [firstName, lastName].filter(Boolean).join(' ');
nameEl.textContent = fullName || cleanLogin;
const relation = chatRelationLabel(currentPeer?.relationType);
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
// Статус аккаунта, а не «в сети»: присутствие сервер не сообщает, выдумывать его нельзя
const shining = String(currentPeer?.shineStatus || '').toLowerCase() === 'shining' ? '✧ сияющий аккаунт' : '';
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', shining, relation].filter(Boolean).join(' · ');
metaEl.hidden = !metaEl.textContent;
const avatar = renderUserAvatar({
login: cleanLogin,
@@ -768,6 +771,14 @@ function resolveDeliveryStatus(messages, msg) {
return '…';
}
// Статус сообщения — иконкой и словом для экранного чтения (цвет не единственный носитель смысла)
const DELIVERY_STATUS_VIEW = {
'✓✓': { icon: 'check-double', label: 'Прочитано' },
'✓': { icon: 'check', label: 'Отправлено' },
'!': { icon: 'alert', label: 'Не отправлено' },
'…': { icon: 'clock', label: 'Отправляется' },
};
function resolveDeliveryTone(messages, msg) {
if (resolveEffectiveReadState(messages, msg).isRead) return 'read';
const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase();
@@ -1199,7 +1210,11 @@ function renderLog(
if (status) {
const statusNode = document.createElement('span');
statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`;
statusNode.textContent = status;
const statusView = DELIVERY_STATUS_VIEW[status] || DELIVERY_STATUS_VIEW['…'];
statusNode.innerHTML = iconHtml(statusView.icon);
statusNode.setAttribute('role', 'img');
statusNode.setAttribute('aria-label', statusView.label);
statusNode.title = statusView.label;
metaNode.append(statusNode);
}
@@ -1429,6 +1444,7 @@ export function render({ navigate, route, chrome }) {
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
const chatHeader = createTopBar({
center: chatHeaderParts.center,
className: 'topbar--identity',
back: { label: '←', onClick: () => navigate('messages-list') },
actions: [
{
+3 -3
View File
@@ -63,9 +63,9 @@ export function render({navigate, chrome}) {
<div class="stack" style="gap:6px;">
<p class="meta-muted">пользователь выбирает ключи для передачи</p>
<p class="meta-muted">передать можно только существующие ключи</p>
<p class="meta-muted">если ключа нет — он недоступен</p>
<p class="meta-muted">blockchain key — можно передать или нет</p>
<p class="meta-muted">root key — только если существует</p>
<p class="meta-muted">если ключа нет – он недоступен</p>
<p class="meta-muted">blockchain key – можно передать или нет</p>
<p class="meta-muted">root key – только если существует</p>
<p class="meta-muted">client key передаётся всегда</p>
<p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p>
+3 -3
View File
@@ -209,9 +209,9 @@ function showClientUpdateHelp() {
'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с Сиянием.\n'
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ '3) Если приложение установлено как PWA – удалите его с устройства.\n'
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
+ '5) Если всё ещё старая версия – откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
);
}
@@ -315,7 +315,7 @@ export function render({navigate, chrome}) {
};
const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) {
setPwaLabel('Приложение установлено — проверить уведомления');
setPwaLabel('Приложение установлено – проверить уведомления');
return;
}
if (canInstallPwa()) {
+4 -14
View File
@@ -1,4 +1,5 @@
import { createTopBar } from '../components/topbar.js';
import { iconHtml } from '../components/ui-icon.js';
import {
authService,
authorizeSession,
@@ -58,7 +59,7 @@ function requestCardHtml(request) {
const shortCode = formatPairingShortCode(request?.shortCode || '');
const client = String(request?.requesterClientPlatform || 'unknown');
const requesterSessionType = Number(request?.requesterSessionType || 0);
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '–';
const sessionOnly = requesterSessionType === SESSION_TYPE_WALLET;
const sessionKind = pairingSessionKindLabel(requesterSessionType);
return `
@@ -125,19 +126,8 @@ async function runPairingOpWithSessionRestore(runAction) {
function makePasswordToggleIcons() {
return {
eye: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`,
eyeOff: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`,
eye: iconHtml('eye', false, 'key-toggle-icon'),
eyeOff: iconHtml('eyeOff', false, 'key-toggle-icon'),
};
}
+64 -45
View File
@@ -10,14 +10,14 @@ import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '.
export const pageMeta = { id: 'entry-settings-view', title: 'Настройки входа', showAppChrome: false };
const SERVER_FIELDS = [
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
{ key: 'solanaServer', label: 'Сервер Solana' },
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
{ key: 'arweaveServer', label: 'Сервер Arweave' },
];
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack entry-settings';
const draft = {
language: state.entrySettings.language,
@@ -30,62 +30,79 @@ export function render({ navigate }) {
const timers = new Map();
let disposed = false;
const checks = [];
const body = document.createElement('div');
body.className = 'card stack';
body.className = 'entry-servers';
SERVER_FIELDS.forEach((field) => {
const block = document.createElement('div');
block.className = 'stack';
block.className = 'entry-server';
const head = document.createElement('div');
head.className = 'entry-server__head';
const title = document.createElement('label');
title.className = 'field-label';
title.htmlFor = `entry-${field.key}`;
title.textContent = field.label;
// Статус — он же кнопка повторной проверки.
const checkButton = document.createElement('button');
checkButton.className = 'text-btn entry-status';
checkButton.type = 'button';
const input = document.createElement('input');
input.className = 'input';
input.id = `entry-${field.key}`;
input.type = 'text';
input.autocapitalize = 'off';
input.spellcheck = false;
input.value = draft[field.key];
const controls = document.createElement('div');
controls.className = 'row wrap-row';
const checkButton = document.createElement('button');
checkButton.className = 'secondary-btn server-check-btn';
checkButton.type = 'button';
checkButton.textContent = 'Проверить';
const status = document.createElement('span');
status.className = 'status-line';
const hint = document.createElement('p');
hint.className = 'entry-server__hint';
const applyStatus = (value, exactAddress = '') => {
draft.statuses[field.key] = value;
checkButton.classList.remove('is-available', 'is-unavailable');
status.classList.remove('is-available', 'is-unavailable');
if (disposed) return;
if (value !== 'checking') draft.statuses[field.key] = value;
checkButton.classList.remove('is-available', 'is-unavailable', 'is-checking');
checkButton.disabled = false;
if (value === 'available') {
status.textContent = field.key === 'shineServerLogin' && exactAddress
? `Доступен: ${exactAddress}`
: 'Доступен';
checkButton.textContent = 'Доступен';
checkButton.classList.add('is-available');
status.classList.add('is-available');
} else if (value === 'unavailable') {
status.textContent = 'Недоступен';
checkButton.textContent = 'Недоступен';
checkButton.classList.add('is-unavailable');
status.classList.add('is-unavailable');
} else if (value === 'checking') {
checkButton.textContent = 'Проверка…';
checkButton.classList.add('is-checking');
checkButton.disabled = true;
} else {
status.textContent = field.key === 'shineServerLogin' && draft.shineServerHttp
? `Текущий адрес: ${draft.shineServerHttp}`
: 'Статус не проверен';
checkButton.textContent = 'Проверить';
}
checkButton.setAttribute('aria-label', `${field.label}: ${checkButton.textContent}. Проверить ещё раз`);
if (field.key === 'shineServerLogin') {
const address = exactAddress || draft.shineServerHttp;
hint.textContent = address || '';
hint.hidden = !address;
}
};
let checkSeq = 0;
const runCheck = async () => {
draft[field.key] = input.value.trim();
checkButton.disabled = true;
checkButton.textContent = 'Проверка...';
window.clearTimeout(timers.get(field.key));
// Результат устаревшей проверки (адрес успели поменять) не должен перезаписать новый.
const seq = ++checkSeq;
applyStatus('checking');
try {
if (field.key === 'shineServerLogin') {
const resolved = await resolveAndCheckShineServerLogin(input.value, draft.solanaServer);
if (seq !== checkSeq) return;
draft.shineServerHttp = resolved.httpBase;
draft.shineServer = resolved.wsUrl;
applyStatus(resolved.status, resolved.httpBase);
@@ -93,26 +110,29 @@ export function render({ navigate }) {
const next = await checkServerAvailabilityByKey(field.key, input.value, {
solanaEndpoint: draft.solanaServer,
});
if (seq !== checkSeq) return;
applyStatus(next);
}
} finally {
checkButton.disabled = false;
checkButton.textContent = 'Проверить';
} catch {
if (seq === checkSeq) applyStatus('unavailable');
}
};
applyStatus(draft.statuses[field.key]);
hint.hidden = field.key !== 'shineServerLogin' || !hint.textContent;
checks.push(runCheck);
checkButton.addEventListener('click', runCheck);
input.addEventListener('input', () => {
draft[field.key] = input.value;
checkSeq += 1;
applyStatus('idle');
window.clearTimeout(timers.get(field.key));
timers.set(field.key, window.setTimeout(() => {
void runCheck();
}, 3000));
});
input.addEventListener('blur', () => {
input.addEventListener('change', () => {
void runCheck();
});
input.addEventListener('keydown', (event) => {
@@ -122,17 +142,17 @@ export function render({ navigate }) {
}
});
controls.append(checkButton, status);
block.append(title, input, controls);
head.append(title, checkButton);
block.append(head, input, hint);
body.append(block);
});
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.className = 'entry-settings__actions';
if (isLocalDemoAvailable()) {
const localDemoButton = document.createElement('button');
localDemoButton.className = 'secondary-btn preauth-local-demo-btn';
localDemoButton.className = 'secondary-btn';
localDemoButton.type = 'button';
localDemoButton.textContent = 'Открыть локальный тестовый режим';
localDemoButton.addEventListener('click', () => {
@@ -145,18 +165,12 @@ export function render({ navigate }) {
const serverUiButton = document.createElement('button');
serverUiButton.className = 'secondary-btn';
serverUiButton.type = 'button';
serverUiButton.textContent = 'Настроить свой сервер';
serverUiButton.textContent = 'Настроить свой сервер ↗';
serverUiButton.addEventListener('click', () => {
const url = new URL('server-ui.html', window.location.href);
window.open(url.toString(), '_blank', 'noopener');
});
const cancelButton = document.createElement('button');
cancelButton.className = 'secondary-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', () => navigate('start-view'));
const saveButton = document.createElement('button');
saveButton.className = 'primary-btn';
saveButton.type = 'button';
@@ -170,7 +184,8 @@ export function render({ navigate }) {
}
});
actions.append(serverUiButton, cancelButton, saveButton);
actions.prepend(saveButton);
actions.append(serverUiButton);
screen.append(
createTopBar({
@@ -182,8 +197,12 @@ export function render({ navigate }) {
);
screen.cleanup = () => {
disposed = true;
timers.forEach((timerId) => window.clearTimeout(timerId));
};
// Серверы проверяются сразу — зелёный «Доступен» видно без лишних нажатий.
checks.forEach((check) => { void check(); });
return screen;
}
+105 -122
View File
@@ -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);
+25 -15
View File
@@ -208,23 +208,33 @@ export function render({ navigate, chrome }) {
];
let reloadForFilter = () => {};
const filterRow = document.createElement('div');
filterRow.className = 'chip-row chip-row--bleed dm-filter-row';
filterRow.setAttribute('role', 'toolbar');
filterRow.className = 'tabs tabs--auto tabs--dense dm-filter-row';
filterRow.setAttribute('role', 'radiogroup');
filterRow.setAttribute('aria-label', 'Фильтр чатов');
const selectFilter = (value) => {
if (currentChatFilter === value) return;
currentChatFilter = value;
filterRow.querySelectorAll('.tab-btn').forEach((el) => el.setAttribute('aria-checked', String(el.dataset.filter === value)));
reloadForFilter();
};
CHAT_FILTERS.forEach(([value, label]) => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'chip';
chip.dataset.filter = value;
chip.textContent = label;
chip.setAttribute('aria-pressed', String(value === currentChatFilter));
chip.addEventListener('click', () => {
if (currentChatFilter === value) return;
currentChatFilter = value;
filterRow.querySelectorAll('.chip').forEach((el) => el.setAttribute('aria-pressed', String(el.dataset.filter === value)));
reloadForFilter();
});
filterRow.append(chip);
const button = document.createElement('button');
button.type = 'button';
button.className = 'tab-btn';
button.dataset.filter = value;
button.textContent = label;
button.setAttribute('role', 'radio');
button.setAttribute('aria-checked', String(value === currentChatFilter));
button.addEventListener('click', () => selectFilter(value));
filterRow.append(button);
});
filterRow.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const index = CHAT_FILTERS.findIndex(([value]) => value === currentChatFilter);
const next = CHAT_FILTERS[(index + (event.key === 'ArrowRight' ? 1 : -1) + CHAT_FILTERS.length) % CHAT_FILTERS.length][0];
selectFilter(next);
filterRow.querySelector(`[data-filter="${next}"]`)?.focus();
event.preventDefault();
});
const head = createTopBar({
+1 -1
View File
@@ -935,7 +935,7 @@ export function render({ navigate, route, chrome } = {}) {
historyChip.disabled = x2Enabled;
historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false');
historyChip.title = x2Enabled
? '«Друзья друзей» показывают отдельную карту — недавние временно не используются.'
? '«Друзья друзей» показывают отдельную карту – недавние временно не используются.'
: (historyDepth > 0
? `На карте остаются последние ${historyDepth} человека, через которых вы переходили. Нажмите, чтобы изменить.`
: 'Недавние выключены. Нажмите, чтобы на карте оставался предыдущий человек.');
+16 -16
View File
@@ -255,7 +255,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const a11y = document.createElement('div');
a11y.className = 'fg-a11y';
a11y.setAttribute('role', 'region');
a11y.setAttribute('aria-label', 'Карта связей — список');
a11y.setAttribute('aria-label', 'Карта связей – список');
stage.append(edgesSvg, world, a11y);
ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов)
@@ -656,7 +656,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const focus = nodes.find((n) => n.isFocus);
const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus);
const rel = { family: 'семья', friend: 'друг', close_friend: 'близкий друг', business: 'бизнес', contact: 'контакт' };
const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join('');
const items = tier1.map((n) => `<li>${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' – сияющий' : ''} (${rel[n.relationType] || 'связь'})</li>`).join('');
a11y.innerHTML = `<p>Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.</p><ul>${items}</ul>`;
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
@@ -696,7 +696,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.hidden = false;
node.bloom = false;
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
// обновляем классы элемента (роль/тип/свечение/уровень) – без пересоздания DOM
node.el.className = spec.dotOnly
? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
: ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', src.official ? 'is-official' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
@@ -711,7 +711,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// Ограничение ручного pan: карту можно свободно двигать, пока её видимая геометрия пересекает
// обе центральные оси viewport. На крайнем положении к центральной линии прижимается край
// крайнего аватара — полностью выбросить весь граф за экран больше нельзя.
// крайнего аватара – полностью выбросить весь граф за экран больше нельзя.
function clampCameraPosition(nextX, nextY) {
const visible = nodes.filter((n) => !n.hidden && (Number(n.opacity) || 0) > 0.02);
if (!visible.length) return { x: 0, y: 0 };
@@ -783,14 +783,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
n.el.style.transform =
`translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale * ds})`;
n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight/глубина: затемнённые тусклее
// расфокус глубины (dive): фоновые узлы уходят в blur; чёткие — без фильтра (дёшево)
// расфокус глубины (dive): фоновые узлы уходят в blur; чёткие – без фильтра (дёшево)
n.el.style.filter = (n.depthBlur > 0.15 && n.opacity > 0.02) ? `blur(${n.depthBlur.toFixed(1)}px)` : '';
n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : '';
}
}
// Глубина 2-3 уровней (только лаборатория): узлы tier≥2 не участвуют в физике. По умолчанию
// СКРЫТЫ (схлопнуты в родителя, opacity 0) — и «выплывают» вокруг родителя по мере его раскрытия
// СКРЫТЫ (схлопнуты в родителя, opacity 0) – и «выплывают» вокруг родителя по мере его раскрытия
// (parent.expandP: 0 скрыто → 1 выплыло). Так нет «каши»: видно только то, с чем взаимодействуешь.
function layoutDeep() {
if (!hasDeep) return;
@@ -820,13 +820,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const pr = ORB_R * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы)
const cnt = childCountByParent.get(n.parentId) || 1;
const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место)
const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите
const r = ringR * e; // при e=0 – в центре родителя, при 1 – на полной орбите
// ВЕЕР «полукругом наружу»: раскрываем детей в сторону ОТ пути назад (от деда к родителю),
// чтобы они не перекрывали нить-крошку и родителя. Равномерно по индексу среди братьев.
const gp = nodeById.get(p.parentId);
const ox = p.x - (gp ? gp.x : 0);
const oy = p.y - (gp ? gp.y : 0);
const outward = (ox || oy) ? Math.atan2(oy, ox) : n.deepAngle; // направление наружу (фолбэк — старый угол)
const outward = (ox || oy) ? Math.atan2(oy, ox) : n.deepAngle; // направление наружу (фолбэк – старый угол)
const t = cnt > 1 ? ((n.sibIndex || 0) / (cnt - 1) - 0.5) : 0; // -0.5..0.5 по веера
const ang = outward + t * DEEP_FAN;
n.x = p.x + Math.cos(ang) * r;
@@ -834,7 +834,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY;
// tier-3: точка-звезда (scale ~1, CSS фиксирует 9px) ИЛИ аватарка при LOD-апгрейде (мельче, 52px*0.42)
const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1);
// вложенность: tier-3 виден только когда виден его tier-2 родитель (он сам — глубокий)
// вложенность: tier-3 виден только когда виден его tier-2 родитель (он сам – глубокий)
const parentVis = p.tier >= 2 ? ((p.opacity || 0) > 0.04 ? 1 : 0) : 1;
n.opacity = baseOp * e * parentVis;
n.scale = baseSc * (0.3 + 0.7 * e); // лёгкий «поп» при выплывании
@@ -861,7 +861,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return moving;
}
// Во время CSS-bloom (когда renderNodes не вызывается — узлы 1-го уровня анимирует компоновщик)
// Во время CSS-bloom (когда renderNodes не вызывается – узлы 1-го уровня анимирует компоновщик)
// отдельно позиционируем глубокие узлы из JS, чтобы они следовали за родителями.
function renderDeepNodes() {
if (!hasDeep) return;
@@ -875,14 +875,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
}
// LOD (level-of-detail): при сильном зуме (pinch/dive) видимые точки 3-го уровня дорисовываются как
// маленькие аватарки (лицо+имя), при отдалении — сворачиваются обратно в светящиеся точки.
// маленькие аватарки (лицо+имя), при отдалении – сворачиваются обратно в светящиеся точки.
// Пере-рендер DOM только при пересечении порога (редкое событие); ежекадровая проверка дешёвая.
function updateLod() {
if (!hasDeep) return;
for (const n of nodes) {
if (n.tier < 3) continue; // LOD касается только микрозвёзд 3-го уровня
const isFull = n.lod === 'full';
// гистерезис: апгрейд на UP, откат на DOWN — у промежуточного зума состояние «залипает» (без мигания)
// гистерезис: апгрейд на UP, откат на DOWN – у промежуточного зума состояние «залипает» (без мигания)
const threshold = isFull ? LOD_ZOOM_DOWN : LOD_ZOOM_UP;
const wantFull = zoom >= threshold && (n.opacity > 0.04); // апгрейдим только видимые
if (wantFull === isFull) continue;
@@ -986,8 +986,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
edgesSvg.innerHTML = '';
return;
}
// концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость.
// Z — зум камеры: SVG-слой отдельный (не масштабируется), поэтому координаты узлов умножаем на zoom.
// концы линий – строго по центрам узлов (намертво привязаны), изгиб даёт скорость.
// Z – зум камеры: SVG-слой отдельный (не масштабируется), поэтому координаты узлов умножаем на zoom.
const Z = zoom;
const tx = (n) => centerX + camX + n.x * Z;
const ty = (n) => centerY + camY + n.y * Z;
@@ -1007,14 +1007,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
const fx = centerX + camX + parent.x * Z;
const fy = centerY + camY + parent.y * Z;
// радиус контакта = реальный радиус сферы орба: полный орб = ORB_R (см. renderNodes pr=ORB_R*…),
// лёгкая точка (.fg-dot) = её dotRadius. Старое dotRadius у орбов (32/16) — легаси, давало разный зазор.
// лёгкая точка (.fg-dot) = её dotRadius. Старое dotRadius у орбов (32/16) – легаси, давало разный зазор.
const fr = (parent.dotOnly ? parent.dotRadius : ORB_R) * parent.scale * (parent.depthScale ?? 1) * Z;
const nx = tx(n);
const ny = ty(n);
if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue;
if ((ny < -80 && fy < -80) || (ny > viewH + 80 && fy > viewH + 80)) continue;
// Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) — линию тянем к его ФИНАЛЬНОЙ точке
// Эффект ПРОРАСТАНИЯ: новый узел во время разлёта (bloom) – линию тянем к его ФИНАЛЬНОЙ точке
// и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/
// общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash).
const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1;
+61 -98
View File
@@ -3,6 +3,8 @@ import { renderUserAvatar } from '../components/avatar-image.js';
import { authService, state } from '../state.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
import { formatListTime } from '../services/channels-ux.js';
import { iconHtml } from '../components/ui-icon.js';
const CONNECTION_CLOSE_FRIEND = 10;
const CONNECTION_UNCLOSE_FRIEND = 11;
@@ -19,21 +21,21 @@ const profileSnapshotPending = new Map();
function connectionActionLabel(typeCode) {
switch (Number(typeCode)) {
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья.';
case CONNECTION_UNCLOSE_FRIEND: return 'Удалил(а) вас из близких друзей.';
case CONNECTION_FRIEND: return 'Добавил(а) вас в друзья.';
case CONNECTION_UNFRIEND: return 'Удалил(а) вас из друзей.';
case CONNECTION_SHINE_CONFIRMED: return 'Подтвердил(а), что вы Сияющий.';
case CONNECTION_SHINE_UNCONFIRMED: return 'Снял(а) подтверждение «Сияющий».';
case CONNECTION_OFFICIAL_CONFIRMED: return 'Подтвердил(а) официальный статус аккаунта.';
case CONNECTION_OFFICIAL_UNCONFIRMED: return 'Снял(а) подтверждение официального статуса.';
default: return 'Изменил(а) связь с вами.';
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья';
case CONNECTION_UNCLOSE_FRIEND: return 'Убрал(а) вас из близких друзей';
case CONNECTION_FRIEND: return 'Добавил(а) вас в друзья';
case CONNECTION_UNFRIEND: return 'Убрал(а) вас из друзей';
case CONNECTION_SHINE_CONFIRMED: return 'Подтвердил(а), что вы сияющий';
case CONNECTION_SHINE_UNCONFIRMED: return 'Снял(а) подтверждение «сияющий»';
case CONNECTION_OFFICIAL_CONFIRMED: return 'Подтвердил(а) официальный статус аккаунта';
case CONNECTION_OFFICIAL_UNCONFIRMED: return 'Снял(а) подтверждение официального статуса';
default: return 'Изменил(а) связь с вами';
}
}
function eventActionLabel(typeCode) {
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала.';
return 'Подписался(-ась) на ваш канал.';
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала';
return 'Подписался(-ась) на ваш канал';
}
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
@@ -58,23 +60,6 @@ function normalizeItem(item) {
};
}
function formatRelativeTime(value) {
const ts = Number(value || 0);
if (!Number.isFinite(ts) || ts <= 0) return '';
const diffMs = Math.max(0, Date.now() - ts);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
if (diffMs < minute) return 'сейчас';
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`;
if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`;
if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`;
return `${Math.max(1, Math.floor(diffMs / week))} нед.`;
}
function profileField(snapshot, key) {
const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : [])
.find((field) => String(field?.key || '') === key);
@@ -153,7 +138,7 @@ async function enrichItem(item, activeTab) {
function renderEmpty(activeTab) {
const card = document.createElement('article');
card.className = 'card stack notification-empty-state';
card.className = 'stack notification-empty-state';
const title = document.createElement('strong');
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
const text = document.createElement('p');
@@ -163,72 +148,50 @@ function renderEmpty(activeTab) {
return card;
}
function renderIdentity(item) {
function renderAvatar(item) {
const profile = item.profile;
const firstName = profileField(profile, 'first_name');
const lastName = profileField(profile, 'last_name');
const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь';
const avatar = profile?.avatar?.txId
? {
ar: String(profile.avatar.txId || '').trim(),
sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(),
}
: null;
const header = document.createElement('div');
header.className = 'notification-identity';
header.append(renderUserAvatar({
return renderUserAvatar({
login: item.sourceLogin || 'unknown',
firstName,
lastName,
firstName: profileField(profile, 'first_name'),
lastName: profileField(profile, 'last_name'),
avatar,
size: 'md',
className: 'notification-avatar',
}));
});
}
const text = document.createElement('div');
text.className = 'notification-identity-text';
const primary = document.createElement('div');
primary.className = 'notification-identity-primary';
// Строка как в списке чатов: имя и время, ниже – что произошло и текст ответа.
function renderHead(item) {
const profile = item.profile;
const fullName = [profileField(profile, 'first_name'), profileField(profile, 'last_name')].filter(Boolean).join(' ')
|| item.sourceLogin || 'Пользователь';
const head = document.createElement('div');
head.className = 'notification-card__head';
const name = document.createElement('strong');
name.className = 'notification-person-name';
name.className = 'notification-card__name';
name.textContent = fullName;
primary.append(name);
const login = String(item.sourceLogin || '').trim();
if (login) {
const loginEl = document.createElement('span');
loginEl.className = 'notification-login';
loginEl.textContent = `@${login}`;
primary.append(loginEl);
}
const relative = formatRelativeTime(item.createdAtMs);
if (relative) {
const separator = document.createElement('span');
separator.className = 'notification-time-separator';
separator.textContent = '·';
const time = document.createElement('span');
time.className = 'notification-time';
time.textContent = relative;
primary.append(separator, time);
}
text.append(primary);
header.append(text);
return header;
head.append(name);
const time = document.createElement('span');
time.className = 'notification-card__time';
time.textContent = formatListTime(item.createdAtMs);
head.append(time);
return head;
}
function renderEngagement(engagement) {
if (!engagement) return null;
const stats = [
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
{ key: 'likesCount', icon: 'heart', label: 'Лайки' },
{ key: 'repliesCount', icon: 'message', label: 'Ответы' },
{ key: 'repostsCount', icon: 'refresh', label: 'Репосты' },
{ key: 'sharesCount', icon: 'forward', label: 'Отправки' },
].filter(({ key }) => Number(engagement[key] || 0) > 0);
if (!stats.length) return null;
@@ -243,7 +206,7 @@ function renderEngagement(engagement) {
const iconEl = document.createElement('span');
iconEl.className = 'notification-engagement-icon';
iconEl.setAttribute('aria-hidden', 'true');
iconEl.textContent = icon;
iconEl.innerHTML = iconHtml(icon);
const countEl = document.createElement('span');
countEl.className = 'notification-engagement-count';
@@ -289,31 +252,31 @@ function bindNotificationNavigation(row, routePath, navigate) {
function renderItem(item, activeTab, navigate) {
const row = document.createElement('article');
row.className = 'card stack notification-card';
row.className = 'notification-card';
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
row.append(renderIdentity(item));
const action = document.createElement('p');
action.className = 'notification-action';
if (activeTab === 'connections') {
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
} else if (activeTab === 'events') {
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
} else {
action.textContent = 'Ответил(а) на ваше сообщение.';
}
row.append(action);
const body = document.createElement('div');
body.className = 'notification-card__body';
body.append(renderHead(item));
if (activeTab === 'replies') {
const body = document.createElement('p');
body.className = 'notification-content';
body.textContent = item.sourceText || 'Ответ без текста.';
row.append(body);
// Во вкладке «Ответы» всё – ответы: подпись «ответил(а)» не нужна, сразу текст.
const text = document.createElement('p');
text.className = 'notification-card__text';
text.textContent = item.sourceText || 'Ответ без текста';
body.append(text);
const engagement = renderEngagement(item.engagement);
if (engagement) row.append(engagement);
if (engagement) body.append(engagement);
} else {
const action = document.createElement('p');
action.className = 'notification-card__action';
action.textContent = activeTab === 'connections'
? connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType)
: eventActionLabel(item.sourceMsgSubType, item);
body.append(action);
}
row.append(renderAvatar(item), body);
return row;
}
@@ -333,7 +296,7 @@ export function render({ navigate, chrome } = {}) {
screen.append(tabs);
const list = document.createElement('div');
list.className = 'stack notifications-list';
list.className = 'notifications-list';
let payloadCache = null;
let requestSeq = 0;
let observer = null;
@@ -407,14 +370,14 @@ export function render({ navigate, chrome } = {}) {
const items=await Promise.all(base.map(x=>enrichItem(x,tab)));
const unread=items.filter(x=>x.createdAtMs>seenAt); const old=items.filter(x=>x.createdAtMs<=seenAt);
const nodes=[];
const groupTitle=(text)=>{const el=document.createElement('h2');el.className='notification-group-title';el.textContent=text;return el;};
if(unread.length) nodes.push(groupTitle(`Новые · ${unread.length}`));
unread.forEach(x=>{const n=renderItem(x,tab,navigate);n.classList.add('notification-card--new');n.dataset.createdAtMs=String(x.createdAtMs);nodes.push(n);});
let divider=null;
if(unread.length){divider=document.createElement('div');divider.className='notification-new-divider';divider.textContent=`НОВЫЕ · ${unread.length}`;nodes.push(divider);}
if(unread.length && old.length) nodes.push(groupTitle('Ранее'));
old.forEach(x=>nodes.push(renderItem(x,tab,navigate))); list.replaceChildren(...nodes);
if(unread.length && 'IntersectionObserver' in window){
observer=new IntersectionObserver(entries=>{ entries.forEach(e=>{ if(e.isIntersecting && e.intersectionRatio>=0.5){ const ts=Number(e.target.dataset.createdAtMs||0); if(ts>0){e.target.classList.remove('notification-card--new');scheduleSeen(tab,ts);} } }); },{threshold:[0.5]});
list.querySelectorAll('.notification-card--new').forEach(n=>observer.observe(n));
requestAnimationFrame(()=>divider?.scrollIntoView({block:'end'}));
}
}
+1 -1
View File
@@ -709,7 +709,7 @@ export function render({ navigate, chrome }) {
});
if (entered === null) return;
if (String(entered).length > Number(field.maxLength || 300)) {
window.alert(`Максимальная длина поля «${field.label}» — ${Number(field.maxLength || 300)} символов.`);
window.alert(`Максимальная длина поля «${field.label}» – ${Number(field.maxLength || 300)} символов.`);
return;
}
+2 -2
View File
@@ -23,7 +23,7 @@ function vapidBase64ToUint8Array(base64String) {
function formatTs(ts) {
const num = Number(ts);
if (!Number.isFinite(num) || num <= 0) return '—';
if (!Number.isFinite(num) || num <= 0) return '–';
try {
return new Date(num).toLocaleString('ru-RU');
} catch {
@@ -100,7 +100,7 @@ function rowEl(label, value, tone) {
const right = document.createElement('span');
right.className = 'pwa-diag-value';
right.textContent = String(value || '—');
right.textContent = String(value || '–');
const indicator = document.createElement('span');
indicator.className = `pwa-diag-indicator is-${tone || 'neutral'}`;
+31 -46
View File
@@ -1,4 +1,5 @@
import { createTopBar } from '../components/topbar.js';
import { iconHtml } from '../components/ui-icon.js';
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
@@ -31,19 +32,8 @@ function normalizeLoginForTemporaryUiGuard(login) {
function makePasswordToggleIcons() {
return {
eye: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`,
eyeOff: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`,
eye: iconHtml('eye', false, 'key-toggle-icon'),
eyeOff: iconHtml('eyeOff', false, 'key-toggle-icon'),
};
}
@@ -97,9 +87,10 @@ export function render({ navigate }) {
passwordInputRow.className = 'inline-input-row';
passwordInputRow.append(passwordInput, passwordToggleButton);
const statusText = document.createElement('p');
// Строка под логином всегда занимает место — сообщения не сдвигают форму.
const statusText = document.createElement('span');
statusText.className = 'status-line registration-login-status';
statusText.style.display = 'none';
statusText.setAttribute('aria-live', 'polite');
const formError = document.createElement('p');
formError.className = 'status-line is-unavailable';
@@ -135,10 +126,24 @@ export function render({ navigate }) {
passwordLengthText.textContent = `Символов в пароле: ${getCurrentPassword().length}`;
}
function setStatusMessage(message, kind = '') {
function setStatusMessage(message, kind = '', details = '') {
statusText.textContent = message;
statusText.title = details || message;
statusText.className = kind ? `status-line registration-login-status ${kind}` : 'status-line registration-login-status';
statusText.style.display = message ? '' : 'none';
}
function showCheckedLoginStatus(isFree, className) {
if (!isFree) {
setStatusMessage('Логин уже занят', 'is-unavailable');
} else if (className === 'free') {
setStatusMessage('Логин свободен', 'is-available');
} else if (className === 'premium') {
setStatusMessage('Премиум-логин – покупка через DAO', 'is-unavailable');
} else if (className === 'company') {
setStatusMessage('Логин бренда – нужно согласование', 'is-unavailable');
} else {
setStatusMessage('Не подходит для обычной регистрации', 'is-unavailable');
}
}
function resetLoginCheckState() {
@@ -186,34 +191,24 @@ export function render({ navigate }) {
const normalizedLogin = normalizeLoginForTemporaryUiGuard(login);
if (!normalizedLogin) {
setStatusMessage('Логин содержит недопустимые символы или имеет неверную длину ❌', 'is-unavailable');
setStatusMessage('Только латиница, цифры и _, до 20 символов', 'is-unavailable');
formError.style.display = 'none';
return false;
}
if (normalizedLogin.length < MIN_REGISTRATION_LOGIN_LENGTH) {
setStatusMessage(`Логин должен быть не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов ❌`, 'is-unavailable');
setStatusMessage(`Не короче ${MIN_REGISTRATION_LOGIN_LENGTH} символов`, 'is-unavailable');
formError.style.display = 'none';
return false;
}
if (login === lastCheckedLogin) {
if (!lastCheckedFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (lastCheckedClassName === 'premium') {
setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable');
} else if (lastCheckedClassName === 'company') {
setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable');
} else {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
showCheckedLoginStatus(lastCheckedFree, lastCheckedClassName);
formError.style.display = 'none';
return lastCheckedFree && lastCheckedClassName === 'free';
}
setStatusMessage('Проверяем логин...');
setStatusMessage('Проверяем логин…', 'is-checking');
try {
const check = await checkLoginExistsOnSolana({
login,
@@ -237,28 +232,18 @@ export function render({ navigate }) {
lastCheckedLogin = login;
lastCheckedFree = isFree;
lastCheckedClassName = className;
if (!isFree) {
setStatusMessage('Логин уже занят ❌', 'is-unavailable');
} else if (className === 'free') {
setStatusMessage('Логин свободен ✅', 'is-available');
} else if (className === 'premium') {
setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable');
} else if (className === 'company') {
setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable');
} else {
setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable');
}
showCheckedLoginStatus(isFree, className);
formError.style.display = 'none';
return isFree && className === 'free';
} catch (error) {
if (runId !== loginCheckRunId) return false;
if (isSolanaRpcUnavailableError(error)) {
setStatusMessage('Нет связи с сервером Solana. Попробуйте ещё раз позже.', 'is-unavailable');
setStatusMessage('Нет связи с Solana – попробуйте позже', 'is-unavailable');
return false;
}
const base = toUserMessage(error, 'Не удалось проверить логин');
const details = formatSolanaErrorDetails(error);
setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable');
setStatusMessage(base, 'is-unavailable', `${base}. Детали: ${details}`);
return false;
}
}
@@ -313,8 +298,8 @@ export function render({ navigate }) {
const passwordLabel = document.createElement('label');
passwordLabel.className = 'stack registration-password-single';
passwordLabel.innerHTML = '<span class="field-label">Пароль</span>';
form.append(loginField, statusText, passwordLabel);
loginField.append(loginInput);
form.append(loginField, passwordLabel);
loginField.append(loginInput, statusText);
passwordLabel.append(passwordInputRow);
form.append(passwordLengthText, formError, faqButton);
actions.innerHTML = '';
@@ -115,11 +115,11 @@ export function render({ navigate }) {
rootSep.textContent = 'Root key (recovery)';
card.append(rootSep);
card.append(makePublicField({
label: 'Root / recovery — публичный (base58)',
label: 'Root / recovery – публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Root / recovery — приватный (seed base58, 32 байта)',
label: 'Root / recovery – приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64)),
}));
@@ -129,11 +129,11 @@ export function render({ navigate }) {
bchSep.textContent = 'Blockchain key';
card.append(bchSep);
card.append(makePublicField({
label: 'Blockchain — публичный (base58)',
label: 'Blockchain – публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Blockchain — приватный (seed base58, 32 байта)',
label: 'Blockchain – приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.blockchainPair.privatePkcs8B64)),
}));
@@ -143,11 +143,11 @@ export function render({ navigate }) {
devSep.textContent = 'Client key (= Solana wallet)';
card.append(devSep);
card.append(makePublicField({
label: 'Client — публичный (base58)',
label: 'Client – публичный (base58)',
value: bytesToBase58(base64ToBytes(keyBundle.clientPair.publicKeyB64)),
}));
card.append(makeSecretField({
label: 'Client — приватный (seed base58, 32 байта)',
label: 'Client – приватный (seed base58, 32 байта)',
value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.clientPair.privatePkcs8B64)),
}));
}
+42 -54
View File
@@ -18,47 +18,44 @@ const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => '');
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack keys-screen';
const isLoginFlow = state.registrationDraft.flowType === 'login';
const normalizedLogin = (state.registrationDraft.login || '').trim();
const displayLogin = normalizedLogin || '@new.user';
const card = document.createElement('div');
card.className = 'card stack';
const title = document.createElement('p');
title.className = 'auth-copy';
title.textContent = isLoginFlow
? `Вход выполнен для логина ${displayLogin}.`
: `Отлично, логин ${displayLogin} зарегистрирован.`;
const displayLogin = normalizedLogin ? `@${normalizedLogin.replace(/^@/, '')}` : '@new.user';
const lead = document.createElement('div');
lead.className = 'keys-lead';
const title = document.createElement('h1');
title.className = 'keys-lead__title';
title.textContent = isLoginFlow ? `Вы вошли как ${displayLogin}` : `${displayLogin} зарегистрирован`;
const question = document.createElement('p');
question.className = 'auth-copy';
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
const nextStep = document.createElement('p');
nextStep.className = 'meta-muted';
nextStep.textContent = 'После сохранения откроется профиль. Для проверки откройте вкладку «Каналы».';
question.className = 'keys-lead__text';
question.textContent = 'Какие ключи хранить на этом устройстве?';
const questionHint = document.createElement('p');
questionHint.className = 'keys-lead__text';
questionHint.textContent = 'Они хранятся в зашифрованном виде.';
lead.append(title, question, questionHint);
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const createKeyInfo = (toggle, titleText, descriptionText) => {
const wrap = document.createElement('div');
wrap.className = 'key-storage-option stack';
const createKeyRow = (toggle, titleText, descriptionText) => {
const row = document.createElement('label');
row.className = 'checkbox-row';
row.append(toggle, document.createTextNode(titleText));
const description = document.createElement('p');
description.className = 'meta-muted key-storage-option__description';
row.className = 'keys-row';
const text = document.createElement('span');
text.className = 'keys-row__text';
const name = document.createElement('span');
name.className = 'keys-row__title';
name.textContent = titleText;
const description = document.createElement('span');
description.className = 'keys-row__hint';
description.textContent = descriptionText;
wrap.append(row, description);
return wrap;
text.append(name, description);
toggle.className = 'switch';
row.append(text, toggle);
return row;
};
const rootToggle = document.createElement('input');
@@ -74,35 +71,23 @@ export function render({ navigate }) {
deviceToggle.checked = true;
deviceToggle.disabled = true;
const rootRow = createKeyInfo(
rootToggle,
'Ключ root',
'Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.',
);
const blockchainRow = createKeyInfo(
blockchainToggle,
'Ключ blockchain',
'Используется для подписи ваших действий и записей в блокчейне Сияния.',
);
const deviceRow = createKeyInfo(
deviceToggle,
'Client key (всегда)',
'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.',
const list = document.createElement('div');
list.className = 'keys-list';
list.append(
createKeyRow(rootToggle, 'Главный ключ', 'Смена пароля, восстановление доступа, важные настройки'),
createKeyRow(blockchainToggle, 'Ключ публикаций', 'Подписывает ваши записи и действия'),
createKeyRow(deviceToggle, 'Ключ устройства', 'Вход с этого устройства – нужен всегда'),
);
const simpleNote = document.createElement('p');
simpleNote.className = 'key-storage-note key-storage-note--strong';
simpleNote.textContent = 'Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.';
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, simpleNote, status);
simpleNote.className = 'keys-note';
simpleNote.textContent = 'Не уверены – оставьте все включёнными.';
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.className = 'keys-actions';
const cancelButton = document.createElement('button');
cancelButton.className = 'ghost-btn';
cancelButton.className = 'secondary-btn';
cancelButton.type = 'button';
cancelButton.textContent = 'Отмена';
cancelButton.addEventListener('click', async () => {
@@ -118,7 +103,7 @@ export function render({ navigate }) {
const okButton = document.createElement('button');
okButton.className = 'primary-btn';
okButton.type = 'button';
okButton.textContent = 'OK';
okButton.textContent = 'Готово';
okButton.addEventListener('click', async () => {
status.style.display = 'none';
try {
@@ -190,7 +175,7 @@ export function render({ navigate }) {
}
});
actions.append(cancelButton, okButton);
actions.append(okButton, cancelButton);
screen.append(
createTopBar({
@@ -208,7 +193,10 @@ export function render({ navigate }) {
},
},
}),
card,
lead,
list,
simpleNote,
status,
actions,
);
+21 -15
View File
@@ -1,4 +1,5 @@
import { createTopBar } from '../components/topbar.js';
import { iconHtml } from '../components/ui-icon.js';
import {
authService,
authorizeSession,
@@ -202,15 +203,27 @@ export function render({ navigate }) {
balanceValue.textContent = `${formatSol(parseBalanceSol(state.registrationPayment.balanceSOL), 6)} SOL`;
const refreshButton = document.createElement('button');
refreshButton.className = 'square-btn';
refreshButton.className = 'icon-btn payment-refresh-btn';
refreshButton.type = 'button';
refreshButton.textContent = '↻';
refreshButton.title = 'Обновить';
refreshButton.innerHTML = iconHtml('refresh');
refreshButton.title = 'Обновить баланс';
refreshButton.setAttribute('aria-label', 'Обновить баланс');
// Коротко и по-человечески; технические подробности — во всплывающей подсказке.
const showWalletError = (error, fallback) => {
status.className = 'status-line is-unavailable';
status.textContent = error?.code === 'NO_KEYS'
? 'Не хватает данных регистрации. Вернитесь назад и нажмите «Далее».'
: fallback;
status.title = error?.message || '';
status.style.display = '';
};
const refreshBalance = async ({ showError = true, addressOverride = '' } = {}) => {
const address = String(addressOverride || walletValue.value || '').trim();
if (!address) return null;
refreshButton.disabled = true;
refreshButton.classList.add('is-loading');
try {
const balance = await getBalanceSol({
endpoint: state.entrySettings.solanaServer,
@@ -220,20 +233,17 @@ export function render({ navigate }) {
balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`;
return Number(balance.sol) || 0;
} catch (error) {
if (showError) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось обновить баланс: ${error?.message || 'unknown'}`;
status.style.display = '';
}
if (showError) showWalletError(error, 'Не удалось обновить баланс. Проверьте связь и попробуйте ещё раз.');
return null;
} finally {
refreshButton.disabled = false;
refreshButton.classList.remove('is-loading');
}
};
const deriveUserWalletAddress = async () => {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.');
if (!keyBundle) throw Object.assign(new Error('Ключи ещё не сгенерированы'), { code: 'NO_KEYS' });
const { publicKeyB64 } = keyBundle.clientPair;
const raw = atob(publicKeyB64);
const bytes = new Uint8Array(raw.length);
@@ -261,9 +271,7 @@ export function render({ navigate }) {
await deriveUserWalletAddress();
navigate('topup-view');
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось подготовить кошелёк: ${error?.message || 'unknown'}`;
status.style.display = '';
showWalletError(error, 'Не удалось подготовить кошелёк. Попробуйте ещё раз.');
}
});
@@ -386,9 +394,7 @@ export function render({ navigate }) {
const walletAddress = await deriveUserWalletAddress();
await refreshBalance({ addressOverride: walletAddress });
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`;
status.style.display = '';
showWalletError(error, 'Не удалось показать кошелёк. Попробуйте открыть экран ещё раз.');
}
})();
+15 -20
View File
@@ -34,26 +34,21 @@ export function render({navigate, chrome}) {
screen.className = 'stack';
let isDisposed = false;
chrome?.setTopbar(createTopBar({
title: 'Настройки',
back: { label: '←', onClick: () => navigate('profile-view') },
}));
const topbar = createTopBar({
title: 'Настройки',
back: { label: '←', onClick: () => navigate('profile-view') },
});
chrome?.setTopbar(topbar);
const logoButton = document.createElement('button');
logoButton.type = 'button';
logoButton.className = 'settings-shine-logo-button';
logoButton.setAttribute('aria-label', 'Логотип Сияния');
logoButton.title = 'SHiNE';
logoButton.innerHTML = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />';
let logoTapCount = 0;
let lastLogoTapAt = 0;
logoButton.addEventListener('click', () => {
// Скрытый вход в «Дополнительные настройки»: пять быстрых нажатий на заголовок.
let titleTapCount = 0;
let lastTitleTapAt = 0;
topbar.querySelector('.topbar__title')?.addEventListener('click', () => {
const now = Date.now();
logoTapCount = now - lastLogoTapAt <= 900 ? logoTapCount + 1 : 1;
lastLogoTapAt = now;
if (logoTapCount < 5) return;
logoTapCount = 0;
titleTapCount = now - lastTitleTapAt <= 900 ? titleTapCount + 1 : 1;
lastTitleTapAt = now;
if (titleTapCount < 5) return;
titleTapCount = 0;
navigate('advanced-settings-view');
});
@@ -68,7 +63,7 @@ export function render({navigate, chrome}) {
<button type="button" class="tab-btn" role="radio" data-mode="light">День</button>
<button type="button" class="tab-btn" role="radio" data-mode="dark">Ночь</button>
</div>
<span class="meta-muted">«Авто» — как на устройстве.</span>
<span class="meta-muted">«Авто» – как на устройстве.</span>
<button type="button" class="secondary-btn settings-theme-editor" id="settings-theme-editor">Настроить цвета</button>
</div>
<div class="nav-list">
@@ -137,7 +132,7 @@ export function render({navigate, chrome}) {
}
});
screen.append(logoButton, card);
screen.append(card);
if (isDeveloperToolsEnabled()) {
const versionCard = document.createElement('div');
+3 -13
View File
@@ -1,4 +1,5 @@
import { createTopBar } from '../components/topbar.js';
import { iconHtml } from '../components/ui-icon.js';
import { state } from '../state.js';
import { bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
@@ -37,19 +38,8 @@ export function render({navigate, chrome}) {
const renderField = (id, label, hint = '') => {
const row = document.createElement('div');
row.className = 'key-card stack';
const eyeIcon = `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`;
const eyeOffIcon = `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`;
const eyeIcon = iconHtml('eye', false, 'key-toggle-icon');
const eyeOffIcon = iconHtml('eyeOff', false, 'key-toggle-icon');
row.innerHTML = `
<div class="row">
<span class="field-label">${label}${hint ? `<small class="key-card__hint">${hint}</small>` : ''}</span>
+3 -3
View File
@@ -36,7 +36,7 @@ function classifyResult(result) {
function shortenText(value, max = 140) {
const text = String(value || '').trim();
if (!text) return '—';
if (!text) return '–';
return text.length > max ? `${text.slice(0, max)}...` : text;
}
@@ -124,7 +124,7 @@ function makeResultCard(endpoint) {
const details = document.createElement('div');
details.className = 'meta-muted solana-rpc-details';
details.textContent = '—';
details.textContent = '–';
meta.append(statusLine, details);
card.append(head, endpointLine, meta);
@@ -185,7 +185,7 @@ export function render({navigate, chrome}) {
ui.badge.className = 'badge alt solana-rpc-badge';
ui.badge.textContent = 'Ожидает проверки';
ui.statusLine.textContent = 'Ещё не проверяли.';
ui.details.textContent = '—';
ui.details.textContent = '–';
});
};
+3 -3
View File
@@ -59,11 +59,11 @@ export function render({navigate, chrome}) {
const economyPdaLine = document.createElement('p');
economyPdaLine.className = 'meta-muted';
economyPdaLine.textContent = 'PDA economy config: —';
economyPdaLine.textContent = 'PDA economy config: –';
const txLine = document.createElement('p');
txLine.className = 'meta-muted';
txLine.textContent = 'TX: —';
txLine.textContent = 'TX: –';
const connectBtn = document.createElement('button');
connectBtn.className = 'text-btn';
@@ -111,7 +111,7 @@ export function render({navigate, chrome}) {
if (!provider || !walletPubkey || !economyPda) return;
initBtn.disabled = true;
status.textContent = 'Отправка транзакции...';
txLine.textContent = 'TX: —';
txLine.textContent = 'TX: –';
try {
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(state.entrySettings.solanaServer || ''), 'confirmed');
+6 -1
View File
@@ -1,4 +1,5 @@
import { clearStartHint } from '../state.js';
import { createShineWordmark } from '../components/shine-wordmark.js';
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
@@ -10,6 +11,10 @@ export function render({ navigate }) {
const logoWrap = document.createElement('div');
logoWrap.className = 'auth-logo-wrap';
// Сияние каждый раз начинается с новой фазы (периоды — как в preauth.css).
[83, 7.3, 127, 11.9].forEach((period, i) => {
logoWrap.style.setProperty(`--halo-phase-${i + 1}`, `${(-Math.random() * period).toFixed(2)}s`);
});
const logo = document.createElement('img');
logo.className = 'auth-logo';
@@ -19,7 +24,7 @@ export function render({ navigate }) {
const title = document.createElement('h1');
title.className = 'auth-brand';
title.textContent = 'Сияние';
title.append(createShineWordmark());
const actions = document.createElement('div');
actions.className = 'auth-actions shine-actions';
+57 -57
View File
@@ -1078,8 +1078,8 @@ export function render({navigate, chrome}) {
const currentCoef = formatPpmCoefText(currentCore.coef.coefPpm);
const usdRaw = String(amountInput.value || '').trim().replace(',', '.');
let amountUsdCents = 0n;
let amountSol = '—';
let maxPaySol = '—';
let amountSol = '–';
let maxPaySol = '–';
let canBuy = true;
try {
const asNumber = Number(usdRaw);
@@ -1324,26 +1324,26 @@ export function render({navigate, chrome}) {
limitLabel.textContent = 'Лимит блокчейна';
const limitValue = document.createElement('h2');
limitValue.style.fontSize = '26px';
limitValue.textContent = '— KB';
limitValue.textContent = '– KB';
const usedLabel = document.createElement('p');
usedLabel.className = 'meta-muted';
usedLabel.textContent = 'Израсходовано (фактически на сервере)';
const usedValue = document.createElement('h2');
usedValue.style.fontSize = '26px';
usedValue.textContent = '— KB';
usedValue.textContent = '– KB';
const leftLabel = document.createElement('p');
leftLabel.className = 'meta-muted';
leftLabel.textContent = 'Осталось';
const leftValue = document.createElement('h2');
leftValue.style.fontSize = '30px';
leftValue.textContent = '— KB';
leftValue.textContent = '– KB';
const pdaLabel = document.createElement('p');
pdaLabel.className = 'meta-muted';
pdaLabel.style.wordBreak = 'break-all';
pdaLabel.textContent = 'PDA: —';
pdaLabel.textContent = 'PDA: –';
const endpointLabel = document.createElement('p');
endpointLabel.className = 'meta-muted';
@@ -1351,22 +1351,22 @@ export function render({navigate, chrome}) {
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
updatedLabel.textContent = 'Обновлено: –';
const serverTitle = document.createElement('h3');
serverTitle.style.margin = '14px 0 0';
serverTitle.textContent = 'Фактическое состояние на сервере';
const serverSizeLabel = document.createElement('p');
serverSizeLabel.className = 'meta-muted';
serverSizeLabel.textContent = 'Размер цепочки: —';
serverSizeLabel.textContent = 'Размер цепочки: –';
const serverLastLabel = document.createElement('p');
serverLastLabel.className = 'meta-muted';
serverLastLabel.textContent = 'Крайний блок: —';
serverLastLabel.textContent = 'Крайний блок: –';
const serverLastHashLabel = document.createElement('p');
serverLastHashLabel.className = 'meta-muted';
serverLastHashLabel.style.wordBreak = 'break-all';
serverLastHashLabel.style.fontSize = '11px';
serverLastHashLabel.textContent = 'Hash: —';
serverLastHashLabel.textContent = 'Hash: –';
card.append(
limitLabel,
@@ -1420,7 +1420,7 @@ export function render({navigate, chrome}) {
serverSizeLabel.textContent = `Размер цепочки: ${formatKbFromBytes(serverState.sizeBytes)}`;
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '–'}`;
setStatus('Данные лимита и состояния блокчейна обновлены.');
} catch (error) {
@@ -1503,8 +1503,8 @@ export function render({navigate, chrome}) {
publicationStatus.className = 'card stack';
publicationStatus.innerHTML = `
<h3 style="margin:0;">Статус публикации</h3>
<p class="meta-muted" id="publish-status-main">—</p>
<p class="meta-muted" id="publish-status-local">—</p>
<p class="meta-muted" id="publish-status-main">–</p>
<p class="meta-muted" id="publish-status-local">–</p>
`;
const publicationMainEl = publicationStatus.querySelector('#publish-status-main');
const publicationLocalEl = publicationStatus.querySelector('#publish-status-local');
@@ -1513,13 +1513,13 @@ export function render({navigate, chrome}) {
pdaCard.className = 'card stack';
pdaCard.innerHTML = `
<h3 style="margin:0;">Что закреплено в PDA</h3>
<p class="meta-muted" id="pda-name">Блокчейн: —</p>
<p class="meta-muted" id="pda-address" style="word-break:break-all;">PDA: —</p>
<p class="meta-muted" id="pda-last">Крайний блок: —</p>
<p class="meta-muted" id="pda-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="pda-used">Размер: —</p>
<p class="meta-muted" id="pda-limit">Лимит: —</p>
<p class="meta-muted" id="pda-arweave" style="word-break:break-all;">Arweave tx: —</p>
<p class="meta-muted" id="pda-name">Блокчейн: –</p>
<p class="meta-muted" id="pda-address" style="word-break:break-all;">PDA: –</p>
<p class="meta-muted" id="pda-last">Крайний блок: –</p>
<p class="meta-muted" id="pda-hash" style="word-break:break-all; font-size:11px;">Hash: –</p>
<p class="meta-muted" id="pda-used">Размер: –</p>
<p class="meta-muted" id="pda-limit">Лимит: –</p>
<p class="meta-muted" id="pda-arweave" style="word-break:break-all;">Arweave tx: –</p>
<a class="text-btn" id="pda-arweave-link" href="#" target="_blank" rel="noreferrer noopener" style="pointer-events:none; opacity:.55; padding:0;">Открыть Arweave</a>
`;
@@ -1527,25 +1527,25 @@ export function render({navigate, chrome}) {
serverCard.className = 'card stack';
serverCard.innerHTML = `
<h3 style="margin:0;">Что реально на сервере</h3>
<p class="meta-muted" id="server-name">Блокчейн: —</p>
<p class="meta-muted" id="server-last">Крайний блок: —</p>
<p class="meta-muted" id="server-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="server-size">Размер: —</p>
<p class="meta-muted" id="server-updated">Обновлено: —</p>
<p class="meta-muted" id="server-name">Блокчейн: –</p>
<p class="meta-muted" id="server-last">Крайний блок: –</p>
<p class="meta-muted" id="server-hash" style="word-break:break-all; font-size:11px;">Hash: –</p>
<p class="meta-muted" id="server-size">Размер: –</p>
<p class="meta-muted" id="server-updated">Обновлено: –</p>
`;
const localCard = document.createElement('div');
localCard.className = 'card stack';
localCard.innerHTML = `
<h3 style="margin:0;">Локальный слепок в этом браузере</h3>
<p class="meta-muted" id="local-state">Слепок: —</p>
<p class="meta-muted" id="local-transport">Способ: —</p>
<p class="meta-muted" id="local-last">Крайний блок: —</p>
<p class="meta-muted" id="local-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="local-size">Размер: —</p>
<p class="meta-muted" id="local-arweave" style="word-break:break-all;">Arweave tx: —</p>
<p class="meta-muted" id="local-created">Создан: —</p>
<p class="meta-muted" id="local-solana">Solana: —</p>
<p class="meta-muted" id="local-state">Слепок: –</p>
<p class="meta-muted" id="local-transport">Способ: –</p>
<p class="meta-muted" id="local-last">Крайний блок: –</p>
<p class="meta-muted" id="local-hash" style="word-break:break-all; font-size:11px;">Hash: –</p>
<p class="meta-muted" id="local-size">Размер: –</p>
<p class="meta-muted" id="local-arweave" style="word-break:break-all;">Arweave tx: –</p>
<p class="meta-muted" id="local-created">Создан: –</p>
<p class="meta-muted" id="local-solana">Solana: –</p>
`;
const actions = document.createElement('div');
@@ -1631,13 +1631,13 @@ export function render({navigate, chrome}) {
}
publicationLocalEl.textContent = localStatusText;
pdaNameEl.textContent = `Блокчейн: ${usage.blockchainName || '—'}`;
pdaAddressEl.textContent = `PDA: ${usage.userPda || '—'}`;
pdaNameEl.textContent = `Блокчейн: ${usage.blockchainName || '–'}`;
pdaAddressEl.textContent = `PDA: ${usage.userPda || '–'}`;
pdaLastEl.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
pdaHashEl.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
pdaHashEl.textContent = `Hash: ${usage.lastBlockHashHex || '–'}`;
pdaUsedEl.textContent = `Размер: ${formatBytesRu(usage.usedBytes)} байт`;
pdaLimitEl.textContent = `Лимит: ${formatBytesRu(usage.paidLimitBytes)} байт`;
pdaArweaveEl.textContent = `Arweave tx: ${usage.arweaveTxId || '—'}`;
pdaArweaveEl.textContent = `Arweave tx: ${usage.arweaveTxId || '–'}`;
if (usage.arweaveTxId) {
pdaArweaveLinkEl.href = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: usage.arweaveTxId });
pdaArweaveLinkEl.style.pointerEvents = 'auto';
@@ -1648,32 +1648,32 @@ export function render({navigate, chrome}) {
pdaArweaveLinkEl.style.opacity = '.55';
}
serverNameEl.textContent = `Блокчейн: ${serverState.blockchainName || usage.blockchainName || '—'}`;
serverNameEl.textContent = `Блокчейн: ${serverState.blockchainName || usage.blockchainName || '–'}`;
serverLastEl.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverHashEl.textContent = `Hash: ${serverState.lastHash || '—'}`;
serverHashEl.textContent = `Hash: ${serverState.lastHash || '–'}`;
serverSizeEl.textContent = `Размер: ${formatBytesRu(serverState.sizeBytes)} байт`;
serverUpdatedEl.textContent = `Обновлено: ${nowRu()}`;
if (localSnapshot?.txId) {
localStateEl.textContent = `Слепок: ${String(localSnapshot.txId || '') === String(usage.arweaveTxId || '') ? 'закреплён' : 'есть, но не закреплён в Solana'}`;
localTransportEl.textContent = `Способ: ${localSnapshot.uploadTransport === 'turbo' ? 'Turbo' : (localSnapshot.uploadTransport === 'arweave' ? 'Arweave' : '—')}`;
localLastEl.textContent = `Крайний блок: ${localSnapshot.lastBlockNumber ?? '—'}`;
localHashEl.textContent = `Hash: ${localSnapshot.lastBlockHash || '—'}`;
localTransportEl.textContent = `Способ: ${localSnapshot.uploadTransport === 'turbo' ? 'Turbo' : (localSnapshot.uploadTransport === 'arweave' ? 'Arweave' : '–')}`;
localLastEl.textContent = `Крайний блок: ${localSnapshot.lastBlockNumber ?? '–'}`;
localHashEl.textContent = `Hash: ${localSnapshot.lastBlockHash || '–'}`;
localSizeEl.textContent = `Размер: ${formatBytesRu(localSnapshot.usedBytes || 0)} байт`;
localArweaveEl.textContent = `Arweave tx: ${localSnapshot.txId}`;
localCreatedEl.textContent = `Создан: ${localSnapshot.uploadedAtMs ? new Date(localSnapshot.uploadedAtMs).toLocaleString('ru-RU') : '—'}`;
localCreatedEl.textContent = `Создан: ${localSnapshot.uploadedAtMs ? new Date(localSnapshot.uploadedAtMs).toLocaleString('ru-RU') : '–'}`;
localSolanaEl.textContent = localSnapshot.solanaUpdatedAtMs
? `Solana обновлена: ${new Date(localSnapshot.solanaUpdatedAtMs).toLocaleString('ru-RU')}`
: 'Solana: слепок ещё не закреплён';
} else {
localStateEl.textContent = 'Слепок: отсутствует';
localTransportEl.textContent = 'Способ: —';
localLastEl.textContent = 'Крайний блок: —';
localHashEl.textContent = 'Hash: —';
localSizeEl.textContent = 'Размер: —';
localArweaveEl.textContent = 'Arweave tx: —';
localCreatedEl.textContent = 'Создан: —';
localSolanaEl.textContent = 'Solana: —';
localTransportEl.textContent = 'Способ: –';
localLastEl.textContent = 'Крайний блок: –';
localHashEl.textContent = 'Hash: –';
localSizeEl.textContent = 'Размер: –';
localArweaveEl.textContent = 'Arweave tx: –';
localCreatedEl.textContent = 'Создан: –';
localSolanaEl.textContent = 'Solana: –';
}
publishBtn.disabled = !localSnapshot?.txId || !localMatchesServer || String(localSnapshot.txId || '') === String(usage.arweaveTxId || '');
@@ -1854,10 +1854,10 @@ export function render({navigate, chrome}) {
balanceLabel.textContent = 'Баланс (Solana)';
const balanceValue = document.createElement('h2');
balanceValue.style.fontSize = '30px';
balanceValue.textContent = '— SOL';
balanceValue.textContent = '– SOL';
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
updatedLabel.textContent = 'Обновлено: –';
const endpointLabel = document.createElement('p');
endpointLabel.className = 'meta-muted';
endpointLabel.textContent = `RPC: ${state.entrySettings.solanaServer}`;
@@ -1868,7 +1868,7 @@ export function render({navigate, chrome}) {
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (client.key)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">–</p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
@@ -2000,10 +2000,10 @@ export function render({navigate, chrome}) {
balanceLabel.textContent = 'Баланс (Arweave)';
const balanceValue = document.createElement('h2');
balanceValue.style.fontSize = '30px';
balanceValue.textContent = '— AR';
balanceValue.textContent = '– AR';
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
updatedLabel.textContent = 'Обновлено: –';
const gatewayLabel = document.createElement('p');
gatewayLabel.className = 'meta-muted';
gatewayLabel.textContent = `Gateway: ${state.entrySettings.arweaveServer}`;
@@ -2014,7 +2014,7 @@ export function render({navigate, chrome}) {
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес Arweave (SAWD-v1)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">–</p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
@@ -2133,7 +2133,7 @@ export function render({navigate, chrome}) {
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
wasFirstTimeGeneration = true;
setStatus('Подождите — ваш Arweave-ключ вычисляется из client key. Это происходит только один раз, потом будет мгновенно.');
setStatus('Подождите – ваш Arweave-ключ вычисляется из client key. Это происходит только один раз, потом будет мгновенно.');
return;
}
setStatus(text);
+13 -66
View File
@@ -1,3 +1,4 @@
import { openMediaViewer } from '../components/media-viewer.js';
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
const ATTACH_BLOCK_RE = /^<(?:SHiNE|S):(attach|att);([^>]*)>\n?/u;
@@ -177,67 +178,6 @@ function stopVideos(root) {
});
}
function createDownloadLink(url, label = 'Скачать', { iconOnly = false } = {}) {
const link = document.createElement('a');
link.className = `message-attachment-download${iconOnly ? ' message-attachment-download--icon' : ''}`;
link.href = url;
link.target = '_blank';
link.rel = 'noopener';
link.download = '';
if (iconOnly) {
link.setAttribute('aria-label', label);
link.title = label;
link.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v12M6 10l6 6 6-6M5 20h14"/></svg>';
} else {
link.textContent = label;
}
link.addEventListener('click', (event) => {
event.stopPropagation();
});
return link;
}
function openAttachmentViewer({ item, url, kind }) {
const root = document.getElementById('modal-root') || document.body;
const overlay = document.createElement('div');
overlay.className = 'modal attachment-viewer-modal';
overlay.innerHTML = `
<div class="attachment-viewer-card" role="dialog" aria-modal="true">
<div class="attachment-viewer-head">
<div class="attachment-viewer-title">${escapeHtml(item.name)}</div>
<button class="icon-btn attachment-viewer-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="attachment-viewer-body"></div>
<div class="attachment-viewer-actions"></div>
</div>
`;
const close = () => {
stopVideos(overlay);
overlay.remove();
};
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
overlay.querySelector('.attachment-viewer-close')?.addEventListener('click', close);
const body = overlay.querySelector('.attachment-viewer-body');
if (kind === 'video') {
const video = document.createElement('video');
video.className = 'attachment-viewer-media';
video.src = url;
video.controls = true;
video.autoplay = true;
body.append(video);
} else {
const img = document.createElement('img');
img.className = 'attachment-viewer-media';
img.src = url;
img.alt = item.name;
body.append(img);
}
overlay.querySelector('.attachment-viewer-actions')?.append(createDownloadLink(url));
root.append(overlay);
}
function replaceWithUnavailable(slide, messageTimestampMs) {
if (!slide || slide.dataset.unavailable === '1') return;
slide.dataset.unavailable = '1';
@@ -275,7 +215,7 @@ function createFileCard({ item, url, messageTimestampMs, slide }) {
return card;
}
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '' }) {
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '', onOpen }) {
const frame = document.createElement('div');
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
frame.role = 'button';
@@ -283,12 +223,12 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
frame.title = kind === 'video' ? 'Открыть видео' : 'Открыть изображение';
frame.addEventListener('click', (event) => {
event.stopPropagation();
openAttachmentViewer({ item, url, kind });
onOpen?.();
});
frame.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openAttachmentViewer({ item, url, kind });
onOpen?.();
});
const bindImageLayout = (img) => {
@@ -340,7 +280,6 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
frame.append(video, play);
}
frame.append(createDownloadLink(url, 'Скачать', { iconOnly: true }));
return frame;
}
@@ -382,6 +321,14 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
counter.className = 'message-attachment-counter';
wrap.classList.toggle('has-single-attachment', items.length <= 1);
// Просмотр на весь экран листает все фото и видео записи, файлы пропускает.
const mediaItems = items
.map((item, i) => ({ i, kind: getAttachmentKind(item), url: buildArweaveDataUrl({ gateway, txId: item.ar }), name: item.name }))
.filter((entry) => entry.kind === 'image' || entry.kind === 'video');
const openViewer = (itemIndex) => {
openMediaViewer(mediaItems, Math.max(0, mediaItems.findIndex((entry) => entry.i === itemIndex)));
};
const render = () => {
stopVideos(wrap);
const item = items[index];
@@ -394,7 +341,7 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
slide.replaceChildren();
slide.append(kind === 'file'
? createFileCard({ item, url, messageTimestampMs, slide })
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl, onOpen: () => openViewer(index) }));
prev.hidden = items.length <= 1 || index <= 0;
next.hidden = items.length <= 1 || index >= items.length - 1;
counter.hidden = items.length <= 1;
+9 -7
View File
@@ -11,8 +11,7 @@ function escapeHtml(text) {
export function openAuthRequiredModal({
title = 'Нужен вход',
text = 'Эта часть доступна после входа в систему.',
startRoute = 'start-view',
text = 'Эта часть доступна после входа.',
} = {}) {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) {
@@ -25,18 +24,21 @@ export function openAuthRequiredModal({
<h3 class="modal-title">${escapeHtml(title)}</h3>
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="auth-required-close">Закрыть</button>
<button class="primary-btn" type="button" id="auth-required-start">На старт</button>
<button class="secondary-btn" type="button" id="auth-required-login">Войти</button>
<button class="primary-btn" type="button" id="auth-required-register">Зарегистрироваться</button>
</div>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#auth-required-close')?.addEventListener('click', close);
root.querySelector('#auth-required-start')?.addEventListener('click', () => {
root.querySelector('#auth-required-login')?.addEventListener('click', () => {
close();
navigate(startRoute);
navigate('login-view');
});
root.querySelector('#auth-required-register')?.addEventListener('click', () => {
close();
navigate('register-view');
});
root.querySelector('#auth-required-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'auth-required-modal') close();
+16 -1
View File
@@ -35,6 +35,21 @@ export function formatClockTime(timestampMs) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(ts));
}
const SHORT_MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
// Дата записи в ряду действий: сегодня «10:24», раньше «25 сен, 10:24», в прошлом году «25 сен 2025».
export function formatPostStamp(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '';
const dt = new Date(ts);
const now = new Date();
const clock = formatClockTime(ts);
if (dt.toDateString() === now.toDateString()) return clock;
const day = `${dt.getDate()} ${SHORT_MONTHS[dt.getMonth()]}`;
if (dt.getFullYear() !== now.getFullYear()) return `${day} ${dt.getFullYear()}`;
return `${day}, ${clock}`;
}
// Плашка дня в ленте: «Сегодня», «Вчера», «24 сентября», «24 сентября 2025».
export function formatDayLabel(timestampMs) {
const ts = toNumber(timestampMs);
@@ -80,7 +95,7 @@ export function formatListTime(timestampMs) {
export function formatRelativeTime(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '—';
if (!ts) return '–';
const now = Date.now();
const dt = new Date(ts);
+36 -2
View File
@@ -16,15 +16,38 @@ export const PALETTE_ROLES = [
{ id: 'text-secondary', label: 'Вторичный текст: время, подсказки' },
{ id: 'border-subtle', label: 'Разделители' },
{ id: 'border-control', label: 'Рамки полей и кнопок' },
{ id: 'surface-raised', label: 'Приподнятое: поля ввода, наведение' },
{ id: 'accent', label: 'Акцент: главные кнопки, ссылки' },
{ id: 'action', label: 'Вторичное действие: «Ответить», ссылки' },
{ id: 'bubble-out', label: 'Исходящее сообщение' },
{ id: 'on-accent', label: 'Текст на акцентной кнопке' },
{ id: 'reaction-active', label: 'Поставленный лайк' },
{ id: 'danger', label: 'Ошибки и удаление' },
{ id: 'success', label: 'Успех' },
{ id: 'warning', label: 'Предупреждение' },
{ id: 'info', label: 'Информация, подсказки о процессе' },
];
export const PALETTE_PRESETS = {
// «Сияние» – цветовые роли макета V3/V4: тёмная тема из спецификации,
// светлая подобрана отдельно, а не инверсией.
aurora: {
label: 'Сияние',
dark: {
background: '#121419', surface: '#1c1f27', 'surface-selected': '#302b43', 'surface-raised': '#272a34',
'text-primary': '#f1f1f5', 'text-secondary': '#a8abb8',
'border-subtle': '#303440', 'border-control': '#454858',
accent: '#b8aef5', 'on-accent': '#211c34', action: '#c6bff5', 'bubble-out': '#39324d',
'reaction-active': '#b8aef5', danger: '#f2a1ad', success: '#91d4b0', warning: '#edc487', info: '#a8c9f3',
},
light: {
background: '#f3f2f7', surface: '#ffffff', 'surface-selected': '#e8e3fa', 'surface-raised': '#ecebf2',
'text-primary': '#1d1b26', 'text-secondary': '#5f5b6e',
'border-subtle': '#e0dde8', 'border-control': '#a19db2',
accent: '#5d4fc4', 'on-accent': '#ffffff', action: '#5244b5', 'bubble-out': '#e6e0fb',
'reaction-active': '#5d4fc4', danger: '#b3304a', success: '#2d7552', warning: '#8a5a0e', info: '#2f5f9e',
},
},
club: {
label: 'Индиго',
dark: {
@@ -138,7 +161,7 @@ for (const [id, label, ...colors] of EXTRA_PRESETS) {
PALETTE_PRESETS[id] = makePreset(label, colors.slice(0, 7), colors.slice(7));
}
const DEFAULT_PRESET = 'club';
const DEFAULT_PRESET = 'aurora';
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
function normalizeMode(value) {
@@ -184,9 +207,20 @@ export function getPaletteSettings() {
};
}
// Роли, которых нет в старых пресетах, выводятся из основных цветов.
function withDerivedRoles(colors, theme) {
return {
'surface-raised': mixHex(colors.surface, colors['text-primary'], 0.07),
action: mixHex(colors.accent, colors['text-primary'], 0.18),
'bubble-out': mixHex(colors.surface, colors.accent, theme === 'light' ? 0.16 : 0.22),
info: theme === 'light' ? '#2f5f9e' : '#a8c9f3',
...colors,
};
}
export function resolvePalette(resolvedTheme, settings = getPaletteSettings()) {
const theme = resolvedTheme === 'light' ? 'light' : 'dark';
return { ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] };
return withDerivedRoles({ ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] }, theme);
}
function isDefaultPalette(settings) {
+2 -2
View File
@@ -225,7 +225,7 @@
<div class="block full">
<strong>Подписываемое сообщение</strong>
<div class="muted-text" id="messagePreview">—</div>
<div class="muted-text" id="messagePreview">–</div>
</div>
<div class="block full">
@@ -398,7 +398,7 @@
function updateMessagePreview() {
const login = normalizeLogin(targetLoginInput.value);
if (!login) {
messagePreviewNode.textContent = '—';
messagePreviewNode.textContent = '–';
return;
}
messagePreviewNode.textContent = new TextDecoder().decode(buildPromoMessageBytes(login));
+5 -5
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Регистрация сервера — SHiNE Server Admin</title>
<title>Регистрация сервера – SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.pwd-wrap { display: flex; }
@@ -79,7 +79,7 @@
<input type="password" id="password" name="server-ui-password-create" placeholder="Пароль аккаунта сервера" autocomplete="new-password" autocapitalize="off" spellcheck="false" />
<button class="btn-eye" id="btnEye" type="button">Показать</button>
</div>
<div class="hint">Нажмите «Сгенерировать» — поля ниже заполнятся из логина + пароля (Argon2id).<br/>Или введите ключи вручную.</div>
<div class="hint">Нажмите «Сгенерировать» – поля ниже заполнятся из логина + пароля (Argon2id).<br/>Или введите ключи вручную.</div>
</div>
<div class="btn-row">
@@ -95,17 +95,17 @@
<div class="sec-lbl">Ключевые пары (base58)</div>
<div class="kp-block">
<div class="kp-title">Root Key — холодное восстановление / полный контроль PDA</div>
<div class="kp-title">Root Key – холодное восстановление / полный контроль PDA</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="rootPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Blockchain Key — активный ключ блокчейна и обычный authority PDA</div>
<div class="kp-title">Blockchain Key – активный ключ блокчейна и обычный authority PDA</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="bchPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="bchPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Device Key — оплата транзакции Solana</div>
<div class="kp-title">Device Key – оплата транзакции Solana</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="devPub" placeholder="base58, ~44 символа (= Solana-адрес)" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="devPriv" placeholder="seed base58, ~44 символа" /></div>
<div class="sol-box" id="solBox">
+4 -4
View File
@@ -69,7 +69,7 @@ function renderBlock(title, subtitle, items) {
return card;
}
function renderArrayLine(values, emptyLabel = '—') {
function renderArrayLine(values, emptyLabel = '–') {
const arr = Array.isArray(values) ? values : [];
return arr.length ? arr.join(', ') : emptyLabel;
}
@@ -93,7 +93,7 @@ function renderSessionList(sessions) {
`Session #${idx + 1}`,
`type=${session.sessionType}, version=${session.sessionVersion}`,
[
{ label: 'Имя', value: session.sessionName || '—' },
{ label: 'Имя', value: session.sessionName || '–' },
{ label: 'PubKey', value: base58(session.sessionPubKey32) },
],
));
@@ -153,7 +153,7 @@ function renderParsed(parsed) {
);
blocks.appendChild(renderBlock('BlockchainRegistryBlock', 'block_type=3, version=0', [
{ label: 'forkCount', value: String(parsed.forks?.length || 0) },
{ label: 'forks', value: forkLines.length ? forkLines.join('\n') : '—' },
{ label: 'forks', value: forkLines.length ? forkLines.join('\n') : '–' },
]));
if (parsed.isServer) {
@@ -162,7 +162,7 @@ function renderParsed(parsed) {
);
blocks.appendChild(renderBlock('ServerProfileBlock', 'block_type=30, version=0', [
{ label: 'addressCount', value: String(parsed.serverAddresses?.length || 0) },
{ label: 'address', value: addressLines.length ? addressLines.join('\n') : '—' },
{ label: 'address', value: addressLines.length ? addressLines.join('\n') : '–' },
]));
}
if (Array.isArray(parsed.accessServers) && parsed.accessServers.length) {
+1 -1
View File
@@ -43,7 +43,7 @@ export function formatBigInt(value) {
export function formatTimestamp(value) {
const ts = Number(BigInt(value || 0n));
if (!Number.isFinite(ts) || ts <= 0) return '—';
if (!Number.isFinite(ts) || ts <= 0) return '–';
return new Date(ts).toLocaleString('ru-RU');
}
@@ -171,7 +171,7 @@ $('btnLoad').addEventListener('click', async () => {
$('iVer').textContent = `#${parsed.recordNumber}`;
$('iCreated').textContent = formatTimestamp(parsed.createdAtMs);
$('iUpdated').textContent = formatTimestamp(parsed.updatedAtMs);
$('iSrvAddr').textContent = parsed.serverAddress || '—';
$('iSrvAddr').textContent = parsed.serverAddress || '–';
$('iBch').textContent = parsed.blockchain.blockchainName;
$('iLimit').textContent = formatBigInt(parsed.blockchain.paidLimitBytes);
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Просмотр PDA — SHiNE Server Admin</title>
<title>Просмотр PDA – SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.field-row { display: grid; grid-template-columns: 180px 1fr; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); }
+6 -6
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Обновление PDA сервера — SHiNE Server Admin</title>
<title>Обновление PDA сервера – SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.pwd-wrap { display: flex; }
@@ -92,7 +92,7 @@
<div class="card">
<h2>Ключи для подписи и оплаты</h2>
<p class="muted">Root-ключ подписывает новую PDA-запись. Device-ключ оплачивает транзакцию.<br/>Blockchain-ключ не нужен — подпись LastBlockState из PDA переиспользуется автоматически.</p>
<p class="muted">Root-ключ подписывает новую PDA-запись. Device-ключ оплачивает транзакцию.<br/>Blockchain-ключ не нужен – подпись LastBlockState из PDA переиспользуется автоматически.</p>
<div class="field">
<label>Пароль</label>
@@ -100,7 +100,7 @@
<input type="password" id="password" name="server-ui-password-update" placeholder="Пароль аккаунта сервера" autocomplete="new-password" autocapitalize="off" spellcheck="false" />
<button class="btn-eye" id="btnEye" type="button">Показать</button>
</div>
<div class="hint">Нажмите «Сгенерировать» — поля ниже заполнятся из логина + пароля.<br/>Или введите ключи вручную.</div>
<div class="hint">Нажмите «Сгенерировать» – поля ниже заполнятся из логина + пароля.<br/>Или введите ключи вручную.</div>
</div>
<div class="btn-row">
<button class="btn-secondary" id="btnGen" type="button">Сгенерировать ключи</button>
@@ -115,17 +115,17 @@
<div class="sec-lbl">Ключевые пары (base58)</div>
<div class="kp-block">
<div class="kp-title">Root Key — холодное восстановление / полный контроль PDA</div>
<div class="kp-title">Root Key – холодное восстановление / полный контроль PDA</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="rootPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Blockchain Key — справочно, при обновлении не используется</div>
<div class="kp-title">Blockchain Key – справочно, при обновлении не используется</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="bchPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="bchPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Device Key — оплата транзакции Solana</div>
<div class="kp-title">Device Key – оплата транзакции Solana</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="devPub" placeholder="base58, ~44 символа (= Solana-адрес)" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="devPriv" placeholder="seed base58, ~44 символа" /></div>
<div class="sol-box" id="solBox">
@@ -1789,12 +1789,12 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
function updateSummary(data) {
if (!data) {
summaryFields.login.textContent = '—';
summaryFields.pda.textContent = '—';
summaryFields.recordNumber.textContent = '—';
summaryFields.blockchainName.textContent = '—';
summaryFields.arweave.textContent = '—';
summaryFields.lastBlock.textContent = '—';
summaryFields.login.textContent = '–';
summaryFields.pda.textContent = '–';
summaryFields.recordNumber.textContent = '–';
summaryFields.blockchainName.textContent = '–';
summaryFields.arweave.textContent = '–';
summaryFields.lastBlock.textContent = '–';
setLinkState(solanaAccountLink, '', 'Открыть PDA в Solana explorer');
setLinkState(arweaveLink, '', 'Открыть Arweave запись');
return;
@@ -2297,27 +2297,27 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
<div id="summary-grid" class="summary-grid">
<div class="summary-item">
<span class="summary-item__label">Логин</span>
<strong id="summary-login">—</strong>
<strong id="summary-login">–</strong>
</div>
<div class="summary-item">
<span class="summary-item__label">PDA</span>
<strong id="summary-pda">—</strong>
<strong id="summary-pda">–</strong>
</div>
<div class="summary-item">
<span class="summary-item__label">Версия записи</span>
<strong id="summary-record-number">—</strong>
<strong id="summary-record-number">–</strong>
</div>
<div class="summary-item">
<span class="summary-item__label">Блокчейн</span>
<strong id="summary-blockchain-name">—</strong>
<strong id="summary-blockchain-name">–</strong>
</div>
<div class="summary-item">
<span class="summary-item__label">Arweave TX</span>
<strong id="summary-arweave">—</strong>
<strong id="summary-arweave">–</strong>
</div>
<div class="summary-item">
<span class="summary-item__label">Последний блок</span>
<strong id="summary-last-block">—</strong>
<strong id="summary-last-block">–</strong>
</div>
</div>
</section>
@@ -2660,12 +2660,12 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
function updateSummary(data) {
if (!data) {
summaryFields.login.textContent = '—';
summaryFields.pda.textContent = '—';
summaryFields.recordNumber.textContent = '—';
summaryFields.blockchainName.textContent = '—';
summaryFields.arweave.textContent = '—';
summaryFields.lastBlock.textContent = '—';
summaryFields.login.textContent = '–';
summaryFields.pda.textContent = '–';
summaryFields.recordNumber.textContent = '–';
summaryFields.blockchainName.textContent = '–';
summaryFields.arweave.textContent = '–';
summaryFields.lastBlock.textContent = '–';
setLinkState(solanaAccountLink, '', 'Открыть PDA в Solana explorer');
setLinkState(arweaveLink, '', 'Открыть Arweave запись');
updateToolbarState();
@@ -3309,7 +3309,7 @@ var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x
clearBlockchainBrowser();
setContentStatus('ok', 'Viewer читает новый формат Arweave: ?login=...&channel=... или ?key=...&channel=...');
renderResult({
info: 'Передайте ?login=...&channel=... — ключ будет взят из Solana PDA. Или ?key=<blockchainKey>&channel=... — прямое чтение Arweave без Solana.',
info: 'Передайте ?login=...&channel=... – ключ будет взят из Solana PDA. Или ?key=<blockchainKey>&channel=... – прямое чтение Arweave без Solana.',
});
showEmptyState('Укажите логин пользователя SHiNE и откройте его блокчейн.');
updateToolbarState();
@@ -3,19 +3,19 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SHiNE — четыре направления UI</title>
<title>SHiNE – четыре направления UI</title>
<link rel="stylesheet" href="mockups.css">
<script src="mockups.js" defer></script>
</head>
<body>
<header class="studio-header">
<a class="brand" href="index.html" aria-label="SHiNE — все варианты"><span class="brand-spark">✳</span> SHiNE<span class="brand-caption">DESIGN STUDY / 01</span></a>
<a class="brand" href="index.html" aria-label="SHiNE – все варианты"><span class="brand-spark">✳</span> SHiNE<span class="brand-caption">DESIGN STUDY / 01</span></a>
<span class="draft-label">Концепции · сентябрь 2026</span>
</header>
<main>
<section class="intro">
<div><div class="eyebrow">КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ</div><h1>Ближе к разговору.</h1><p>Четыре характера. Один привычный путь от канала к ответу.<br>Выберите экран и сравните — или нажмите на элементы внутри макета.</p></div>
<div class="intro-note"><span>01—04</span><p>Меньше визуального шума.<br>Больше места людям и мыслям.</p></div>
<div><div class="eyebrow">КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ</div><h1>Ближе к разговору.</h1><p>Четыре характера. Один привычный путь от канала к ответу.<br>Выберите экран и сравните – или нажмите на элементы внутри макета.</p></div>
<div class="intro-note"><span>01–04</span><p>Меньше визуального шума.<br>Больше места людям и мыслям.</p></div>
</section>
<section class="review-bar" aria-label="Управление просмотром">
<div class="screen-switch" role="group" aria-label="Экран для всех вариантов">
@@ -43,7 +43,7 @@ const channels = [
let role = 'reader';
let showKeyboard = false;
const states = new Map();
const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?';
const mainText = 'Иногда лучший маршрут – чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?';
const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!';
const landscape = () => `<div class="landscape" role="img" aria-label="Иллюстрация: тихая река, холмы и солнце"><svg viewBox="0 0 400 180" preserveAspectRatio="xMidYMid slice"><rect width="400" height="180" fill="#cad8d0"/><circle cx="290" cy="46" r="24" fill="#f5e7b5"/><path d="M0 87Q65 35 136 79T270 70T400 61V180H0" fill="#8faaa0"/><path d="M0 125Q73 67 155 102T310 86T400 101V180H0" fill="#527b70"/><path d="M0 153Q98 104 184 130T400 110V180H0" fill="#31594f"/><path d="M216 100Q105 130 236 145T201 180H311Q347 151 239 137T242 101Z" fill="#c2d2ba"/><path d="M32 138V59m0 4-15 30h30ZM361 129V47m0 4-18 34h36Z" fill="#30534a" stroke="#30534a" stroke-width="4"/></svg><span class="media-caption">У реки / 18:42</span></div>`;
function header(s) {
@@ -198,8 +198,8 @@ function handle(s, event) {
else if (action==='details') menu(s,'Данные записи','<p>Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.</p>',el);
else if (action==='about') menu(s,'О канале','<p>Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.</p><p>Автор: @'+escapeText(s.channel.owner)+'</p>',el);
else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); }
else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования');
else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап');
else if (action==='new-channel') toast(s,'Создание канала – следующий этап проектирования');
else if (action==='edit-demo') toast(s,'Редактор существующего сообщения – следующий этап');
else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.');
}
function attachEvents(s) {
+2 -2
View File
@@ -49,7 +49,7 @@
}
.app-shell.has-minimized-call .screen-content {
padding-top: 12px;
padding-top: var(--page-top-gap);
}
.topbar-slot,
@@ -131,7 +131,7 @@
/* Обычный scroll-container физически продолжается под прозрачным TopBar. */
.app-shell--content-under-topbar:not(.app-shell--scroll-nested):not(.app-shell--scroll-locked) .screen-content:not(.no-app-chrome) {
top: var(--call-minimized-bar-height, 0px);
padding-top: calc(var(--topbar-height, 64px) + 14px);
padding-top: calc(var(--topbar-height, 64px) + var(--page-top-gap));
}
/* Вложенный scroll-container (например, переписка) сам резервирует место под chrome. */
+10 -9
View File
@@ -1,7 +1,8 @@
/* Семантические роли кнопок. Геометрией сложных компонентов владеют их стили. */
.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn { color: var(--text-primary); background: transparent; border: 0; box-shadow: none; font: inherit; cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }
.primary-btn { color: var(--on-accent); background: var(--accent); }
.secondary-btn { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-control); }
/* Второстепенное действие: прозрачная, тонкая рамка, текст цвета «вторичного действия». */
.secondary-btn { color: var(--action); background: transparent; border: 1px solid var(--border-control); }
.destructive-btn { color: var(--danger); }
:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):hover { filter: brightness(.96); }
:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):active { filter: brightness(.9); }
@@ -16,9 +17,9 @@
text-shadow: none;
}
.filled-action-buttons :is(.secondary-btn, .ghost-btn, .text-btn, .login-panel-inline-back, .profiles-close) {
color: var(--text-primary);
background: var(--surface);
.filled-action-buttons :is(.secondary-btn, .ghost-btn, .text-btn, .profiles-close) {
color: var(--action);
background: transparent;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
box-shadow: none;
@@ -27,23 +28,23 @@
.filled-action-buttons .destructive-btn {
color: var(--danger);
background: var(--surface);
background: transparent;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
}
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):active {
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .registration-faq-link, .profiles-select, .profiles-close):active {
transform: scale(0.98);
}
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):disabled {
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .registration-faq-link, .profiles-select, .profiles-close):disabled {
color: var(--text-secondary);
background: var(--surface-selected);
border-color: transparent;
}
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close)::before,
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close)::after {
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .registration-faq-link, .profiles-select, .profiles-close)::before,
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .registration-faq-link, .profiles-select, .profiles-close)::after {
content: none;
}
+18 -107
View File
@@ -137,44 +137,6 @@
}
.message-attachment-download {
display: inline-flex;
align-items: center;
min-height: 44px;
background: var(--accent);
border-radius: 999px;
color: var(--on-accent);
font-size: 0.82rem;
font-weight: 700;
padding: 0.45rem 0.75rem;
position: relative;
text-decoration: none;
width: max-content;
z-index: 3;
}
.message-attachment-media-frame > .message-attachment-download {
bottom: 0.5rem;
position: absolute;
right: 0.5rem;
}
.message-attachment-download--icon {
width: 36px;
min-height: 36px;
height: 36px;
padding: 0;
justify-content: center;
border-radius: 50%;
color: #fff;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.message-attachment-download--icon svg { width: 18px; height: 18px; }
.message-attachment-download--icon:hover { background: rgba(0, 0, 0, 0.6); }
.message-attachment-media-frame:not(.is-loaded) { background: var(--surface-selected); animation: attachment-loading 1.2s ease-in-out infinite alternate; }
.message-attachment-media-frame.is-loaded { background: transparent; }
@keyframes attachment-loading { from { opacity: 1; } to { opacity: .55; } }
@@ -299,84 +261,32 @@
.message-attachment-file-card {
grid-template-columns: auto minmax(0, 1fr);
}
.message-attachment-file-card .message-attachment-download {
grid-column: 1 / -1;
}
}
.ar-attachment-manager-root .modal { z-index: 120; }
.attachment-viewer-modal {
z-index: 80;
}
.attachment-viewer-card {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: 1.2rem;
box-shadow: none;
color: var(--text-primary);
display: grid;
gap: 0.8rem;
max-height: 92vh;
max-width: min(94vw, 68rem);
padding: 0.9rem;
width: 100%;
}
.attachment-viewer-head,
.attachment-viewer-actions {
align-items: center;
display: flex;
gap: 0.8rem;
justify-content: space-between;
}
.attachment-viewer-title {
font-weight: 800;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-viewer-body {
align-items: center;
display: grid;
justify-items: center;
min-height: 12rem;
}
.attachment-viewer-media {
background: var(--background);
border-radius: 0.8rem;
max-height: 76vh;
max-width: 100%;
object-fit: contain;
}
.ar-attachment-manager-card {
max-width: min(92vw, 34rem);
gap: 16px;
gap: var(--space-3);
}
.ar-attachment-manager-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ar-attachment-manager-close { width: 44px; min-width: 44px; padding: 0; font-size: 18px; color: var(--text-secondary); }
.ar-attachment-note { margin: 0; font-size: .8125rem; line-height: 1.25rem; color: var(--text-secondary); }
.ar-attachment-alt-sources { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.ar-attachment-link { min-height: 44px; padding: 0; justify-self: start; color: var(--accent); background: transparent; font-weight: 500; }
/* Пустые служебные блоки (сумма, превью, ошибка) не должны давать промежутков. */
.ar-attachment-manager-card :is(.ar-attachment-meta, .inline-error):empty,
.ar-attachment-manager-card [hidden] { display: none; }
.ar-attachment-manager-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ar-attachment-manager-close { display: grid; place-items: center; width: 44px; min-width: 44px; padding: 0; color: var(--text-secondary); }
.ar-attachment-manager-close svg { width: 20px; height: 20px; }
.ar-attachment-note { margin: 0; font-size: .8125rem; line-height: 1.25rem; color: var(--text-secondary); }
.ar-attachment-alt-sources { display: flex; flex-wrap: wrap; gap: 0 var(--space-4); }
.ar-attachment-link { min-height: 36px; padding: 0; justify-self: start; color: var(--action); background: transparent; font-size: var(--text-sm); font-weight: var(--weight-medium); }
/* Та же высота, что у поля ввода (44), кнопка внутри – как в переключателе (34). */
.ar-attachment-file-picker {
min-height: 56px;
padding: 8px;
min-height: var(--control-height);
padding: 4px;
display: flex;
align-items: center;
gap: 12px;
gap: var(--space-3);
border: 1px dashed var(--border-control);
border-radius: 12px;
background: var(--surface);
@@ -387,11 +297,12 @@
.ar-attachment-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.ar-attachment-file-btn {
flex-shrink: 0;
min-height: 40px;
min-height: 34px;
padding: 0 14px;
display: inline-flex;
align-items: center;
border-radius: 10px;
border-radius: 8px;
font-size: var(--text-md);
color: var(--on-accent);
background: var(--accent);
font-weight: 600;
@@ -23,18 +23,20 @@
.channel-editor__header {
min-height: calc(56px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 6px 0;
padding: env(safe-area-inset-top) 14px 0 6px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 44px;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
border-bottom: 1px solid var(--border-subtle);
background: var(--surface);
}
.channel-editor__header h2 {
font-size: 16px;
line-height: 24px;
font-weight: 600;
text-align: center;
text-align: left;
}
.channel-editor__close {
@@ -225,3 +227,7 @@
@media (prefers-reduced-motion: reduce) {
.channel-editor-overlay, .channel-editor { animation: none; transition: none; }
}
.channel-editor__author-text { display: grid; gap: 1px; min-width: 0; }
.channel-editor__author-text small { color: var(--text-secondary); font-size: .75rem; }
.channel-editor__draft { justify-self: end; color: var(--text-secondary); font-size: .75rem; }
.channel-editor__close svg { width: 24px; height: 24px; }
+14
View File
@@ -0,0 +1,14 @@
/* Строка ввода внизу экрана (обсуждение; тот же рисунок у чата): капсула с полем и эмодзи + круглая отправка */
.compose-row { display: flex; gap: 6px; align-items: flex-end; }
.compose-field { flex: 1; min-width: 0; display: flex; align-items: flex-end; padding: 0 6px 0 12px; border: 1px solid var(--border-control); border-radius: 22px; background: var(--surface-raised); }
.compose-field:focus-within { border-color: var(--accent); }
.compose-field__input { flex: 1; min-width: 0; min-height: 44px; max-height: 132px; padding: 10px 0; border: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 1rem; line-height: 1.375rem; resize: none; field-sizing: content; outline: none; }
.compose-field__input::placeholder { color: var(--text-secondary); }
.compose-field__emoji { width: 32px; min-width: 32px; height: 44px; padding: 0; display: grid; place-items: center; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; }
.compose-field__emoji svg { width: 22px; height: 22px; }
.compose-send { width: 44px; height: 44px; min-width: 44px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 50%; background: var(--accent); color: var(--on-accent); cursor: pointer; }
.compose-send svg { width: 22px; height: 22px; stroke-width: 2.2; }
.compose-send:hover:not(:disabled) { background: var(--accent-hover); }
.compose-send:active:not(:disabled) { background: var(--accent-pressed); }
.compose-send:disabled { background: var(--disabled-bg); color: var(--disabled-text); cursor: default; }
.compose-send:focus-visible, .compose-field__emoji:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
+8 -5
View File
@@ -1,12 +1,15 @@
:root { --z-overlay: 100; --z-dropdown: 110; --z-modal: 120; }
.dropdown-portal { pointer-events: none; }
.dropdown-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay); pointer-events: auto; background: var(--scrim); }
.dropdown-menu { position: fixed; z-index: var(--z-dropdown); display: grid; gap: 2px; padding: 6px; min-width: 196px; max-width: calc(100vw - 20px); max-height: calc(100dvh - 20px); overflow-y: auto; border: 1px solid var(--border-subtle); border-radius: 20px; background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow); pointer-events: auto; }
.dropdown-menu__item { width: 100%; min-height: 44px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary); font: inherit; font-size: .875rem; line-height: 1.4; text-align: left; cursor: pointer; }
.dropdown-menu__item:hover, .dropdown-menu__item.is-selected { background: var(--surface-selected); }
.dropdown-menu { position: fixed; z-index: var(--z-dropdown); display: grid; gap: 2px; padding: 6px; min-width: 196px; max-width: calc(100vw - 20px); max-height: calc(100dvh - 20px); overflow-y: auto; border: 1px solid var(--border-control); border-radius: 14px; background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow); pointer-events: auto; }
.dropdown-menu__item { width: 100%; min-height: 44px; padding: 8px 10px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); font: inherit; font-size: .875rem; line-height: 1.4; text-align: left; cursor: pointer; }
.dropdown-menu__item:hover { background: var(--surface-raised); }
.dropdown-menu__item.is-selected { background: var(--surface-selected); }
.dropdown-menu__item:active { background: var(--surface-selected); }
.dropdown-menu__item:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.dropdown-menu__item.is-danger { color: var(--danger); }
.dropdown-menu__item:disabled { opacity: .5; cursor: not-allowed; }
.dropdown-menu__icon, .dropdown-menu__icon svg, .dropdown-menu__item > img { width: 22px; height: 22px; flex: 0 0 22px; object-fit: contain; color: currentColor; }
.dropdown-menu__divider { height: 1px; margin: 4px 8px; background: var(--border-subtle); }
.dropdown-menu__icon, .dropdown-menu__icon svg, .dropdown-menu__item > img { width: 21px; height: 21px; flex: 0 0 21px; object-fit: contain; }
.dropdown-menu__icon { color: var(--text-secondary); }
.dropdown-menu__item.is-danger .dropdown-menu__icon { color: var(--danger); }
.dropdown-menu__divider { height: 1px; margin: 5px 8px; background: var(--border-subtle); }
+106
View File
@@ -0,0 +1,106 @@
/* Полноэкранный просмотр фото и видео (js/components/media-viewer.js). Чёрный фон в обеих темах – как в галереях. */
.media-viewer {
--viewer-dim: 1;
position: fixed;
inset: 0;
z-index: 130;
display: grid;
/* Слегка прозрачный фон; размытие не даёт ленте за ним отвлекать. */
background: rgb(0 0 0 / calc(0.88 * var(--viewer-dim)));
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
color: #fff;
touch-action: none;
animation: media-viewer-in 160ms ease-out;
}
/* Фото и под ним ряд «1/3 ↓» – одной группой по центру экрана. */
.media-viewer__stage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
min-height: 0;
padding: calc(56px + env(safe-area-inset-top)) 0 calc(var(--space-4) + env(safe-area-inset-bottom));
transition: transform 160ms ease-out;
}
.media-viewer__frame { display: grid; place-items: center; min-height: 0; max-width: 100%; }
.media-viewer__media {
display: block;
max-width: 100vw;
max-height: calc(100dvh - 56px - 72px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
object-fit: contain;
user-select: none;
-webkit-user-drag: none;
}
.media-viewer__controls { display: flex; align-items: center; justify-content: center; gap: var(--space-4); min-height: 44px; }
.media-viewer__counter { color: rgb(255 255 255 / 85%); font-size: var(--text-md); font-variant-numeric: tabular-nums; }
.media-viewer__counter[hidden] { display: none; }
.media-viewer__bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 1;
display: flex;
align-items: center;
min-height: calc(56px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) var(--space-1) 0;
transition: opacity 160ms ease;
}
.media-viewer__btn,
.media-viewer__nav {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: #fff;
cursor: pointer;
}
.media-viewer__btn svg { width: 22px; height: 22px; }
.media-viewer__btn:hover { background: rgb(255 255 255 / 12%); }
.media-viewer__btn--round { background: rgb(255 255 255 / 16%); }
.media-viewer__btn--round:hover { background: rgb(255 255 255 / 26%); }
.media-viewer__btn:focus-visible,
.media-viewer__nav:focus-visible { outline: 2px solid #fff; outline-offset: -2px; }
/* Стрелки листания – для компьютера; на телефоне листают смахиванием. */
.media-viewer__nav {
position: absolute;
top: 50%;
z-index: 1;
background: rgb(255 255 255 / 14%);
transform: translateY(-50%);
transition: opacity 160ms ease;
}
.media-viewer__nav svg { width: 22px; height: 22px; }
.media-viewer__nav--prev { left: var(--space-3); }
.media-viewer__nav--next { right: var(--space-3); }
.media-viewer__nav[hidden] { display: none; }
@media (hover: none) {
.media-viewer__nav { display: none; }
}
/* Нажатие на фото прячет панель – смотреть без помех. */
.media-viewer.is-bare :is(.media-viewer__bar, .media-viewer__nav, .media-viewer__controls) { opacity: 0; pointer-events: none; }
.media-viewer__controls { transition: opacity 160ms ease; }
@keyframes media-viewer-in { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.media-viewer { animation: none; }
.media-viewer__stage { transition: none; }
}
+22
View File
@@ -39,3 +39,25 @@
.confirm-dialog__text { margin: 0; color: var(--text-secondary); font-size: .9375rem; line-height: 1.375rem; overflow-wrap: anywhere; }
.confirm-dialog__danger { color: #fff; background: var(--danger); }
:root[data-theme='dark'] .confirm-dialog__danger { color: var(--background); }
/* Шторка с заголовком (js/components/sheet.js) */
.sheet__card { display: grid; gap: 0; padding: 12px 16px 18px; border-radius: 22px; }
.sheet__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.sheet__title { margin: 0; font-size: 1rem; }
.sheet__close { width: 36px; height: 36px; min-width: 36px; padding: 0; display: grid; place-items: center; border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary); }
.sheet__close svg { width: 22px; height: 22px; }
.sheet__close:hover { background: var(--surface-raised); color: var(--text-primary); }
.sheet__body { display: grid; gap: 12px; font-size: .875rem; line-height: 1.3125rem; }
.sheet__body > p { margin: 0; }
.sheet__footer { display: flex; gap: 8px; margin-top: 18px; }
.sheet__footer > button { flex: 1; min-height: 44px; }
/* «Поделиться» (js/components/share-sheet.js) */
.share-sheet__content { display: grid; gap: 12px; }
.share-sheet__preview { padding: 12px; border-radius: 12px; background: var(--background); font-size: .875rem; line-height: 1.3125rem; }
.share-sheet__preview strong { display: block; margin-bottom: 5px; color: var(--text-secondary); font-size: .75rem; font-weight: 500; }
.share-sheet__preview p { margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.share-sheet__label { display: grid; gap: 5px; color: var(--text-secondary); font-size: .75rem; }
.share-sheet__link { min-height: 44px; padding: 9px 10px; border: 1px solid var(--border-control); border-radius: 10px; background: var(--surface-raised); color: var(--text-primary); font: inherit; font-size: .875rem; }
.share-sheet__copy, .share-sheet__system { width: 100%; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
.share-sheet__copy svg, .share-sheet__system svg { width: 20px; height: 20px; }
+7 -10
View File
@@ -1,15 +1,15 @@
/* Shared overflow-dots trigger. */
/* ===== UI cleanup: main tabs, shared overflow menu and DM list (2026-08-22) ===== */
/* Единое вертикальное троеточие для всех overflow/menu-кнопок. */
/* Единое горизонтальное троеточие для всех overflow/menu-кнопок (как иконка more в ui-icon.js). */
.app-overflow-dots {
width: 8px;
width: 24px;
height: 24px;
display: inline-flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 3.5px;
gap: 4.8px;
color: inherit;
pointer-events: none;
}
@@ -17,14 +17,11 @@
.app-overflow-dots i {
display: block;
width: 4px;
height: 4px;
flex: 0 0 4px;
width: 3.2px;
height: 3.2px;
flex: 0 0 3.2px;
border-radius: 50%;
background: currentColor;
box-shadow:
0 0 5px color-mix(in srgb, var(--accent) 72%, transparent),
0 0 10px color-mix(in srgb, var(--accent) 34%, transparent);
}
+48
View File
@@ -604,3 +604,51 @@ body { overflow-x: hidden; }
.empty-note { padding: var(--space-5) var(--space-4); text-align: center; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-sm, 1.45); }
.action-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); }
/* Переключатель: <input type="checkbox" class="switch">. */
.switch {
appearance: none;
-webkit-appearance: none;
flex: 0 0 auto;
width: 48px;
height: 28px;
margin: 0;
padding: 2px;
border: 1px solid var(--border-control);
border-radius: var(--radius-pill);
background: var(--surface-selected);
cursor: pointer;
transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease;
}
.switch::after {
content: '';
display: block;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--text-secondary);
box-shadow: var(--shadow-sm);
transition: transform var(--duration-fast) ease, background-color var(--duration-fast) ease;
}
.switch:checked {
border-color: var(--accent);
background: var(--accent);
}
.switch:checked::after {
transform: translateX(20px);
background: var(--on-accent);
}
.switch:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 82%, transparent);
outline-offset: 3px;
}
.switch:disabled {
cursor: default;
opacity: .55;
}
+13 -4
View File
@@ -1,7 +1,9 @@
/* Shared tab geometry and tab primitives. */
/* Переключатель той же высоты, что поле ввода и поиск (44 px), кнопки внутри – 34 px. */
.tabs {
min-height: 44px;
box-sizing: border-box;
min-height: var(--control-height);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
@@ -12,13 +14,20 @@
}
.tabs--three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.tabs--auto { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }
/* Много коротких пунктов (4–5) в одну полосу: мельче шрифт и поля. */
.tabs--dense { display: flex; gap: 2px; }
.tabs--dense .tab-btn { flex: 1 1 auto; min-width: 0; padding: 0 6px; font-size: var(--text-sm); white-space: nowrap; }
@media (max-width: 360px) {
.tabs--dense .tab-btn { padding: 0 4px; font-size: var(--text-xs); }
}
.tab-btn {
min-height: 44px;
min-height: 34px;
border: 0;
background: transparent;
border-radius: 9px;
padding: 8px;
border-radius: 8px;
padding: 0 8px;
font-size: var(--text-md);
color: var(--text-muted);
cursor: pointer;
}
+12 -4
View File
@@ -1,5 +1,5 @@
:root { --app-topbar-base-height: 56px; --app-topbar-title-size: 1.0625rem; --app-topbar-bg: var(--background); --app-topbar-fg: var(--text-primary); --app-topbar-gold: var(--text-primary); --app-topbar-blue-glow: transparent; --app-topbar-blue-glow-soft: transparent; }
.topbar { width: 100%; min-height: calc(56px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) 0 0; display: grid; grid-template-columns: minmax(44px, 1fr) minmax(0, auto) minmax(44px, 1fr); align-items: center; gap: 4px; background: var(--background); }
.topbar { width: 100%; min-height: calc(56px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) 0 0; display: grid; grid-template-columns: minmax(44px, 1fr) minmax(0, auto) minmax(44px, 1fr); align-items: center; gap: 4px; background: var(--surface); border-bottom: 1px solid var(--border-subtle); }
.topbar__left, .topbar__center, .topbar__right { display: flex; min-width: 0; align-items: center; }
.topbar__center { justify-content: center; }
.topbar__right { justify-content: flex-end; }
@@ -15,6 +15,14 @@
.header-icon-svg { display: block; width: 22px; height: 22px; }
.topbar__back svg { width: 24px; height: 24px; stroke-width: 2.2; }
.topbar__action svg { width: 22px; height: 22px; }
.topbar--root { grid-template-columns: auto minmax(0, 1fr) auto; }
.topbar--root .topbar__center { justify-content: flex-start; }
.topbar--root .topbar__title { font-size: 1.5rem; line-height: 1.875rem; }
/* Корневые разделы (Чаты, Каналы, Связи, Уведомления, Профиль) – заголовок по центру, как на всех экранах, но крупнее. */
.topbar--root .topbar__title { font-size: 1.5rem; line-height: 1.875rem; font-weight: 700; }
/* Шапка-визитка: аватар и две строки слева (канал, собеседник) */
.topbar--identity { grid-template-columns: auto minmax(0, 1fr) auto; }
.topbar--identity .topbar__center { justify-content: flex-start; }
.topbar-identity { display: flex; align-items: center; gap: 10px; min-width: 0; max-width: 100%; min-height: 44px; padding: 0 4px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary); font: inherit; text-align: left; cursor: pointer; }
.topbar-identity:disabled { cursor: default; }
.topbar-identity__text { display: grid; min-width: 0; }
.topbar-identity__title { font-size: 1rem; line-height: 1.3; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-identity__sub { margin-top: 2px; color: var(--text-secondary); font-size: .75rem; line-height: 1.125rem; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+43 -6
View File
@@ -1,7 +1,44 @@
/* Ветка сохраняет ширину текста на любой глубине. */
/* Обсуждение (макет V4): контекст записи, «Комментарии · N», плоский список, строка ввода. */
.channels-screen--thread { gap: 0; }
.thread-block { display: grid; gap: 0; min-width: 0; }
.thread-node-level { margin-left: 0; }
.thread-block--replies > .section-title { padding: 16px 0 8px; margin: 0; color: var(--text-secondary); font-size: .875rem; font-weight: 600; }
.thread-node-heading { color: var(--text-secondary); font-size: .8125rem; }
.thread-node-card .channel-message-avatar.avatar { width: 32px; height: 32px; min-width: 32px; min-height: 32px; }
.thread-history-divider { border-top: 1px solid var(--border-subtle); margin: 8px 0; }
.thread-context { padding: 10px 0 8px; border-bottom: 1px solid var(--border-subtle); font-size: .875rem; line-height: 1.25rem; }
.thread-block--ancestors .thread-context { opacity: .8; }
.thread-context__text { margin: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; }
.thread-context.is-expanded .thread-context__text { display: block; }
.thread-context__meta { display: flex; align-items: center; gap: 12px; min-height: 32px; color: var(--text-secondary); font-size: .75rem; }
.thread-context__expand, .thread-context__open { min-height: 32px; padding: 0 4px; border: 0; background: transparent; color: var(--action); font: inherit; font-size: .75rem; cursor: pointer; }
.thread-context__open { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; }
.thread-context__open svg { width: 14px; height: 14px; }
.thread-section-title { margin: 12px 0 4px; font-size: .8125rem; font-weight: 600; color: var(--text-primary); }
.channels-screen .thread-comment { display: block; margin: 0 calc(-1 * var(--page-gutter)); padding: 7px var(--page-gutter) 5px; border: 0; border-radius: 0; background: transparent; scroll-margin: 16px; }
.channels-screen .thread-comment + .thread-comment { border-top: 1px solid var(--divider-soft); }
.thread-comment.is-highlight { background: var(--surface-selected); box-shadow: inset 3px 0 var(--accent); }
.thread-comment__person { display: flex; align-items: center; gap: 7px; min-height: 26px; margin-bottom: 4px; font-size: .875rem; font-weight: 600; }
.thread-comment__who { display: inline-flex; align-items: center; gap: 7px; min-width: 0; padding: 0; border: 0; background: transparent; color: var(--text-primary); font: inherit; cursor: pointer; }
.thread-comment__who .avatar.thread-comment__avatar { width: 24px; height: 24px; min-width: 24px; min-height: 24px; font-size: 11px; }
.thread-comment__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.thread-comment__badge { padding: 1px 5px; border-radius: 5px; background: var(--surface-selected); color: var(--action); font-size: .625rem; font-weight: 400; white-space: nowrap; }
.thread-comment__person time { margin-left: auto; flex-shrink: 0; color: var(--text-secondary); font-size: .75rem; font-weight: 400; }
.thread-comment__text { margin: 0; font-size: .9375rem; line-height: 1.4375rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.thread-comment__actions { display: flex; align-items: center; gap: 12px; margin-top: 2px; color: var(--text-secondary); }
.thread-comment__actions > button { min-height: 32px; min-width: 32px; padding: 0 4px; display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 8px; background: transparent; color: inherit; font: inherit; font-size: .75rem; cursor: pointer; }
.thread-comment__actions .thread-comment__reply { padding-left: 0; color: var(--action); }
.thread-comment__actions svg { width: 21px; height: 21px; }
.thread-comment__actions .thread-comment__more { margin-left: auto; }
.thread-comment__actions .thread-comment__more svg { width: 24px; height: 24px; }
.thread-quote { display: block; width: 100%; margin: 0 0 7px; padding: 5px 9px; border: 0; border-left: 2px solid var(--quote-line); border-radius: 0 6px 6px 0; background: var(--quote-bg); color: var(--text-secondary); font: inherit; font-size: .75rem; line-height: 1.125rem; text-align: left; cursor: pointer; }
.thread-quote strong { display: block; color: var(--action); font-weight: 500; }
.thread-quote span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
button.thread-quote:hover { background: var(--surface); }
.thread-return { position: fixed; left: 50%; bottom: calc(96px + env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 5; padding: 9px 12px; border: 1px solid var(--border-subtle); border-radius: 20px; background: var(--surface-raised); color: var(--text-primary); font: inherit; font-size: .75rem; white-space: nowrap; cursor: pointer; }
.thread-empty { padding: 36px 20px; text-align: center; }
.thread-composer { display: grid; gap: 8px; }
.thread-replying { display: flex; gap: 8px; align-items: flex-start; }
.thread-replying[hidden] { display: none; }
.thread-replying .thread-quote { margin: 0; min-width: 0; cursor: default; }
.thread-replying__cancel { width: 32px; height: 32px; flex-shrink: 0; padding: 0; display: grid; place-items: center; border: 0; background: transparent; color: var(--text-secondary); cursor: pointer; }
.thread-replying__cancel svg { width: 20px; height: 20px; }
.thread-composer__emoji { max-height: 260px; overflow: auto; }
+5 -3
View File
@@ -6,10 +6,11 @@
.channels-screen .channel-message-card.is-focus-flash { background: var(--surface-selected); }
.channel-system-event-card { padding: 8px; margin: 8px auto; width: fit-content; max-width: 100%; color: var(--text-secondary); font-size: .75rem; text-align: center; }
.channel-meta-details-grid { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 8px; overflow-wrap: anywhere; }
.channel-profile-avatar { width: 96px; height: 96px; border-radius: 50%; overflow: hidden; }
.channel-profile-avatar { width: var(--channel-avatar-size, 96px); height: var(--channel-avatar-size, 96px); min-width: var(--channel-avatar-size, 96px); border-radius: calc(var(--channel-avatar-size, 96px) * .28); overflow: hidden; color: var(--accent); font-size: calc(var(--channel-avatar-size, 96px) * .5); }
.channel-profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
.channel-profile-modal-head { display: flex; gap: 12px; align-items: center; }
.channel-main-action { min-height: 44px; border-radius: 12px; padding: 10px 16px; color: var(--on-accent); background: var(--accent); }
.channel-main-action { width: 100%; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 12px; padding: 0 16px; color: var(--on-accent); background: var(--accent); font-weight: 600; }
.channel-main-action svg { width: 20px; height: 20px; }
.channel-back-btn { min-height: 44px; color: var(--accent); }
.entrypoint-history-list { display: grid; gap: 8px; max-height: 58vh; overflow: auto; }
.entrypoint-history-item { display: grid; gap: 8px; text-align: left; padding: 12px; border-radius: 12px; background: var(--surface); color: var(--text-primary); }
@@ -24,7 +25,7 @@
.channel-about-hero { display: grid; justify-items: center; gap: 4px; padding: 8px 16px 0; text-align: center; }
.channel-about-avatar-slot { margin-bottom: 8px; }
.channel-about-avatar.avatar { width: 88px; height: 88px; min-width: 88px; min-height: 88px; font-size: 32px; }
.channel-about-avatar.avatar { width: 80px; height: 80px; min-width: 80px; min-height: 80px; border-radius: 24px; font-size: 36px; color: var(--accent); }
.channel-about-title { margin: 0; color: var(--text-primary); font-size: 1.375rem; line-height: 1.75rem; font-weight: 600; overflow-wrap: anywhere; }
.channel-about-subline { color: var(--text-secondary); font-size: .875rem; line-height: 1.25rem; overflow-wrap: anywhere; }
@@ -416,3 +417,4 @@
grid-column: 1 / -1;
}
}
.channel-about-tiles:empty { display: none; }
+30 -22
View File
@@ -42,7 +42,8 @@
.deleted-message-pill { min-height: 44px; text-align: left; color: var(--text-secondary); padding: 8px 0; }
.channel-header-title, .channel-header-owner { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.channel-header-owner { color: var(--text-secondary); font-size: .75rem; line-height: 1rem; font-weight: 400; }
.channel-composer { display: flex; gap: 8px; padding: 8px 0; background: var(--background); }
/* Нижняя панель экранов канала: поверхность с линией сверху, как шапка */
.channel-composer { display: flex; gap: 8px; margin: 0 -12px; padding: 9px 12px calc(11px + env(safe-area-inset-bottom)); background: var(--surface); border-top: 1px solid var(--border-subtle); }
.channel-compose-bar {
width: 100%;
min-height: 44px;
@@ -77,29 +78,36 @@
.thread-reply-context { color: var(--text-secondary); font-size: .75rem; line-height: 1.125rem; border-left: 2px solid var(--border-control); padding-left: 10px; overflow-wrap: anywhere; }
@media (prefers-reduced-motion: reduce) { .channels-screen *, .channel-composer * { animation: none; scroll-behavior: auto; transition: none; } }
.channels-screen .channel-message-card.channel-message-card--owner {
gap: 6px;
margin: 0 0 8px;
padding: 10px 12px 2px 14px;
border: 1px solid var(--border-subtle);
border-radius: 16px;
background: var(--surface);
justify-self: start;
width: fit-content;
min-width: min(100%, 17rem);
max-width: 100%;
}
.channels-screen .channel-message-card--owner:has(.message-attachment-carousel) { width: 100%; }
/* Запись автора в его канале (макет V4): плоская, на всю ширину, между записями — линия с отступами */
.channels-screen .channel-message-card.channel-message-card--owner { gap: 0; margin: 0; padding: 20px 0 8px; border: 0; position: relative; }
.channels-screen .channel-message-card--owner + .channel-message-card--owner::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px; background: var(--border-subtle); }
.channels-screen .channel-message-card--owner .channel-message-body { font-size: 1rem; line-height: 1.52; }
.channel-message-card--owner .channel-message-head-row[hidden] { display: none; }
.channel-message-card--owner .channel-message-body { line-height: 1.375rem; }
.channel-message-card--owner .channel-message-actions { gap: 0; margin-left: -8px; flex-wrap: nowrap; }
.channel-message-card--owner .channel-action-item { min-width: 40px; min-height: 40px; padding: 6px 8px; gap: 4px; }
.channel-message-card--owner .channel-action-icon svg { width: 20px; height: 20px; }
.channel-message-meta { display: flex; align-items: center; gap: 0; margin-left: auto; min-width: 0; }
.channel-message-card--owner .message-attachment-carousel { width: calc(100% + 2 * var(--page-gutter)); max-width: none; margin: 0 calc(-1 * var(--page-gutter)) 14px; }
.channel-message-card--owner .message-attachment-carousel-viewport { border: 0; border-radius: 0; }
.topbar-identity .avatar.channel-profile-avatar { --channel-avatar-size: 40px; width: 40px; height: 40px; min-width: 40px; min-height: 40px; font-size: 22px; border-radius: 11px; }
.channel-message-card--owner .channel-message-actions { gap: 0; margin: 6px -8px 0; min-height: 50px; flex-wrap: nowrap; }
/* Равный шаг иконок: каждое действие – ячейка 56 px, иконка у левого края, счётчик внутри ячейки (влезает и «999»). */
.channel-message-card--owner .channel-action-item { width: 56px; min-width: 56px; min-height: 44px; padding: 0 0 0 8px; gap: 5px; justify-content: flex-start; border-radius: 10px; font-variant-numeric: tabular-nums; }
.channel-message-card--owner .channel-action-discussion { width: auto; padding-right: 8px; }
.channel-message-card--owner .channel-action-icon svg { width: 23px; height: 23px; }
.channel-message-card--owner .channel-action-share .channel-action-icon svg { width: 25px; height: 25px; }
.channel-action-item.is-liked svg { fill: currentColor; }
.channel-action-faces { display: inline-flex; align-items: center; margin-left: 2px; padding-right: 3px; }
.channel-action-faces .avatar.channel-action-face { width: 22px; height: 22px; min-width: 22px; min-height: 22px; font-size: 10px; border: 2px solid var(--background); }
.channel-action-faces .channel-action-face + .channel-action-face { margin-left: -6px; }
.channel-message-meta { display: flex; align-items: center; gap: var(--space-1); margin-left: auto; min-width: 0; padding-left: 4px; }
.channel-message-meta .channel-message-time { white-space: nowrap; }
.channel-message-meta .channel-message-time::before { content: none; }
.channel-message-meta .channel-message-more { width: 40px; height: 40px; min-height: 40px; margin: 0 -4px 0 0; padding: 0; font-size: 20px; }
.channel-message-card--owner .attachment-carousel,
.channel-message-card--owner [class*='attachment'] { border-radius: 12px; }
.channel-message-meta .channel-message-more { display: inline-grid; place-items: center; width: 36px; height: 44px; min-height: 44px; margin: 0 -4px 0 0; padding: 0; }
.channel-message-more svg { display: block; width: 20px; height: 20px; }
@media (max-width: 360px) { .channel-message-card--owner .channel-action-item { width: 50px; min-width: 50px; } .channel-message-meta .channel-message-time { font-size: .6875rem; } }
/* Нижняя панель ленты: «Новая запись» у владельца, «Подписаться» у читателя */
.channel-new-post { width: 100%; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 12px; background: var(--surface-selected); color: var(--action); font: inherit; font-weight: 600; cursor: pointer; }
.channel-new-post svg { width: 21px; height: 21px; }
.channel-new-post:hover { background: color-mix(in srgb, var(--surface-selected) 85%, var(--text-primary)); }
.channel-new-post:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.channels-screen--channel, .channels-screen--thread { padding-bottom: 8px; }
.channel-message-card--owner .message-attachment-media { height: auto; max-height: 70vh; object-fit: cover; }
@@ -24,6 +24,7 @@
.channels-list-controls {
display: grid;
gap: 12px;
padding-top: var(--page-top-gap);
}
.channels-inline-search {
+31 -127
View File
@@ -850,26 +850,19 @@
/* Поле сообщения занимает почти всю ширину; действия прижаты к правому краю. */
.composer-slot:has(> .dm-chat-input) {
padding-left: 0;
padding-right: 4px;
}
/* Нижняя панель чата — поверхность с линией сверху, как у канала и обсуждения */
.dm-chat-input {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
position: relative;
z-index: 10;
margin-inline: 0;
padding: 10px 0;
border-top: 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
gap: 4px;
padding-left: 0;
padding-right: 0;
margin-inline: calc(-1 * var(--page-gutter));
padding: 9px var(--page-gutter) calc(11px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border-subtle);
background: var(--surface);
gap: 6px;
}
@@ -891,98 +884,16 @@
.chat-header-peer-btn {
width: 100%;
min-width: 0;
min-height: 46px;
padding: 2px 0;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 7px;
text-align: left;
cursor: pointer;
}
.chat-header-peer-btn .chat-header-avatar-slot {
flex: 0 0 40px;
}
.chat-header-peer-text {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
}
.chat-header-peer-name,
.chat-header-peer-meta {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-header-peer-name {
font-size: 14px;
line-height: 1.15;
font-weight: 650;
color: var(--text-primary);
}
.chat-header-peer-meta {
font-size: 11px;
line-height: 1.15;
font-weight: 500;
color: var(--text-secondary);
}
/* ===== DM header regression fix (2026-09-02) =====
* The peer identity button intentionally spans the whole free header area.
* Its avatar uses a 119% glass overlay, so neither the center cell nor the
* button may clip overflow. Text keeps its own ellipsis boundary instead.
*/
.chat-header-peer-btn {
overflow: visible;
padding-inline: 2px;
}
.chat-header-peer-btn .chat-header-avatar-slot {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
flex: 0 0 44px;
overflow: visible;
}
.chat-header-peer-btn .chat-header-avatar-slot .chat-header-avatar {
overflow: visible;
}
.chat-header-peer-text {
flex: 1 1 auto;
overflow: hidden;
}
/* Шапка чата — общая «визитка» (topbar.css .topbar-identity). У аватара стеклянный ободок на 119%,
* поэтому ни кнопка, ни слот аватара не обрезают содержимое; текст обрезается сам. */
.chat-header-peer-btn { width: 100%; overflow: visible; }
.chat-header-peer-btn .chat-header-avatar-slot { width: 36px; height: 36px; min-width: 36px; flex: 0 0 36px; overflow: visible; }
.chat-header-peer-btn .chat-header-avatar-slot .avatar.chat-header-avatar { width: 36px; height: 36px; min-width: 36px; min-height: 36px; overflow: visible; }
.chat-header-peer-text { flex: 1 1 auto; overflow: hidden; }
/* Shell state + Chat-owned composer adjustment (moved out of app-shell.css). */
.app-shell--scroll-nested.keyboard-open .composer-slot .dm-chat-input {
padding-bottom: 0;
padding-bottom: 9px;
}
@@ -1393,35 +1304,28 @@
margin-top: 2px;
}
.chat-header-peer-btn { border: 0; background: transparent; color: inherit; font: inherit; }
.chat-header-peer-btn:hover { background: var(--surface); }
.chat-header-peer-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
.chat-header-peer-name { font-size: var(--text-md); font-weight: var(--weight-bold); }
.chat-header-peer-meta { font-size: var(--text-xs); }
.dm-chat-screen .bubble {
max-width: min(80%, 34rem);
padding: 8px 12px 6px;
border-radius: var(--radius-lg);
max-width: min(85%, 34rem);
padding: 10px 12px 7px;
border-radius: 17px;
color: var(--text-primary);
font-size: var(--text-md);
line-height: var(--leading-md);
font-size: .9375rem;
line-height: 1.375rem;
box-shadow: none;
overflow-wrap: anywhere;
}
.dm-chat-screen .bubble.in {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-bottom-left-radius: var(--radius-xs);
border-top-left-radius: var(--radius-lg);
}
.dm-chat-screen .bubble.out {
background: color-mix(in srgb, var(--accent) 24%, var(--surface));
border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border-subtle));
border-bottom-right-radius: var(--radius-xs);
border-top-right-radius: var(--radius-lg);
}
.dm-chat-screen .bubble-meta { font-size: .6875rem; line-height: 1rem; color: var(--text-secondary); }
.dm-chat-screen .bubble-status--read { color: var(--accent); }
.dm-chat-screen .bubble.in { background: var(--surface); border-bottom-left-radius: 5px; }
.dm-chat-screen .bubble.out { background: var(--bubble-out); border-bottom-right-radius: 5px; }
/* Время и статус — в конце последней строки (эксперимент V4, принят); не влезают — переносятся */
.dm-chat-screen .bubble-text { display: inline; }
.dm-chat-screen .bubble-meta { float: right; margin: 5px 0 0 10px; display: inline-flex; align-items: center; gap: 4px; font-size: .6875rem; line-height: 1rem; color: color-mix(in srgb, var(--text-secondary) 70%, var(--text-primary)); }
.dm-chat-screen .bubble::after { content: ''; display: table; clear: both; }
.dm-chat-screen .bubble-status { display: inline-flex; min-width: 0; color: inherit; text-shadow: none; }
.dm-chat-screen .bubble-status svg { width: 16px; height: 16px; }
.dm-chat-screen .bubble-status--read { color: var(--action); }
.dm-chat-screen .bubble-status--failed { color: var(--danger); }
.dm-chat-screen .bubble-delivery-note { clear: both; }
.dm-day-separator { display: flex; justify-content: center; margin: 8px 0; }
.dm-day-separator span { padding: 3px 10px; border-radius: var(--radius-pill); color: var(--text-secondary); background: var(--surface); border: 1px solid var(--border-subtle); font-size: var(--text-xs); line-height: var(--leading-xs); }
@@ -1431,7 +1335,7 @@
border: 1px solid var(--border-control);
border-radius: 22px;
color: var(--text-primary);
background: var(--surface);
background: var(--surface-raised);
text-shadow: none;
caret-color: var(--accent);
font-size: 1rem;
@@ -200,3 +200,7 @@
.arweave-uploads-menu-btn .app-overflow-dots {
margin: auto;
}
/* Проверка Solana RPC: длинные адреса узлов переносятся, а не раздвигают экран */
.solana-rpc-card { min-width: 0; }
.solana-rpc-url, .solana-rpc-details { min-width: 0; overflow-wrap: anywhere; }
+8 -4
View File
@@ -151,13 +151,17 @@
}
/* QR для второго устройства: по центру карточки, во всю доступную ширину до 280 px.
* Фон всегда светлый — тёмный QR на тёмном фоне камеры читают плохо. */
.qr-image {
width: 64px;
height: 64px;
border-radius: 8px;
width: min(280px, 100%);
aspect-ratio: 1;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background: var(--text-primary);
background: #ffffff;
}
.qr-image svg { display: block; width: 100%; height: 100%; }
/* Pairing approve/reject use their semantic button roles; legacy green/red surfaces were not observable in Stage 3.1. */
+1 -3
View File
@@ -757,7 +757,7 @@ button.dm-via-node:hover { border-color: color-mix(in srgb, var(--rel-link) 50%,
.dm-row-time { font-size: var(--text-xs); font-weight: 400; color: var(--text-secondary); }
.dm-contact-note { flex-shrink: 0; padding: 0; border: 0; border-radius: 0; background: none; color: var(--rel-contact); font-size: var(--text-xs); font-weight: var(--weight-medium); }
.dm-contact-note--close_friend { color: var(--rel-family); }
.dm-filter-row { margin-bottom: var(--space-1); }
.dm-filter-row { margin: 0 0 var(--space-1); }
.dm-row-meta-col { align-self: stretch; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 6px; }
.dm-row-meta-line { display: flex; align-items: center; gap: 0; }
.dm-unread-badge { min-width: 20px; height: 20px; padding: 0 6px; display: grid; place-items: center; border-radius: var(--radius-pill); color: var(--on-accent); background: var(--accent); border: 0; font-size: var(--text-xs); font-weight: var(--weight-bold); }
@@ -766,6 +766,4 @@ button.dm-via-node:hover { border-color: color-mix(in srgb, var(--rel-link) 50%,
.dm-divider::before, .dm-divider::after { content: none; }
.contact-search-results { gap: 0; margin-top: var(--space-3); }
.contact-search-results .contact-search-results-title { margin: 0 0 var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); }
.dm-filter-row { gap: 6px; }
.dm-filter-row .chip { padding: 0 12px; }
.dm-row-last-message.is-encrypted { font-style: italic; }
+74 -189
View File
@@ -1,203 +1,88 @@
/* Notifications feature styles. */
/* Уведомления: плоский список как в чатах – аватар, имя и время, ниже текст; тонкие разделители от текста. */
.notifications-screen { align-content: start; }
.notifications-screen .notification-feed-tabs { margin-bottom: var(--space-1); }
/* ===== Notifications glass style ===== */
.notifications-screen {
position: relative;
isolation: isolate;
color: var(--text-primary);
min-height: 100%;
align-content: start;
}
.notifications-list { display: grid; }
.notifications-screen::before {
content: "";
position: absolute;
inset: -12px -12px 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(320px 320px at 86% 12%, color-mix(in srgb, var(--rel-contact) 20%, transparent), transparent 72%),
radial-gradient(280px 280px at 18% 82%, color-mix(in srgb, var(--rel-contact) 14%, transparent), transparent 72%),
radial-gradient(260px 260px at 70% 65%, color-mix(in srgb, var(--warning) 12%, transparent), transparent 75%),
var(--surface);
}
.notifications-screen .tabs {
background: var(--surface);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid transparent;
border-radius: 16px;
}
.notifications-screen .tab-btn {
color: var(--text-secondary);
}
.notifications-screen .tab-btn.active {
background: color-mix(in srgb, var(--warning) 12%, transparent);
border: 1px solid transparent;
color: var(--warning);
}
.notifications-screen .notifications-list > .card {
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-subtle);
border-radius: 20px;
box-shadow: 0 0 60px color-mix(in srgb, var(--accent) 12%, transparent);
}
.notifications-screen::before {
content: none;
}
/* Contacts overflow menu body portal: avoids topbar overflow hit-testing on desktop/Android. */
/* ===== Notifications: social event cards ===== */
.notifications-screen .notification-card {
gap: 10px;
}
.notification-identity {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.notification-avatar {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
flex: 0 0 auto;
}
.notification-identity-text {
min-width: 0;
flex: 1 1 auto;
}
.notification-identity-primary {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.notification-person-name {
color: var(--text-primary);
font-weight: 700;
overflow-wrap: anywhere;
}
.notification-login,
.notification-time-separator,
.notification-time {
color: var(--text-secondary);
font-size: 12px;
}
.notification-action,
.notification-content {
.notification-group-title {
margin: 0;
}
.notification-action {
padding: var(--space-3) 0 var(--space-1);
color: var(--text-secondary);
font-size: 13px;
font-size: var(--text-xs);
line-height: var(--leading-xs);
font-weight: var(--weight-bold);
}
.notification-card {
position: relative;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: var(--space-3);
padding: var(--space-3) 0 0;
border-radius: var(--radius-md);
}
.notification-content {
.notification-avatar { width: 40px; height: 40px; min-width: 40px; min-height: 40px; }
.notification-card__body {
display: grid;
gap: 2px;
min-width: 0;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--divider-soft);
}
.notification-card__head { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
.notification-card__name {
flex: 1 1 auto;
min-width: 0;
color: var(--text-primary);
font-size: 15px;
line-height: 1.45;
font-size: var(--text-md);
font-weight: var(--weight-bold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-card__time { flex: 0 0 auto; color: var(--text-secondary); font-size: var(--text-xs); }
/* Непрочитанное: точка-акцент перед временем. */
.notification-card--new .notification-card__time::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: var(--accent);
vertical-align: 1px;
}
.notification-card__action { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-sm); }
.notification-card__text {
margin: 0;
color: var(--text-primary);
font-size: var(--text-md);
line-height: var(--leading-md);
white-space: pre-wrap;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notification-engagement { display: flex; align-items: center; gap: var(--space-4); padding-top: var(--space-1); color: var(--text-secondary); }
.notification-engagement-item { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); line-height: 1; }
.notification-engagement-icon { display: inline-flex; }
.notification-engagement-icon svg { width: 14px; height: 14px; }
.notification-engagement {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
padding-top: 2px;
color: var(--text-secondary);
}
.notification-card--clickable { cursor: pointer; }
.notification-card--clickable:hover { background: color-mix(in srgb, var(--surface-selected) 45%, transparent); }
.notification-card--clickable:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
.notification-engagement-item {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 24px;
font-size: 12px;
line-height: 1;
}
.notification-engagement-icon {
font-size: 15px;
}
.notifications-screen .notification-card--clickable {
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease;
}
.notifications-screen .notification-card--clickable:hover,
.notifications-screen .notification-card--clickable:focus-visible {
border-color: var(--border-subtle);
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
outline: none;
}
.notifications-screen .notification-card--clickable:active {
transform: scale(0.99);
}
/* Уведомления: информационные блоки без рамок. Hover/focus не возвращает
* обводку — различение сохраняется фоном и лёгкой реакцией на нажатие. */
.notifications-screen .notifications-list > .notification-card,
.notifications-screen .notifications-list > .notification-card:hover,
.notifications-screen .notifications-list > .notification-card:focus,
.notifications-screen .notifications-list > .notification-card:focus-visible,
.notifications-screen .notifications-list > .notification-card:active {
border: 0;
outline: 0;
}
.notifications-screen .notifications-list > .notification-empty-state.card, .notifications-screen .notification-empty-state { margin-top: var(--space-5); padding: var(--space-5) var(--space-4); border: 0; background: none; box-shadow: none; text-align: center; justify-items: center; }
.notifications-screen .notification-empty-state strong { font-size: var(--text-md); color: var(--text-primary); }
.notifications-screen .notification-empty-state .meta-muted { max-width: 30ch; font-size: var(--text-sm); }
.notifications-screen .notification-feed-tabs { margin-bottom: var(--space-2); }
.notification-empty-state { margin-top: var(--space-5); padding: var(--space-5) var(--space-4); text-align: center; justify-items: center; }
.notification-empty-state strong { color: var(--text-primary); font-size: var(--text-md); }
.notification-empty-state .meta-muted { max-width: 30ch; font-size: var(--text-sm); }
+410 -128
View File
@@ -49,11 +49,11 @@
.initial-splash__logo-wrap::before {
content: "";
position: absolute;
inset: 16%;
/* Сияние гаснет в пределах ~четверти размера знака — не дотягивается до краёв экрана. */
inset: -14%;
z-index: -1;
border-radius: 50%;
background: color-mix(in srgb, var(--warning) 20%, transparent);
filter: blur(30px);
background: radial-gradient(closest-side, color-mix(in srgb, var(--warning) 15%, transparent), color-mix(in srgb, var(--warning) 6%, transparent) 55%, transparent);
animation: shine-halo-breathe 4.8s ease-in-out infinite;
}
@@ -66,7 +66,7 @@
filter:
drop-shadow(0 0 8px color-mix(in srgb, var(--warning) 72%, transparent))
drop-shadow(0 8px 22px color-mix(in srgb, var(--warning) 38%, transparent));
animation: shine-logo-breathe 4.8s ease-in-out infinite;
animation: shine-logo-breathe 5.3s ease-in-out infinite;
}
@@ -83,13 +83,47 @@
.auth-screen--welcome {
position: relative;
isolation: isolate;
align-content: center;
/* Знак с надписью — у оптического центра, действия — ближе к низу, под большой палец. */
grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr);
align-content: stretch;
justify-items: center;
gap: clamp(12px, 2.4vh, 20px);
padding-block: 8px;
gap: 0;
padding-block: clamp(var(--space-5), 6vh, 56px) calc(var(--space-5) + env(safe-area-inset-bottom));
font-family: var(--font-main);
}
.auth-screen--welcome .auth-logo-wrap { align-self: end; }
.auth-screen--welcome .shine-actions {
align-self: end;
width: min(100%, 390px);
grid-template-columns: 1fr 1fr;
gap: var(--space-3) var(--space-2);
}
.auth-screen--welcome .shine-actions > :is(.shine-btn, .secondary-btn) {
grid-column: 1 / -1;
width: 100%;
height: 52px;
min-height: 48px;
border-radius: var(--radius-lg);
font-size: var(--text-lg);
font-weight: var(--weight-bold);
}
.auth-screen--welcome :is(.start-language-link, .start-settings-link) {
min-height: var(--control-height);
border: 0;
background: transparent;
box-shadow: none;
color: var(--text-secondary);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
}
.auth-screen--welcome .start-language-link { color: var(--accent); }
.auth-screen--welcome .start-settings-link::after { content: none; }
.screen-content.no-app-chrome:has(> .auth-screen--welcome) {
overflow: hidden;
@@ -97,29 +131,6 @@
}
.auth-screen--welcome::before {
content: "";
position: absolute;
inset: -14px -14px calc(-24px - env(safe-area-inset-bottom));
z-index: -2;
background:
radial-gradient(circle at 50% 18%, color-mix(in srgb, var(--warning) 8%, transparent), transparent 26%),
radial-gradient(circle at 50% 67%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 38%),
var(--background);
}
.auth-screen--welcome::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--text-primary) 2%, transparent) 50%, transparent);
opacity: 0.7;
}
.auth-screen--lower {
align-content: start;
padding-top: clamp(80px, 18vh, 180px);
@@ -134,7 +145,7 @@
filter:
drop-shadow(0 0 8px color-mix(in srgb, var(--warning) 72%, transparent))
drop-shadow(0 8px 22px color-mix(in srgb, var(--warning) 38%, transparent));
animation: shine-logo-breathe 4.8s ease-in-out infinite;
animation: shine-logo-breathe 5.3s ease-in-out infinite;
}
@@ -145,26 +156,60 @@
}
.auth-logo-wrap::before {
/* Сияние знака: два медленно вращающихся навстречу веера широких мазков. Периоды взаимно простые —
мазки то сходятся, то расходятся и почти не повторяются. Эллипс шире по горизонтали, к краям гаснет. */
.auth-logo-wrap::before,
.auth-logo-wrap::after {
--halo: color-mix(in srgb, var(--warning) 34%, transparent);
--halo-soft: color-mix(in srgb, var(--warning) 15%, transparent);
content: "";
position: absolute;
inset: 16%;
/* Спад прозрачности по плавной кривой с длинным хвостом — без видимой границы овала. */
inset: -80%;
z-index: -1;
pointer-events: none;
border-radius: 50%;
background: color-mix(in srgb, var(--warning) 20%, transparent);
filter: blur(30px);
animation: shine-halo-breathe 4.8s ease-in-out infinite;
-webkit-mask: radial-gradient(closest-side, #000 12%, rgb(0 0 0 / 0.8) 22%, rgb(0 0 0 / 0.58) 33%, rgb(0 0 0 / 0.4) 44%, rgb(0 0 0 / 0.26) 55%, rgb(0 0 0 / 0.16) 65%, rgb(0 0 0 / 0.09) 75%, rgb(0 0 0 / 0.045) 84%, rgb(0 0 0 / 0.018) 92%, transparent);
mask: radial-gradient(closest-side, #000 12%, rgb(0 0 0 / 0.8) 22%, rgb(0 0 0 / 0.58) 33%, rgb(0 0 0 / 0.4) 44%, rgb(0 0 0 / 0.26) 55%, rgb(0 0 0 / 0.16) 65%, rgb(0 0 0 / 0.09) 75%, rgb(0 0 0 / 0.045) 84%, rgb(0 0 0 / 0.018) 92%, transparent);
filter: blur(24px);
}
.auth-logo-wrap::before {
background: conic-gradient(from 0deg, var(--halo) 0deg, transparent 58deg, var(--halo-soft) 104deg, transparent 150deg, var(--halo) 212deg, var(--halo-soft) 250deg, transparent 296deg, var(--halo) 360deg);
transform: scaleY(0.5);
animation: shine-halo-spin 83s linear infinite, shine-halo-breathe 7.3s ease-in-out infinite;
animation-delay: var(--halo-phase-1, 0s), var(--halo-phase-2, 0s);
}
.auth-logo-wrap::after {
--halo: color-mix(in srgb, color-mix(in srgb, var(--warning) 82%, var(--accent)) 28%, transparent);
background: conic-gradient(from 40deg, transparent 0deg, var(--halo) 36deg, transparent 92deg, var(--halo-soft) 140deg, transparent 176deg, var(--halo) 238deg, transparent 290deg, var(--halo-soft) 322deg, transparent 360deg);
transform: scaleY(0.5);
animation: shine-halo-spin 127s linear infinite reverse, shine-halo-breathe 11.9s ease-in-out infinite;
animation-delay: var(--halo-phase-3, 0s), var(--halo-phase-4, -4s);
}
/* На светлом фоне золото темнеет в коричневый — сияние тише. */
:root[data-theme='light'] .auth-logo-wrap::before,
:root[data-theme='light'] .auth-logo-wrap::after {
--halo: color-mix(in srgb, var(--warning) 18%, transparent);
--halo-soft: color-mix(in srgb, var(--warning) 8%, transparent);
}
.auth-brand {
margin: -4px 0 clamp(4px, 1vh, 10px);
margin: clamp(20px, 3.4vh, 32px) 0 0;
color: var(--text-primary);
font-size: clamp(42px, 9vw, 56px);
font-weight: 500;
line-height: 1;
letter-spacing: 0;
text-shadow: 0 3px 22px color-mix(in srgb, var(--warning) 20%, transparent);
}
.auth-brand .shine-wordmark {
display: block;
width: auto;
height: 0.5em;
overflow: visible;
}
@@ -206,14 +251,12 @@
}
.shine-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.shine-local-demo-btn {
min-height: 32px;
padding: 5px 12px;
@@ -245,8 +288,15 @@
@keyframes shine-halo-breathe {
0%, 100% { opacity: 0.66; transform: scale(0.96); }
50% { opacity: 1; transform: scale(1.05); }
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* Сначала поворот, потом сплющивание: эллипс всегда лежит горизонтально и не тянется к верхнему краю. */
@keyframes shine-halo-spin {
from { transform: scaleY(0.5) rotate(0deg); }
to { transform: scaleY(0.5) rotate(360deg); }
}
@@ -265,16 +315,9 @@
.initial-splash__brand {
font-size: 40px;
}
.auth-screen--welcome {
gap: 10px;
}
.auth-logo-wrap {
width: 206px;
}
.auth-brand {
font-size: 40px;
margin-bottom: 2px;
}
.shine-actions {
gap: 10px;
}
@@ -292,6 +335,7 @@
.initial-splash__logo-wrap::before,
.auth-logo,
.auth-logo-wrap::before,
.auth-logo-wrap::after,
.shine-btn {
animation: none;
transition: none;
@@ -456,7 +500,6 @@
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .field-label,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .auth-copy,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .status-line,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-word-number {
color: var(--preauth-muted);
}
@@ -516,11 +559,6 @@
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .preauth-local-demo-btn {
min-height: 44px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-toggle,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .session-item {
border-color: var(--preauth-line);
@@ -554,11 +592,6 @@
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-screen {
gap: 12px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-form {
gap: 14px;
padding: 16px;
@@ -592,10 +625,19 @@
.registration-login-status {
display: block;
min-height: var(--leading-sm);
margin: 0;
font-size: 13px;
font-size: var(--text-sm);
line-height: var(--leading-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.registration-login-status.is-available::before { content: "✓ "; }
.registration-login-status.is-checking { color: var(--text-secondary); }
.registration-faq-link {
justify-self: center;
@@ -607,8 +649,6 @@
}
@media (max-width: 360px) {
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) {
padding-inline: 12px;
@@ -626,11 +666,6 @@
}
.login-panel--wide {
width: min(100%, 420px);
}
.login-panel-title {
font-size: 28px;
font-weight: 700;
@@ -807,12 +842,35 @@
}
/* Глаз — внутри поля пароля, у правого края. */
.registration-password-single .inline-input-row {
position: relative;
display: block;
}
.registration-password-single .inline-input-row .input {
width: 100%;
padding-right: 48px;
}
.registration-password-toggle {
position: absolute;
top: 50%;
right: 2px;
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
border-radius: 14px;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-secondary);
transform: translateY(-50%);
}
.registration-password-toggle .key-toggle-icon {
width: 20px;
height: 20px;
}
@@ -833,7 +891,6 @@
}
/* Новый двухшаговый login flow */
.login-remote-server {
width: 100%;
@@ -854,11 +911,6 @@
}
.login-device-preparation {
width: 100%;
}
.secret-generation-overlay {
position: fixed;
inset: 0;
@@ -912,58 +964,302 @@
}
/* ===== Вход через другое устройство: заголовок внутри панели ===== */
.auth-screen--other-device {
justify-content: flex-start;
padding-top: max(18px, env(safe-area-inset-top));
/* Сохранение ключей: заголовок-итог, список с переключателями, короткий совет. */
.keys-lead { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-1) 0; }
.keys-lead__title { margin: 0; color: var(--text-primary); font-size: var(--text-xl); line-height: var(--leading-xl); font-weight: var(--weight-bold); }
.keys-lead__text { margin: 0; color: var(--text-secondary); font-size: var(--text-md); line-height: var(--leading-md); text-wrap: pretty; }
.keys-lead__text + .keys-lead__text { margin-top: calc(-1 * var(--space-1)); }
.keys-list {
display: grid;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
overflow: hidden;
}
.auth-screen--other-device .login-panel {
margin-top: 0;
}
.login-panel-inline-back {
width: 100%;
min-height: 42px;
.keys-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 2px;
font-size: 18px;
font-weight: 700;
text-align: left;
gap: var(--space-3);
min-height: 64px;
padding: var(--space-3) var(--space-4);
cursor: pointer;
}
.keys-row + .keys-row { border-top: 1px solid var(--border-subtle); }
.keys-row:has(.switch:disabled) { cursor: default; }
.keys-row__text { display: grid; gap: 2px; flex: 1 1 auto; min-width: 0; }
.keys-row__title { color: var(--text-primary); font-size: var(--text-md); line-height: var(--leading-md); font-weight: var(--weight-medium); }
.keys-row__hint { color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-sm); text-wrap: pretty; }
.keys-note { margin: 0; padding: 0 var(--space-1); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-sm); }
.keys-actions { display: grid; gap: var(--space-2); margin-top: var(--space-2); }
/* Оплата регистрации: «обновить баланс» — второстепенная кнопка-иконка, пока грузится — крутится. */
.payment-refresh-btn {
display: grid;
place-items: center;
width: 44px;
min-width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: transparent;
color: var(--action);
}
.payment-refresh-btn svg { width: 22px; height: 22px; }
.payment-refresh-btn.is-loading:disabled { opacity: 1; cursor: progress; }
.payment-refresh-btn.is-loading svg { animation: secret-generation-spin 0.9s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.payment-refresh-btn.is-loading svg { animation: none; }
}
.login-panel-inline-back > span:first-child {
font-size: 24px;
line-height: 1;
/* ===== Вход с другого устройства: код, таймер, шаги ===== */
.pairing-who {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-md);
text-align: center;
}
.pairing-code-block { display: grid; gap: var(--space-4); }
.pairing-code-block[hidden] { display: none; }
.pairing-code-card {
display: grid;
justify-items: center;
gap: var(--space-3);
padding: var(--space-5) var(--space-4) var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
}
.pairing-code-card__label {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.pairing-code {
color: var(--text-primary);
font-size: clamp(30px, 9vw, 38px);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.06em;
line-height: 1.1;
white-space: nowrap;
user-select: all;
}
.pairing-timer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-3);
width: min(100%, 260px);
}
.pairing-timer__bar {
height: 4px;
border-radius: var(--radius-pill);
background: var(--surface-raised);
overflow: hidden;
}
.pairing-timer__bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--accent);
transition: width 1s linear;
}
.pairing-timer__text {
min-width: 3.2em;
color: var(--text-secondary);
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
text-align: right;
}
.pairing-online {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.pairing-online.is-online { color: var(--success); }
.pairing-online.is-online::before { content: "● "; }
.pairing-steps {
display: grid;
gap: var(--space-2);
margin: 0;
padding: 0 var(--space-2);
list-style: none;
counter-reset: pairing-step;
}
.pairing-steps > li {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
font-size: var(--text-md);
line-height: var(--leading-md);
text-wrap: pretty;
counter-increment: pairing-step;
}
.pairing-steps > li::before {
content: counter(pairing-step);
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--surface-selected);
color: var(--action);
font-size: var(--text-xs);
font-weight: var(--weight-bold);
}
.pairing-waiting,
.pairing-notice {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
text-align: center;
}
.pairing-notice[hidden] { display: none; }
.pairing-notice > p { margin: 0; text-wrap: balance; }
.pairing-notice.is-error {
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--danger) 10%, transparent);
color: var(--danger);
font-size: var(--text-md);
}
.pairing-spinner {
flex: 0 0 auto;
width: 14px;
height: 14px;
border: 2px solid var(--border-control);
border-top-color: var(--accent);
border-radius: 50%;
animation: secret-generation-spin 0.9s linear infinite;
}
.pairing-spinner[hidden],
.pairing-password[hidden],
.pairing-login > .primary-btn[hidden] { display: none; }
/* Узкие экраны: шаги остаются в одну строку. */
@media (max-width: 360px) {
.pairing-steps > li { font-size: var(--text-sm); }
}
@media (prefers-reduced-motion: reduce) {
.pairing-spinner { animation: none; }
.pairing-timer__bar > span { transition: none; }
}
/* ===== UX refinements 2026-08-29 ===== */
/* Start: larger breathing room between brand and actions. */
.auth-screen--welcome .auth-brand {
margin-bottom: clamp(34px, 6vh, 58px);
/* Настройки входа: все серверы в одной карточке, статус — пилюля справа от подписи (она же «проверить ещё раз»). */
.entry-servers {
display: grid;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
}
.auth-screen--welcome .shine-actions {
margin-top: 0;
.entry-server {
display: grid;
gap: var(--space-2);
padding: var(--space-3) var(--space-4) var(--space-4);
}
.entry-server + .entry-server { border-top: 1px solid var(--border-subtle); }
/* Entry settings actions use the existing Start button visual language without huge width side effects. */
.screen-content.preauth-flow .server-check-btn.shine-btn {
width: auto;
height: 44px;
min-height: 44px;
padding-inline: 18px;
border-radius: 14px;
font-size: 15px;
.entry-server__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.entry-server__hint {
margin: calc(-1 * var(--space-1)) 0 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: var(--leading-xs);
overflow-wrap: anywhere;
}
.entry-status {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 var(--space-3);
border: 0;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--action) 14%, transparent);
color: var(--action);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
white-space: nowrap;
}
.entry-status:is(.is-available, .is-unavailable)::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.entry-status.is-available {
color: var(--success);
background: color-mix(in srgb, var(--success) 15%, transparent);
}
.entry-status.is-unavailable {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 15%, transparent);
}
.entry-status.is-checking:disabled {
opacity: 1;
cursor: progress;
color: var(--text-secondary);
background: transparent;
}
.entry-settings__actions {
display: grid;
gap: var(--space-2);
width: 100%;
margin-top: var(--space-2);
}
.screen-content.preauth-flow .auth-footer-actions .shine-btn {
@@ -994,19 +1290,6 @@
}
.screen-content.preauth-flow .topbar { background: transparent; }
.auth-screen--welcome .shine-actions { gap: var(--space-3); }
.auth-screen--welcome .start-language-link,
.auth-screen--welcome .start-settings-link {
min-height: var(--control-height);
border: 0;
background: transparent;
box-shadow: none;
color: var(--accent);
font-size: var(--text-md);
font-weight: var(--weight-medium);
}
.auth-screen--welcome .start-settings-link { color: var(--text-secondary); font-size: var(--text-sm); }
.auth-screen--welcome .start-settings-link::after { content: none; }
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-choice-screen .topbar { margin-bottom: var(--space-4); }
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-choice-screen .topbar__title { display: block; }
@@ -1025,7 +1308,6 @@
text-underline-offset: 3px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar { min-height: 56px; margin-bottom: var(--space-2); grid-template-columns: 44px minmax(0, 1fr) 44px; }
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar { min-height: 56px; margin-bottom: calc(var(--page-top-gap) - 10px); /* + промежуток .stack 10px */ grid-template-columns: 44px minmax(0, 1fr) 44px; }
.auth-screen--welcome .shine-actions { width: min(100%, 420px); justify-self: center; }
.auth-screen--welcome .shine-actions > :is(.shine-btn, .secondary-btn) { width: 100%; min-height: 48px; font-size: var(--text-md); font-weight: var(--weight-bold); }
+1 -1
View File
@@ -1,6 +1,6 @@
/* Карточка профиля (js/components/profile-card.js): свой и чужой профиль в одной системе с «О канале».
* Селекторы привязаны к .user-profile-screen, чтобы перекрыть старую геометрию profile.css. */
.user-profile-screen { gap: 0; min-height: 0; overflow-x: hidden; }
.user-profile-screen { gap: 0; min-height: 0; overflow-x: clip; } /* clip, а не hidden: hidden обрезал бы и свечение аватарки сверху */
.user-profile-screen::before { content: none; }
.user-profile-screen .user-profile-body { display: grid; gap: var(--space-4); padding: var(--space-2) 0 var(--space-5); }
.user-profile-screen .user-profile-status:empty { display: none; }
+14 -87
View File
@@ -142,15 +142,25 @@
}
.screen-content.settings-bordered-actions :is(.secondary-btn, .ghost-btn, .text-btn, .shine-btn, .settings-bordered-btn, .profile-relative-suggest-item) {
/* Второстепенные действия – та же роль, что во всём приложении: прозрачная кнопка, рамка, цвет «действия». */
.screen-content.settings-bordered-actions .profile-relative-suggest-item {
min-height: var(--control-height);
padding-inline: 14px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: none;
color: var(--text-primary);
font-size: var(--text-md);
}
.screen-content.settings-bordered-actions :is(.secondary-btn, .ghost-btn, .text-btn, .shine-btn, .settings-bordered-btn) {
min-height: var(--control-height);
padding-inline: 14px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: transparent;
box-shadow: none;
color: var(--action);
font-size: var(--text-md);
font-weight: var(--weight-medium);
text-shadow: none;
backdrop-filter: none;
@@ -171,9 +181,9 @@
font-size: var(--text-md);
font-weight: var(--weight-medium);
min-height: var(--control-height);
border: 1px solid var(--border-subtle);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface);
background: transparent;
color: var(--danger);
box-shadow: none;
}
@@ -276,45 +286,6 @@
.language-choice-actions .shine-btn { height: 50px; }
/* ===== Скрытые расширенные настройки ===== */
.settings-shine-logo-button {
width: min(72vw, 280px);
min-height: 118px;
margin: 2px auto 10px;
padding: 6px 12px;
display: grid;
place-items: center;
border: 0;
background: transparent;
box-shadow: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.settings-shine-logo-button:hover,
.settings-shine-logo-button:focus,
.settings-shine-logo-button:active {
border: 0;
background: transparent;
box-shadow: none;
transform: none;
}
.settings-shine-logo-button:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 70%, transparent);
outline-offset: 4px;
border-radius: 22px;
}
.settings-shine-logo {
display: block;
width: 100%;
max-height: 126px;
object-fit: contain;
pointer-events: none;
filter: drop-shadow(0 12px 30px color-mix(in srgb, var(--accent) 16%, transparent));
}
.settings-feature-toggle-row {
min-height: 76px;
padding: 15px 16px;
@@ -343,50 +314,6 @@
color: var(--text-secondary);
}
.settings-feature-switch {
appearance: none;
-webkit-appearance: none;
flex: 0 0 auto;
width: 48px;
height: 28px;
margin: 0;
padding: 2px;
border: 1px solid var(--border-control);
border-radius: var(--radius-pill);
background: var(--surface-selected);
cursor: pointer;
transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease;
}
.settings-feature-switch::after {
content: '';
display: block;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--text-secondary);
box-shadow: var(--shadow-sm);
transition: transform var(--duration-fast) ease, background-color var(--duration-fast) ease;
}
.settings-feature-switch:checked {
border-color: var(--accent);
background: var(--accent);
}
.settings-feature-switch:checked::after {
transform: translateX(20px);
background: var(--on-accent);
}
.settings-feature-switch:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 82%, transparent);
outline-offset: 3px;
}
.settings-shine-logo-button { justify-self: center; }
.settings-shine-logo { width: 48px; height: 48px; max-height: 48px; object-fit: contain; }
.settings-shine-logo-button { min-height: 0; padding: 0; margin: 0 auto; }
.settings-sections { gap: var(--space-4); }
.settings-theme-card { gap: var(--space-2); }
.settings-theme-editor { width: 100%; }
+47 -32
View File
@@ -1,19 +1,32 @@
:root {
--background: #12141f;
--surface: #1c2031;
--surface-selected: #272c45;
--text-primary: #eceefa;
--text-secondary: #a0a6c2;
--border-subtle: #343a55;
--border-control: #6d7396;
--accent: #a9b4ff;
--on-accent: #151a3d;
--focus-ring: #a9b4ff;
--reaction-active: #ff9bb3;
--danger: #ffa7a3;
--success: #8fdab4;
--warning: #e8c585;
--scrim: #00000099;
/* Палитра по умолчанию — «Сияние» (js/services/theme-service.js, пресет aurora) */
--background: #121419;
--surface: #1c1f27;
--surface-selected: #302b43;
--surface-raised: #272a34;
--text-primary: #f1f1f5;
--text-secondary: #a8abb8;
--border-subtle: #303440;
--border-control: #454858;
--accent: #b8aef5;
--on-accent: #211c34;
--action: #c6bff5;
--bubble-out: #39324d;
--focus-ring: #b8aef5;
--reaction-active: #b8aef5;
--danger: #f2a1ad;
--success: #91d4b0;
--warning: #edc487;
--info: #a8c9f3;
--scrim: #06080cb3;
/* Производные состояния: считаются от ролей, поэтому работают с любой палитрой */
--accent-hover: color-mix(in srgb, var(--accent) 86%, var(--text-primary));
--accent-pressed: color-mix(in srgb, var(--accent) 88%, var(--background));
--disabled-bg: color-mix(in srgb, var(--surface-raised) 80%, var(--background));
--disabled-text: color-mix(in srgb, var(--text-secondary) 72%, var(--background));
--quote-bg: color-mix(in srgb, var(--background) 70%, var(--surface));
--quote-line: color-mix(in srgb, var(--accent) 55%, var(--text-secondary));
--divider-soft: color-mix(in srgb, var(--border-subtle) 70%, var(--background));
/* Язык связей: один цвет — один тип отношения во всём приложении (граф, личные, профиль). */
--rel-contact: #8c63ff; /* обычная связь / контакт */
--rel-family: #f0b82e; /* семья / близкий круг */
@@ -48,6 +61,8 @@
--space-5: 24px;
--space-6: 32px;
--page-gutter: var(--space-4);
/* Зазор между шапкой и первым элементом экрана – один на всё приложение. */
--page-top-gap: var(--space-3);
/* Скругления */
--radius-xs: 8px;
--radius-sm: 10px;
@@ -80,21 +95,25 @@
}
:root[data-theme='light'] {
--background: #eef0f5;
--background: #f3f2f7;
--surface: #ffffff;
--surface-selected: #dde3f5;
--text-primary: #1e2233;
--text-secondary: #5c6279;
--border-subtle: #d5d9e5;
--border-control: #8a90a8;
--accent: #4c5caa;
--surface-selected: #e8e3fa;
--surface-raised: #ecebf2;
--text-primary: #1d1b26;
--text-secondary: #5f5b6e;
--border-subtle: #e0dde8;
--border-control: #a19db2;
--accent: #5d4fc4;
--on-accent: #ffffff;
--focus-ring: #4c5caa;
--reaction-active: #c02a55;
--danger: #b3261e;
--success: #2c6b4a;
--warning: #7d5a10;
--scrim: #302c2566;
--action: #5244b5;
--bubble-out: #e6e0fb;
--focus-ring: #5d4fc4;
--reaction-active: #5d4fc4;
--danger: #b3304a;
--success: #2d7552;
--warning: #8a5a0e;
--info: #2f5f9e;
--scrim: #1d1b2666;
--shadow: 0 16px 42px rgba(30, 34, 51, 0.14);
--shadow-sm: 0 2px 8px rgba(30, 34, 51, 0.08);
--rel-contact: #6b3fe0;
@@ -141,10 +160,6 @@ a {
text-decoration: none;
}
.notification-card--new { background: var(--accent-soft); border-color: var(--accent); }
.notification-card--new::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 10px currentColor; position: absolute; right: 12px; top: 12px; opacity: .9; }
.notification-card { position: relative; }
.notification-new-divider { text-align: center; font-size: 11px; letter-spacing: .12em; opacity: .72; padding: 6px 0; }
.channel-view-topbar { position: relative; }